tekivex-ui 2.3.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 (92) 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 +20 -32
  10. package/dist/index.d.ts +1 -1
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +7942 -8903
  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/TkxDataGrid.d.ts +16 -2
  32. package/dist/src/components/TkxDataGrid.d.ts.map +1 -1
  33. package/dist/src/components/TkxForm.d.ts +33 -3
  34. package/dist/src/components/TkxForm.d.ts.map +1 -1
  35. package/dist/src/components/TkxImage.d.ts.map +1 -1
  36. package/dist/src/components/TkxTypography.d.ts +1 -1
  37. package/dist/src/components/TkxTypography.d.ts.map +1 -1
  38. package/dist/src/engine/tkx.d.ts.map +1 -1
  39. package/dist/src/headless/index.d.ts +18 -0
  40. package/dist/src/headless/index.d.ts.map +1 -0
  41. package/dist/src/headless/useControllable.d.ts +20 -0
  42. package/dist/src/headless/useControllable.d.ts.map +1 -0
  43. package/dist/src/headless/useDebounce.d.ts +14 -0
  44. package/dist/src/headless/useDebounce.d.ts.map +1 -0
  45. package/dist/src/headless/useDisclosure.d.ts +20 -0
  46. package/dist/src/headless/useDisclosure.d.ts.map +1 -0
  47. package/dist/src/headless/useFormState.d.ts +44 -0
  48. package/dist/src/headless/useFormState.d.ts.map +1 -0
  49. package/dist/src/headless/useIntersectionObserver.d.ts +23 -0
  50. package/dist/src/headless/useIntersectionObserver.d.ts.map +1 -0
  51. package/dist/src/headless/useListSelection.d.ts +29 -0
  52. package/dist/src/headless/useListSelection.d.ts.map +1 -0
  53. package/dist/src/headless/useLocalStorage.d.ts +9 -0
  54. package/dist/src/headless/useLocalStorage.d.ts.map +1 -0
  55. package/dist/src/headless/useMediaQuery.d.ts +21 -0
  56. package/dist/src/headless/useMediaQuery.d.ts.map +1 -0
  57. package/dist/src/headless/useRovingTabIndex.d.ts +33 -0
  58. package/dist/src/headless/useRovingTabIndex.d.ts.map +1 -0
  59. package/dist/src/headless/useThrottle.d.ts +9 -0
  60. package/dist/src/headless/useThrottle.d.ts.map +1 -0
  61. package/dist/src/i18n/index.d.ts +47 -2
  62. package/dist/src/i18n/index.d.ts.map +1 -1
  63. package/dist/src/themes/index.d.ts.map +1 -1
  64. package/package.json +61 -7
  65. package/src/charts/TkxAreaChart.tsx +119 -0
  66. package/src/charts/TkxBarChart.tsx +115 -0
  67. package/src/charts/TkxDonutChart.tsx +145 -0
  68. package/src/charts/TkxLineChart.tsx +135 -0
  69. package/src/charts/TkxPieChart.tsx +82 -0
  70. package/src/charts/TkxRadarChart.tsx +90 -0
  71. package/src/charts/TkxScatterChart.tsx +102 -0
  72. package/src/charts/index.ts +20 -0
  73. package/src/charts/shared.ts +45 -0
  74. package/src/components/TkxDataGrid.tsx +532 -268
  75. package/src/components/TkxForm.tsx +67 -15
  76. package/src/components/TkxImage.tsx +1 -1
  77. package/src/components/TkxTypography.tsx +4 -2
  78. package/src/engine/tkx.ts +60 -3
  79. package/src/headless/index.ts +44 -0
  80. package/src/headless/useControllable.ts +40 -0
  81. package/src/headless/useDebounce.ts +35 -0
  82. package/src/headless/useDisclosure.ts +28 -0
  83. package/src/headless/useFormState.ts +111 -0
  84. package/src/headless/useIntersectionObserver.ts +51 -0
  85. package/src/headless/useListSelection.ts +101 -0
  86. package/src/headless/useLocalStorage.ts +46 -0
  87. package/src/headless/useMediaQuery.ts +37 -0
  88. package/src/headless/useRovingTabIndex.ts +84 -0
  89. package/src/headless/useThrottle.ts +46 -0
  90. package/src/i18n/I18nProvider.tsx +1 -1
  91. package/src/i18n/index.ts +678 -38
  92. 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,
