tekivex-ui 2.3.0 → 2.5.2
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/README.md +436 -83
- 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 +20 -32
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7942 -8903
- 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/TkxDataGrid.d.ts +16 -2
- 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/TkxTypography.d.ts +1 -1
- package/dist/src/components/TkxTypography.d.ts.map +1 -1
- 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/TkxDataGrid.tsx +532 -268
- package/src/components/TkxForm.tsx +67 -15
- package/src/components/TkxImage.tsx +1 -1
- package/src/components/TkxTypography.tsx +4 -2
- package/src/engine/tkx.ts +60 -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={{
|
|
@@ -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
|
-
|
|
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
|
|
729
|
-
|
|
730
|
-
|
|
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
|
+
}
|