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
package/src/engine/tkx.ts
CHANGED
|
@@ -660,6 +660,10 @@ function resolveUtility(u: string): Declarations | null {
|
|
|
660
660
|
// ── CSS variable passthrough: [--my-var:value] ────────────────────────────
|
|
661
661
|
if ((m = u.match(/^\[(--[a-zA-Z0-9-]+):(.+)]$/))) { return { [m[1]]: m[2] }; }
|
|
662
662
|
|
|
663
|
+
// ── Plugin utilities ─────────────────────────────────────────────────────
|
|
664
|
+
const pluginResult = resolvePluginUtility(u);
|
|
665
|
+
if (pluginResult) return pluginResult;
|
|
666
|
+
|
|
663
667
|
return null;
|
|
664
668
|
}
|
|
665
669
|
|
|
@@ -705,6 +709,8 @@ function declarationsToCSS(decls: Declarations): string {
|
|
|
705
709
|
|
|
706
710
|
const registry = new Map<string, string>(); // hash → full CSS block
|
|
707
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;
|
|
708
714
|
|
|
709
715
|
function getOrCreateStyleEl(): HTMLStyleElement {
|
|
710
716
|
if (!styleEl || !styleEl.isConnected) {
|
|
@@ -718,15 +724,66 @@ function getOrCreateStyleEl(): HTMLStyleElement {
|
|
|
718
724
|
return styleEl;
|
|
719
725
|
}
|
|
720
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
|
+
|
|
721
740
|
function registerBlock(hash: string, cssBlock: string): void {
|
|
722
741
|
if (registry.has(hash)) return;
|
|
723
742
|
registry.set(hash, cssBlock);
|
|
724
|
-
if (typeof document
|
|
725
|
-
|
|
726
|
-
|
|
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';
|
|
727
762
|
}
|
|
728
763
|
}
|
|
729
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
|
+
}
|
|
783
|
+
}
|
|
784
|
+
return rules;
|
|
785
|
+
}
|
|
786
|
+
|
|
730
787
|
// ── Main TKX function ─────────────────────────────────────────────────────────
|
|
731
788
|
|
|
732
789
|
export type TkxInput = string | false | null | undefined | TkxInput[] | Record<string, boolean>;
|
|
@@ -862,6 +919,10 @@ export function extractAtomicCSS(): string {
|
|
|
862
919
|
export function resetAtomicCSS(): void {
|
|
863
920
|
registry.clear();
|
|
864
921
|
styleEl = null;
|
|
922
|
+
if (adoptedSheet && typeof document !== 'undefined') {
|
|
923
|
+
document.adoptedStyleSheets = document.adoptedStyleSheets.filter(s => s !== adoptedSheet);
|
|
924
|
+
adoptedSheet = null;
|
|
925
|
+
}
|
|
865
926
|
}
|
|
866
927
|
|
|
867
928
|
// ── cx — className merge helper (like clsx but integrated) ────────────────────
|
|
@@ -872,3 +933,61 @@ export function resetAtomicCSS(): void {
|
|
|
872
933
|
export function cx(...classes: (string | false | null | undefined)[]): string {
|
|
873
934
|
return classes.filter(Boolean).join(' ');
|
|
874
935
|
}
|
|
936
|
+
|
|
937
|
+
// ── Plugin System ────────────────────────────────────────────────────────────
|
|
938
|
+
/**
|
|
939
|
+
* TKX Plugin API — extend the utility engine with custom utilities.
|
|
940
|
+
*
|
|
941
|
+
* @example
|
|
942
|
+
* ```tsx
|
|
943
|
+
* import { tkxPlugin, tkx } from '@tekivex/ui';
|
|
944
|
+
*
|
|
945
|
+
* // Register custom utilities
|
|
946
|
+
* tkxPlugin({
|
|
947
|
+
* name: 'brand',
|
|
948
|
+
* utilities: {
|
|
949
|
+
* 'brand-gradient': { background: 'linear-gradient(135deg, #667eea, #764ba2)' },
|
|
950
|
+
* 'brand-text': { color: '#667eea', fontWeight: '700' },
|
|
951
|
+
* 'card-shadow': { boxShadow: '0 8px 32px rgba(102, 126, 234, 0.15)' },
|
|
952
|
+
* 'glass': {
|
|
953
|
+
* background: 'rgba(255, 255, 255, 0.05)',
|
|
954
|
+
* backdropFilter: 'blur(12px)',
|
|
955
|
+
* border: '1px solid rgba(255, 255, 255, 0.1)',
|
|
956
|
+
* },
|
|
957
|
+
* },
|
|
958
|
+
* });
|
|
959
|
+
*
|
|
960
|
+
* // Use them like any built-in utility
|
|
961
|
+
* const cls = tkx('brand-gradient p-6 rounded-xl card-shadow');
|
|
962
|
+
* ```
|
|
963
|
+
*/
|
|
964
|
+
|
|
965
|
+
export interface TkxPluginDef {
|
|
966
|
+
name: string;
|
|
967
|
+
utilities: Record<string, Record<string, string>>;
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
const pluginRegistry = new Map<string, Record<string, Record<string, string>>>();
|
|
971
|
+
|
|
972
|
+
export function tkxPlugin(plugin: TkxPluginDef): void {
|
|
973
|
+
pluginRegistry.set(plugin.name, plugin.utilities);
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
export function tkxRemovePlugin(name: string): void {
|
|
977
|
+
pluginRegistry.delete(name);
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
export function tkxListPlugins(): string[] {
|
|
981
|
+
return Array.from(pluginRegistry.keys());
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
/**
|
|
985
|
+
* Resolve a plugin utility to CSS properties.
|
|
986
|
+
* Called internally by the TKX engine — but also exported for inspection.
|
|
987
|
+
*/
|
|
988
|
+
export function resolvePluginUtility(name: string): Record<string, string> | null {
|
|
989
|
+
for (const utilities of pluginRegistry.values()) {
|
|
990
|
+
if (utilities[name]) return utilities[name];
|
|
991
|
+
}
|
|
992
|
+
return null;
|
|
993
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { useState, useCallback } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Persists state to localStorage. Falls back to in-memory on parse errors or
|
|
5
|
+
* SSR (where localStorage is unavailable).
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* const [theme, setTheme] = useLocalStorage('theme', 'dark');
|
|
9
|
+
*/
|
|
10
|
+
export function useLocalStorage<T>(key: string, initialValue: T): [T, (value: T | ((prev: T) => T)) => void, () => void] {
|
|
11
|
+
const [storedValue, setStoredValue] = useState<T>(() => {
|
|
12
|
+
if (typeof window === 'undefined') return initialValue;
|
|
13
|
+
try {
|
|
14
|
+
const item = window.localStorage.getItem(key);
|
|
15
|
+
return item !== null ? (JSON.parse(item) as T) : initialValue;
|
|
16
|
+
} catch {
|
|
17
|
+
return initialValue;
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const setValue = useCallback(
|
|
22
|
+
(value: T | ((prev: T) => T)) => {
|
|
23
|
+
setStoredValue(prev => {
|
|
24
|
+
const next = typeof value === 'function' ? (value as (p: T) => T)(prev) : value;
|
|
25
|
+
if (typeof window !== 'undefined') {
|
|
26
|
+
try {
|
|
27
|
+
window.localStorage.setItem(key, JSON.stringify(next));
|
|
28
|
+
} catch {
|
|
29
|
+
// Quota exceeded or private browsing — silently ignore
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return next;
|
|
33
|
+
});
|
|
34
|
+
},
|
|
35
|
+
[key],
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
const removeValue = useCallback(() => {
|
|
39
|
+
setStoredValue(initialValue);
|
|
40
|
+
if (typeof window !== 'undefined') {
|
|
41
|
+
try { window.localStorage.removeItem(key); } catch { /* ignore */ }
|
|
42
|
+
}
|
|
43
|
+
}, [key, initialValue]);
|
|
44
|
+
|
|
45
|
+
return [storedValue, setValue, removeValue];
|
|
46
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Reactive media query hook. Returns true while the query matches.
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* const isMobile = useMediaQuery('(max-width: 767px)');
|
|
8
|
+
* const isDark = useMediaQuery('(prefers-color-scheme: dark)');
|
|
9
|
+
* const isTouch = useMediaQuery('(hover: none) and (pointer: coarse)');
|
|
10
|
+
*/
|
|
11
|
+
export function useMediaQuery(query: string): boolean {
|
|
12
|
+
const [matches, setMatches] = useState(() => {
|
|
13
|
+
if (typeof window === 'undefined') return false;
|
|
14
|
+
return window.matchMedia(query).matches;
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
if (typeof window === 'undefined') return;
|
|
19
|
+
const mq = window.matchMedia(query);
|
|
20
|
+
setMatches(mq.matches);
|
|
21
|
+
const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
|
|
22
|
+
mq.addEventListener('change', handler);
|
|
23
|
+
return () => mq.removeEventListener('change', handler);
|
|
24
|
+
}, [query]);
|
|
25
|
+
|
|
26
|
+
return matches;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Preset breakpoint hooks matching TekiVex's breakpoint scale. */
|
|
30
|
+
export function useBreakpoint() {
|
|
31
|
+
const sm = useMediaQuery('(min-width: 640px)');
|
|
32
|
+
const md = useMediaQuery('(min-width: 768px)');
|
|
33
|
+
const lg = useMediaQuery('(min-width: 1024px)');
|
|
34
|
+
const xl = useMediaQuery('(min-width: 1280px)');
|
|
35
|
+
const xxl = useMediaQuery('(min-width: 1536px)');
|
|
36
|
+
return { sm, md, lg, xl, xxl, isMobile: !sm, isTablet: sm && !lg, isDesktop: lg };
|
|
37
|
+
}
|