@@ -206,12 +206,14 @@ export function TkxText({
206
206
  );
207
207
  }
208
208
 
209
+ // Use semantic HTML elements when appropriate
210
+ const tag = strong ? 'strong' : italic ? 'em' : 'span';
211
+
209
212
  return createElement(
210
- 'span',
213
+ tag,
211
214
  {
212
215
  style: {
213
216
  color,
214
- fontWeight: strong ? 600 : 'inherit',
215
217
  fontStyle: italic ? 'italic' : 'normal',
216
218
  textDecoration: decorations.length > 0 ? decorations.join(' ') : 'none',
217
219
  ...style,
package/src/engine/tkx.ts CHANGED
@@ -709,6 +709,8 @@ function declarationsToCSS(decls: Declarations): string {
709
709
 
710
710
  const registry = new Map<string, string>(); // hash → full CSS block
711
711
  let styleEl: HTMLStyleElement | null = null;
712
+ // Constructable CSSStyleSheet for O(1) rule insertion (Chrome 73+, Firefox 101+, Safari 16.4+)
713
+ let adoptedSheet: CSSStyleSheet | null = null;
712
714
 
713
715
  function getOrCreateStyleEl(): HTMLStyleElement {
714
716
  if (!styleEl || !styleEl.isConnected) {
@@ -722,13 +724,64 @@ function getOrCreateStyleEl(): HTMLStyleElement {
722
724
  return styleEl;
723
725
  }
724
726
 
727
+ function getAdoptedSheet(): CSSStyleSheet | null {
728
+ if (adoptedSheet) return adoptedSheet;
729
+ try {
730
+ // Constructable Stylesheets API — supported in all evergreen browsers
731
+ const sheet = new CSSStyleSheet();
732
+ document.adoptedStyleSheets = [...document.adoptedStyleSheets, sheet];
733
+ adoptedSheet = sheet;
734
+ return sheet;
735
+ } catch {
736
+ return null;
737
+ }
738
+ }
739
+
725
740
  function registerBlock(hash: string, cssBlock: string): void {
726
741
  if (registry.has(hash)) return;
727
742
  registry.set(hash, cssBlock);
728
- if (typeof document !== 'undefined') {
729
- const el = getOrCreateStyleEl();
730
- el.textContent += cssBlock + '\n';
743
+ if (typeof document === 'undefined') return;
744
+
745
+ const sheet = getAdoptedSheet();
746
+ if (sheet) {
747
+ // O(1) per rule — insertRule appends without touching existing text
748
+ // Split multi-rule blocks (e.g. base + media query) and insert each one.
749
+ // We use a simple regex split on top-level `}` boundaries.
750
+ const rules = splitCSSRules(cssBlock);
751
+ for (const rule of rules) {
752
+ try {
753
+ sheet.insertRule(rule, sheet.cssRules.length);
754
+ } catch {
755
+ // Fallback for any rule the browser rejects (e.g. invalid pseudo)
756
+ getOrCreateStyleEl().textContent += rule + '\n';
757
+ }
758
+ }
759
+ } else {
760
+ // Fallback for environments that don't support adoptedStyleSheets
761
+ getOrCreateStyleEl().textContent += cssBlock + '\n';
762
+ }
763
+ }
764
+
765
+ /**
766
+ * Split a CSS string into individual top-level rules.
767
+ * Handles nested braces (media queries, @supports, etc.).
768
+ */
769
+ function splitCSSRules(css: string): string[] {
770
+ const rules: string[] = [];
771
+ let depth = 0;
772
+ let start = 0;
773
+ for (let i = 0; i < css.length; i++) {
774
+ if (css[i] === '{') depth++;
775
+ else if (css[i] === '}') {
776
+ depth--;
777
+ if (depth === 0) {
778
+ const rule = css.slice(start, i + 1).trim();
779
+ if (rule) rules.push(rule);
780
+ start = i + 1;
781
+ }
782
+ }
731
783
  }
784
+ return rules;
732
785
  }
733
786
 
734
787
  // ── Main TKX function ─────────────────────────────────────────────────────────
@@ -866,6 +919,10 @@ export function extractAtomicCSS(): string {
866
919
  export function resetAtomicCSS(): void {
867
920
  registry.clear();
868
921
  styleEl = null;
922
+ if (adoptedSheet && typeof document !== 'undefined') {
923
+ document.adoptedStyleSheets = document.adoptedStyleSheets.filter(s => s !== adoptedSheet);
924
+ adoptedSheet = null;
925
+ }
869
926
  }
870
927
 
871
928
  // ── cx — className merge helper (like clsx but integrated) ────────────────────
@@ -0,0 +1,44 @@
1
+ // ── @tekivex/ui/headless ─────────────────────────────────────────────────────
2
+ // Behavior-only primitives, hooks, and utilities — zero styles shipped.
3
+ // Use these to build completely custom UI on top of TekiVex's battle-tested
4
+ // accessibility, form, and interaction logic.
5
+ //
6
+ // Import from '@tekivex/ui/headless' — no CSS, no theme tokens, no components.
7
+ //
8
+ // @example
9
+ // import { useDisclosure, useFocusTrap, useFormState, useRovingTabIndex } from '@tekivex/ui/headless';
10
+ // ─────────────────────────────────────────────────────────────────────────────
11
+
12
+ // ── Accessibility hooks ──────────────────────────────────────────────────────
13
+ export {
14
+ useReducedMotion,
15
+ useHighContrast,
16
+ useFocusTrap,
17
+ useAnnounce,
18
+ useEscapeKey,
19
+ useClickOutside,
20
+ } from '../hooks';
21
+
22
+ // ── Form primitives ──────────────────────────────────────────────────────────
23
+ export { useTkxForm } from '../components/TkxForm';
24
+ export type { FormInstance, ValidationRule } from '../components/TkxForm';
25
+
26
+ // ── Engine utilities ─────────────────────────────────────────────────────────
27
+ export { extractAtomicCSS, resetAtomicCSS, cx, tkxPlugin, tkxRemovePlugin, tkxListPlugins } from '../engine/tkx';
28
+ export { extractCSS, resetStyles, injectStyles, cssVar } from '../engine/css';
29
+ export { meetsAA, meetsAAA, contrastRatio } from '../engine/wcag';
30
+ export { sanitizeString, sanitizeProps } from '../engine/security';
31
+
32
+ // ── Interaction hooks ────────────────────────────────────────────────────────
33
+ // Additional behavior primitives beyond the base hook set.
34
+
35
+ export { useDisclosure } from './useDisclosure';
36
+ export { useRovingTabIndex } from './useRovingTabIndex';
37
+ export { useFormState } from './useFormState';
38
+ export { useListSelection } from './useListSelection';
39
+ export { useDebounce } from './useDebounce';
40
+ export { useThrottle } from './useThrottle';
41
+ export { useControllable } from './useControllable';
42
+ export { useMediaQuery } from './useMediaQuery';
43
+ export { useLocalStorage } from './useLocalStorage';
44
+ export { useIntersectionObserver } from './useIntersectionObserver';
@@ -0,0 +1,40 @@
1
+ import { useState, useCallback, useRef } from 'react';
2
+
3
+ /**
4
+ * Unified controlled/uncontrolled state management.
5
+ * The component accepts both a controlled `value` + `onChange` pair
6
+ * OR works standalone with internal state when they're not provided.
7
+ *
8
+ * This is the same pattern used by Radix UI, Mantine, and Ant Design internally.
9
+ *
10
+ * @example — in a custom Select component:
11
+ * const [currentValue, setCurrentValue] = useControllable({
12
+ * value: props.value, // may be undefined (uncontrolled)
13
+ * onChange: props.onChange, // may be undefined
14
+ * defaultValue: '',
15
+ * });
16
+ */
17
+ export function useControllable<T>({
18
+ value,
19
+ onChange,
20
+ defaultValue,
21
+ }: {
22
+ value?: T;
23
+ onChange?: (value: T) => void;
24
+ defaultValue: T;
25
+ }): [T, (next: T) => void] {
26
+ const isControlled = value !== undefined;
27
+ const [internalValue, setInternalValue] = useState<T>(defaultValue);
28
+ const onChangeRef = useRef(onChange);
29
+ onChangeRef.current = onChange;
30
+
31
+ const setValue = useCallback(
32
+ (next: T) => {
33
+ if (!isControlled) setInternalValue(next);
34
+ onChangeRef.current?.(next);
35
+ },
36
+ [isControlled],
37
+ );
38
+
39
+ return [isControlled ? value! : internalValue, setValue];
40
+ }
@@ -0,0 +1,35 @@
1
+ import { useState, useEffect, useCallback, useRef } from 'react';
2
+
3
+ /**
4
+ * Returns a debounced version of the value that only updates after the
5
+ * specified delay has passed without changes.
6
+ *
7
+ * @example
8
+ * const debouncedSearch = useDebounce(searchQuery, 300);
9
+ * useEffect(() => { fetchResults(debouncedSearch); }, [debouncedSearch]);
10
+ */
11
+ export function useDebounce<T>(value: T, delay: number): T {
12
+ const [debouncedValue, setDebouncedValue] = useState(value);
13
+ useEffect(() => {
14
+ const timer = setTimeout(() => setDebouncedValue(value), delay);
15
+ return () => clearTimeout(timer);
16
+ }, [value, delay]);
17
+ return debouncedValue;
18
+ }
19
+
20
+ /**
21
+ * Returns a debounced callback that only fires after the delay.
22
+ */
23
+ export function useDebouncedCallback<T extends (...args: any[]) => any>(
24
+ fn: T,
25
+ delay: number,
26
+ ): (...args: Parameters<T>) => void {
27
+ const fnRef = useRef(fn);
28
+ fnRef.current = fn;
29
+ const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
30
+
31
+ return useCallback((...args: Parameters<T>) => {
32
+ if (timerRef.current) clearTimeout(timerRef.current);
33
+ timerRef.current = setTimeout(() => fnRef.current(...args), delay);
34
+ }, [delay]);
35
+ }
@@ -0,0 +1,28 @@
1
+ import { useState, useCallback } from 'react';
2
+
3
+ export interface UseDisclosureReturn {
4
+ isOpen: boolean;
5
+ open: () => void;
6
+ close: () => void;
7
+ toggle: () => void;
8
+ }
9
+
10
+ /**
11
+ * Manages boolean open/close state for modals, drawers, popovers, dropdowns, etc.
12
+ *
13
+ * @example
14
+ * const { isOpen, open, close, toggle } = useDisclosure();
15
+ * return (
16
+ * <>
17
+ * <button onClick={open}>Open</button>
18
+ * {isOpen && <MyModal onClose={close} />}
19
+ * </>
20
+ * );
21
+ */
22
+ export function useDisclosure(initialState = false): UseDisclosureReturn {
23
+ const [isOpen, setIsOpen] = useState(initialState);
24
+ const open = useCallback(() => setIsOpen(true), []);
25
+ const close = useCallback(() => setIsOpen(false), []);
26
+ const toggle = useCallback(() => setIsOpen(v => !v), []);
27
+ return { isOpen, open, close, toggle };
28
+ }
@@ -0,0 +1,111 @@
1
+ import { useState, useCallback, useRef } from 'react';
2
+
3
+ export type FormFieldValue = string | number | boolean | string[] | null | undefined;
4
+
5
+ export interface FieldState {
6
+ value: FormFieldValue;
7
+ error: string | null;
8
+ touched: boolean;
9
+ dirty: boolean;
10
+ }
11
+
12
+ export interface UseFormStateOptions<T extends Record<string, FormFieldValue>> {
13
+ initialValues: T;
14
+ validate?: (values: T) => Partial<Record<keyof T, string>>;
15
+ }
16
+
17
+ export interface UseFormStateReturn<T extends Record<string, FormFieldValue>> {
18
+ values: T;
19
+ errors: Partial<Record<keyof T, string>>;
20
+ touched: Partial<Record<keyof T, boolean>>;
21
+ dirty: boolean;
22
+ isValid: boolean;
23
+ setValue: (name: keyof T, value: FormFieldValue) => void;
24
+ setValues: (values: Partial<T>) => void;
25
+ touchField: (name: keyof T) => void;
26
+ validate: () => boolean;
27
+ reset: () => void;
28
+ getFieldProps: (name: keyof T) => {
29
+ value: FormFieldValue;
30
+ onChange: (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => void;
31
+ onBlur: () => void;
32
+ 'aria-invalid': boolean | undefined;
33
+ 'aria-describedby': string | undefined;
34
+ };
35
+ }
36
+
37
+ /**
38
+ * Headless form state manager — no components, no context, no validation library required.
39
+ * Works with any input elements.
40
+ *
41
+ * @example
42
+ * const { values, errors, getFieldProps, validate } = useFormState({
43
+ * initialValues: { email: '', password: '' },
44
+ * validate: ({ email }) => ({
45
+ * email: !email.includes('@') ? 'Invalid email' : undefined,
46
+ * }),
47
+ * });
48
+ */
49
+ export function useFormState<T extends Record<string, FormFieldValue>>({
50
+ initialValues,
51
+ validate: validateFn,
52
+ }: UseFormStateOptions<T>): UseFormStateReturn<T> {
53
+ const [values, setValuesState] = useState<T>({ ...initialValues });
54
+ const [errors, setErrors] = useState<Partial<Record<keyof T, string>>>({});
55
+ const [touched, setTouched] = useState<Partial<Record<keyof T, boolean>>>({});
56
+ const initialRef = useRef(initialValues);
57
+
58
+ const setValue = useCallback((name: keyof T, value: FormFieldValue) => {
59
+ setValuesState(prev => ({ ...prev, [name]: value }));
60
+ }, []);
61
+
62
+ const setValues = useCallback((partial: Partial<T>) => {
63
+ setValuesState(prev => ({ ...prev, ...partial }));
64
+ }, []);
65
+
66
+ const touchField = useCallback((name: keyof T) => {
67
+ setTouched(prev => ({ ...prev, [name]: true }));
68
+ }, []);
69
+
70
+ const validate = useCallback(() => {
71
+ if (!validateFn) return true;
72
+ const newErrors = validateFn(values);
73
+ const filtered = Object.fromEntries(
74
+ Object.entries(newErrors).filter(([, v]) => v != null),
75
+ ) as Partial<Record<keyof T, string>>;
76
+ setErrors(filtered);
77
+ // Touch all fields so errors show
78
+ const allTouched = Object.fromEntries(
79
+ Object.keys(values).map(k => [k, true]),
80
+ ) as Partial<Record<keyof T, boolean>>;
81
+ setTouched(allTouched);
82
+ return Object.keys(filtered).length === 0;
83
+ }, [values, validateFn]);
84
+
85
+ const reset = useCallback(() => {
86
+ setValuesState({ ...initialRef.current });
87
+ setErrors({});
88
+ setTouched({});
89
+ }, []);
90
+
91
+ const dirty = Object.keys(values).some(
92
+ k => values[k as keyof T] !== initialRef.current[k as keyof T],
93
+ );
94
+
95
+ const isValid = Object.keys(errors).length === 0;
96
+
97
+ const getFieldProps = useCallback(
98
+ (name: keyof T) => ({
99
+ value: values[name],
100
+ onChange: (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
101
+ setValue(name, e.target.value);
102
+ },
103
+ onBlur: () => touchField(name),
104
+ 'aria-invalid': touched[name] && !!errors[name] ? true : undefined,
105
+ 'aria-describedby': errors[name] ? `${String(name)}-error` : undefined,
106
+ }),
107
+ [values, errors, touched, setValue, touchField],
108
+ );
109
+
110
+ return { values, errors, touched, dirty, isValid, setValue, setValues, touchField, validate, reset, getFieldProps };
111
+ }
@@ -0,0 +1,51 @@
1
+ import { useState, useEffect, useRef, type RefObject } from 'react';
2
+
3
+ export interface UseIntersectionObserverOptions {
4
+ threshold?: number | number[];
5
+ rootMargin?: string;
6
+ root?: Element | null;
7
+ /** Unobserve after first intersection (useful for lazy-load). Default: false */
8
+ once?: boolean;
9
+ }
10
+
11
+ export interface UseIntersectionObserverReturn {
12
+ ref: RefObject<HTMLElement | null>;
13
+ isIntersecting: boolean;
14
+ entry: IntersectionObserverEntry | null;
15
+ }
16
+
17
+ /**
18
+ * Observe when an element enters or leaves the viewport.
19
+ * Perfect for lazy loading, infinite scroll, and scroll-triggered animations.
20
+ *
21
+ * @example
22
+ * const { ref, isIntersecting } = useIntersectionObserver({ threshold: 0.1 });
23
+ * return <div ref={ref} style={{ opacity: isIntersecting ? 1 : 0 }}>Animated content</div>;
24
+ */
25
+ export function useIntersectionObserver({
26
+ threshold = 0,
27
+ rootMargin = '0px',
28
+ root = null,
29
+ once = false,
30
+ }: UseIntersectionObserverOptions = {}): UseIntersectionObserverReturn {
31
+ const ref = useRef<HTMLElement>(null);
32
+ const [entry, setEntry] = useState<IntersectionObserverEntry | null>(null);
33
+ const observerRef = useRef<IntersectionObserver | null>(null);
34
+
35
+ useEffect(() => {
36
+ const el = ref.current;
37
+ if (!el || typeof IntersectionObserver === 'undefined') return;
38
+
39
+ observerRef.current = new IntersectionObserver(
40
+ ([e]) => {
41
+ setEntry(e);
42
+ if (once && e.isIntersecting) observerRef.current?.unobserve(el);
43
+ },
44
+ { threshold, rootMargin, root },
45
+ );
46
+ observerRef.current.observe(el);
47
+ return () => observerRef.current?.disconnect();
48
+ }, [threshold, rootMargin, root, once]);
49
+
50
+ return { ref, isIntersecting: entry?.isIntersecting ?? false, entry };
51
+ }
@@ -0,0 +1,101 @@
1
+ import { useState, useCallback, useMemo } from 'react';
2
+
3
+ export interface UseListSelectionOptions {
4
+ items: string[];
5
+ initialSelected?: string[];
6
+ /** Allow selecting multiple items. Default: true */
7
+ multiple?: boolean;
8
+ }
9
+
10
+ export interface UseListSelectionReturn {
11
+ selected: Set<string>;
12
+ isSelected: (id: string) => boolean;
13
+ toggle: (id: string) => void;
14
+ select: (id: string) => void;
15
+ deselect: (id: string) => void;
16
+ selectAll: () => void;
17
+ deselectAll: () => void;
18
+ toggleAll: () => void;
19
+ allSelected: boolean;
20
+ someSelected: boolean;
21
+ selectedCount: number;
22
+ selectedArray: string[];
23
+ }
24
+
25
+ /**
26
+ * Manages selection state for lists, tables, and grids.
27
+ * Supports single and multi-select modes.
28
+ *
29
+ * @example
30
+ * const { isSelected, toggle, selectedArray } = useListSelection({ items: rows.map(r => r.id) });
31
+ */
32
+ export function useListSelection({
33
+ items,
34
+ initialSelected = [],
35
+ multiple = true,
36
+ }: UseListSelectionOptions): UseListSelectionReturn {
37
+ const [selected, setSelected] = useState<Set<string>>(() => new Set(initialSelected));
38
+
39
+ const toggle = useCallback((id: string) => {
40
+ setSelected(prev => {
41
+ const next = new Set(prev);
42
+ if (next.has(id)) {
43
+ next.delete(id);
44
+ } else {
45
+ if (!multiple) next.clear();
46
+ next.add(id);
47
+ }
48
+ return next;
49
+ });
50
+ }, [multiple]);
51
+
52
+ const select = useCallback((id: string) => {
53
+ setSelected(prev => {
54
+ if (prev.has(id)) return prev;
55
+ const next = multiple ? new Set(prev) : new Set<string>();
56
+ next.add(id);
57
+ return next;
58
+ });
59
+ }, [multiple]);
60
+
61
+ const deselect = useCallback((id: string) => {
62
+ setSelected(prev => {
63
+ if (!prev.has(id)) return prev;
64
+ const next = new Set(prev);
65
+ next.delete(id);
66
+ return next;
67
+ });
68
+ }, []);
69
+
70
+ const selectAll = useCallback(() => {
71
+ if (multiple) setSelected(new Set(items));
72
+ }, [items, multiple]);
73
+
74
+ const deselectAll = useCallback(() => setSelected(new Set()), []);
75
+
76
+ const toggleAll = useCallback(() => {
77
+ setSelected(prev => {
78
+ const allSel = items.every(id => prev.has(id));
79
+ return allSel ? new Set() : new Set(items);
80
+ });
81
+ }, [items]);
82
+
83
+ const allSelected = items.length > 0 && items.every(id => selected.has(id));
84
+ const someSelected = items.some(id => selected.has(id));
85
+ const selectedArray = useMemo(() => Array.from(selected), [selected]);
86
+
87
+ return {
88
+ selected,
89
+ isSelected: useCallback((id: string) => selected.has(id), [selected]),
90
+ toggle,
91
+ select,
92
+ deselect,
93
+ selectAll,
94
+ deselectAll,
95
+ toggleAll,
96
+ allSelected,
97
+ someSelected,
98
+ selectedCount: selected.size,
99
+ selectedArray,
100
+ };
101
+ }