@cleen/ui-core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@cleen/ui-core",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "module": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "import": "./dist/index.js",
12
+ "types": "./dist/index.d.ts"
13
+ },
14
+ "./tailwind-preset": "./tailwind.preset.js",
15
+ "./tailwind-entry.css": "./tailwind-entry.css",
16
+ "./styles.css": "./dist/styles.css"
17
+ },
18
+ "scripts": {
19
+ "dev": "concurrently \"tsup --watch --onSuccess 'tsc'\" \"tailwindcss -w -c ./tailwind.config.js -i ./tailwind-entry.css -o ./dist/styles.css\"",
20
+ "build": "tsup && tailwindcss -m -c ./tailwind.config.js -i ./tailwind-entry.css -o ./dist/styles.css",
21
+ "typecheck": "tsc --noEmit",
22
+ "lint": "eslint \"src/**/*.{ts,tsx}\"",
23
+ "format": "prettier --write \"src/**/*.{ts,tsx}\""
24
+ },
25
+ "dependencies": {
26
+ "clsx": "^2.1.1",
27
+ "color2k": "^2.0.3",
28
+ "tailwind-merge": "^2.6.0",
29
+ "zustand": "^5.0.8"
30
+ },
31
+ "peerDependencies": {
32
+ "react": "^18.3.1",
33
+ "react-dom": "^18.3.1"
34
+ },
35
+ "devDependencies": {
36
+ "@types/react": "^19.1.10",
37
+ "concurrently": "^9.2.1",
38
+ "tailwindcss": "^3.4.17",
39
+ "tsup": "^8.5.1",
40
+ "typescript": "~5.8.3"
41
+ }
42
+ }
@@ -0,0 +1,56 @@
1
+ import { useEffect, useState } from 'react';
2
+
3
+ interface UseAnimateNumberProps {
4
+ targetNumber: number;
5
+ defaultNumber?: number;
6
+ duration?: number;
7
+ disabled?: boolean;
8
+ easeOut?: boolean;
9
+ }
10
+
11
+ // Easing function for smooth deceleration
12
+ const easeOutQuad = (progress: number): number => {
13
+ return 1 - (1 - progress) * (1 - progress);
14
+ };
15
+
16
+ export const useAnimateNumber = ({
17
+ targetNumber,
18
+ defaultNumber = 0,
19
+ duration = 1000,
20
+ disabled = false,
21
+ easeOut = true,
22
+ }: UseAnimateNumberProps): number => {
23
+ const [animatedNumber, setAnimatedNumber] = useState(defaultNumber);
24
+
25
+ useEffect(() => {
26
+ if (disabled) {
27
+ return;
28
+ }
29
+
30
+ let start: number | null = null;
31
+
32
+ const animate = (timestamp: number) => {
33
+ if (!start) start = timestamp;
34
+ const progressTime = timestamp - start;
35
+ let progress = Math.min(progressTime / duration, 1);
36
+
37
+ if (easeOut) {
38
+ progress = easeOutQuad(progress);
39
+ }
40
+
41
+ const progressValue = progress * targetNumber;
42
+ setAnimatedNumber(progressValue);
43
+ if (progressValue < targetNumber) {
44
+ requestAnimationFrame(animate);
45
+ }
46
+ };
47
+
48
+ requestAnimationFrame(animate);
49
+ }, [targetNumber, duration, disabled, easeOut]);
50
+
51
+ if (disabled) {
52
+ return targetNumber;
53
+ }
54
+
55
+ return animatedNumber;
56
+ };
@@ -0,0 +1,40 @@
1
+ import { useEffect, useState } from 'react';
2
+
3
+ interface UseControlledProps<T> {
4
+ value?: T;
5
+ defaultValue?: T;
6
+ onChange?: (value: T) => void;
7
+ }
8
+
9
+ export const useControlled = <T>({
10
+ value,
11
+ defaultValue,
12
+ onChange,
13
+ }: UseControlledProps<T>) => {
14
+ const isControlled = value !== undefined;
15
+ const [internalValue, setInternalValue] = useState<T | undefined>(
16
+ defaultValue
17
+ );
18
+
19
+ const currentValue = isControlled ? value : internalValue;
20
+
21
+ const handleChange = (newValue: T) => {
22
+ if (!isControlled) {
23
+ setInternalValue(newValue);
24
+ }
25
+
26
+ onChange?.(newValue);
27
+ };
28
+
29
+ useEffect(() => {
30
+ if (!isControlled) {
31
+ setInternalValue(value ?? defaultValue ?? undefined);
32
+ }
33
+ }, [value, defaultValue, isControlled]);
34
+
35
+ return {
36
+ value: currentValue,
37
+ isControlled,
38
+ handleChange,
39
+ };
40
+ };
@@ -0,0 +1,17 @@
1
+ import { useEffect, useState } from 'react';
2
+
3
+ export const useDebounce = <T>(value: T, delay: number): T => {
4
+ const [debouncedValue, setDebouncedValue] = useState<T>(value);
5
+
6
+ useEffect(() => {
7
+ const handler = setTimeout(() => {
8
+ setDebouncedValue(value);
9
+ }, delay);
10
+
11
+ return () => {
12
+ clearTimeout(handler);
13
+ };
14
+ }, [value, delay]);
15
+
16
+ return debouncedValue;
17
+ };
@@ -0,0 +1,33 @@
1
+ import { useState } from 'react';
2
+
3
+ interface UseDisclosureProps {
4
+ value?: boolean;
5
+ setValue?: (value: boolean) => void;
6
+ }
7
+
8
+ export const useDisclosure = (props?: UseDisclosureProps) => {
9
+ const [isOpenInternal, setIsOpenInternal] = useState(false);
10
+ const isOpen = props?.value !== undefined ? props.value : isOpenInternal;
11
+
12
+ const open = () => {
13
+ setIsOpenInternal(true);
14
+ props?.setValue?.(true);
15
+ };
16
+
17
+ const close = () => {
18
+ setIsOpenInternal(false);
19
+ props?.setValue?.(false);
20
+ };
21
+
22
+ const toggle = () => {
23
+ setIsOpenInternal(prev => !prev);
24
+ props?.setValue?.(!isOpen);
25
+ };
26
+
27
+ return {
28
+ isOpen,
29
+ open,
30
+ close,
31
+ toggle,
32
+ };
33
+ };
@@ -0,0 +1,38 @@
1
+ import { useEffect, useMemo, useState } from 'react';
2
+
3
+ interface UseFormProps<T> {
4
+ defaultValue: T | null;
5
+ resetOnDefaultValueChange?: boolean;
6
+ }
7
+
8
+ export const useForm = <T extends Record<string, unknown>>({
9
+ defaultValue,
10
+ resetOnDefaultValueChange = true,
11
+ }: UseFormProps<T>) => {
12
+ const [form, setForm] = useState<T | null>(defaultValue);
13
+
14
+ const reset = () => setForm(defaultValue);
15
+
16
+ const setField = (field: keyof T, value: T[keyof T]) => {
17
+ setForm(prev => ({ ...prev, [field]: value }) as T);
18
+ };
19
+
20
+ const isDirty = useMemo(
21
+ () => JSON.stringify(form) !== JSON.stringify(defaultValue),
22
+ [form, defaultValue]
23
+ );
24
+
25
+ useEffect(() => {
26
+ if (defaultValue && resetOnDefaultValueChange) {
27
+ setForm(defaultValue);
28
+ }
29
+ }, [defaultValue, resetOnDefaultValueChange]);
30
+
31
+ return {
32
+ form,
33
+ isDirty,
34
+ setForm,
35
+ setField,
36
+ reset,
37
+ };
38
+ };
@@ -0,0 +1,42 @@
1
+ import { useEffect, type RefObject } from 'react';
2
+
3
+ interface UseOutsideClickProps {
4
+ refs: RefObject<HTMLElement | null>[];
5
+ handler: (event: MouseEvent | TouchEvent) => void;
6
+ enabled?: boolean;
7
+ }
8
+
9
+ export function useOutsideClick({
10
+ refs,
11
+ handler,
12
+ enabled = true,
13
+ }: UseOutsideClickProps) {
14
+ useEffect(() => {
15
+ if (!enabled) return;
16
+
17
+ const listener = (event: MouseEvent | TouchEvent) => {
18
+ // Check if click is inside any of the referenced elements
19
+ const clickedInside = refs.some(ref => {
20
+ const el = ref.current;
21
+ if (!el) return false;
22
+ return el.contains(event.target as Node);
23
+ });
24
+
25
+ // If click is inside at least one element — ignore
26
+ if (clickedInside) return;
27
+
28
+ // Defer handler to next tick to allow all listeners to evaluate
29
+ // before any DOM mutations occur (prevents conflicts when multiple
30
+ // components use this hook simultaneously)
31
+ setTimeout(() => handler(event), 0);
32
+ };
33
+
34
+ document.addEventListener('mousedown', listener);
35
+ document.addEventListener('touchstart', listener);
36
+
37
+ return () => {
38
+ document.removeEventListener('mousedown', listener);
39
+ document.removeEventListener('touchstart', listener);
40
+ };
41
+ }, [refs, handler, enabled]);
42
+ }
@@ -0,0 +1,39 @@
1
+ import { useState } from 'react';
2
+
3
+ interface PaginationOptions {
4
+ initialPage?: number;
5
+ initialPageSize?: number;
6
+ }
7
+
8
+ interface PaginationState {
9
+ page: number;
10
+ setPage: (page: number) => void;
11
+ pageSize: number;
12
+ setPageSize: (size: number) => void;
13
+ handleNextPage: (page: number) => void;
14
+ handlePreviousPage: (page: number) => void;
15
+ handlePageChange: (page: number) => void;
16
+ }
17
+
18
+ export function usePaginationState(
19
+ options: PaginationOptions = {}
20
+ ): PaginationState {
21
+ const { initialPage = 1, initialPageSize = 10 } = options;
22
+
23
+ const [page, setPage] = useState<number>(initialPage);
24
+ const [pageSize, setPageSize] = useState<number>(initialPageSize);
25
+
26
+ const handleNextPage = (nextPage: number) => setPage(nextPage);
27
+ const handlePreviousPage = (prevPage: number) => setPage(prevPage);
28
+ const handlePageChange = (newPage: number) => setPage(newPage);
29
+
30
+ return {
31
+ page,
32
+ setPage,
33
+ pageSize,
34
+ setPageSize,
35
+ handleNextPage,
36
+ handlePreviousPage,
37
+ handlePageChange,
38
+ };
39
+ }
@@ -0,0 +1,69 @@
1
+ import type { Position } from '@/types/position';
2
+ import {
3
+ calculateOptimalPosition,
4
+ calculatePositionValues,
5
+ } from '@/utils/position';
6
+ import type { RefObject } from 'react';
7
+ import { useEffect, useMemo, useState } from 'react';
8
+
9
+ interface UsePositionCloseProps {
10
+ triggerRef: RefObject<HTMLElement | null>;
11
+ targetRef: RefObject<HTMLElement | null>;
12
+ position?: Position;
13
+ offset?: number;
14
+ isOpen: boolean;
15
+ }
16
+
17
+ /**
18
+ * Hook to calculate fixed positioning for overlays relative to trigger elements.
19
+ * Handles viewport-aware position optimization and click-outside detection.
20
+ */
21
+ export const usePositionClose = ({
22
+ triggerRef,
23
+ targetRef,
24
+ position = 'bottom-left',
25
+ offset = 8,
26
+ isOpen,
27
+ }: UsePositionCloseProps) => {
28
+ const [isMounted, setIsMounted] = useState(false);
29
+
30
+ // Calculate optimal position based on viewport constraints
31
+ const { positionStyles, optimalPosition } = useMemo(() => {
32
+ if (isMounted && targetRef.current && triggerRef.current) {
33
+ const overlayRect = targetRef.current.getBoundingClientRect();
34
+ const triggerRect = triggerRef.current.getBoundingClientRect();
35
+
36
+ // Find the best position to avoid viewport overflow
37
+ const optimalPosition = calculateOptimalPosition(
38
+ overlayRect,
39
+ triggerRect,
40
+ position
41
+ );
42
+
43
+ const coords = calculatePositionValues(
44
+ overlayRect,
45
+ triggerRect,
46
+ optimalPosition,
47
+ offset
48
+ );
49
+
50
+ return { positionStyles: coords, optimalPosition };
51
+ }
52
+ return { positionStyles: { top: 0, left: 0 }, optimal: position };
53
+ }, [triggerRef, targetRef, position, offset, isMounted]);
54
+
55
+ // Track mounting state for smooth positioning
56
+ useEffect(() => {
57
+ if (isOpen) {
58
+ setIsMounted(true);
59
+ } else {
60
+ setIsMounted(false);
61
+ }
62
+ }, [isOpen]);
63
+
64
+ return {
65
+ positionStyles,
66
+ optimalPosition,
67
+ isMounted,
68
+ };
69
+ };
@@ -0,0 +1,33 @@
1
+ import { useCallback, useState } from 'react';
2
+
3
+ export const useValidation = <T>(defaultValue: Partial<T>) => {
4
+ const [errors, setErrors] = useState<Partial<T>>(defaultValue);
5
+
6
+ const setError = useCallback((field: keyof T, value: Partial<T>[keyof T]) => {
7
+ setErrors(prev => {
8
+ prev[field] = value;
9
+
10
+ return { ...prev };
11
+ });
12
+ }, []);
13
+
14
+ const clearErrors = useCallback(() => {
15
+ setErrors(defaultValue);
16
+ }, [defaultValue]);
17
+
18
+ const clearError = useCallback((field: keyof T) => {
19
+ setErrors(prev => {
20
+ delete prev[field];
21
+
22
+ return { ...prev };
23
+ });
24
+ }, []);
25
+
26
+ return {
27
+ errors,
28
+ setError,
29
+ setErrors,
30
+ clearError,
31
+ clearErrors,
32
+ };
33
+ };
@@ -0,0 +1,52 @@
1
+ import { useEffect, type RefObject } from 'react';
2
+
3
+ interface UseWidthDynamicResizeParams<T> {
4
+ ref: RefObject<T>;
5
+ skip?: boolean;
6
+ }
7
+
8
+ export const useWidthDynamicResize = <T extends HTMLElement | null>(
9
+ { ref, skip }: UseWidthDynamicResizeParams<T>,
10
+ deps: unknown[]
11
+ ) => {
12
+ useEffect(() => {
13
+ const node = ref.current;
14
+
15
+ if (!node || skip) {
16
+ return;
17
+ }
18
+
19
+ // Capture current width before content changes
20
+ const currentWidth = node.offsetWidth;
21
+
22
+ // Set explicit width to enable smooth transitions
23
+ node.style.width = `${currentWidth}px`;
24
+
25
+ // Use requestAnimationFrame to allow DOM to update with new content
26
+ requestAnimationFrame(() => {
27
+ // Measure the natural width with new content
28
+ node.style.width = 'auto';
29
+ const newWidth = node.offsetWidth;
30
+
31
+ // Set back to old width momentarily
32
+ node.style.width = `${currentWidth}px`;
33
+
34
+ // Force reflow
35
+ void node.offsetHeight;
36
+
37
+ // Transition to new width
38
+ node.style.width = `${newWidth}px`;
39
+
40
+ // After transition, set to auto for flexibility
41
+ const handleTransitionEnd = () => {
42
+ if (node) {
43
+ node.style.width = 'auto';
44
+ }
45
+
46
+ node?.removeEventListener('transitionend', handleTransitionEnd);
47
+ };
48
+
49
+ node.addEventListener('transitionend', handleTransitionEnd);
50
+ });
51
+ }, deps);
52
+ };
package/src/index.ts ADDED
@@ -0,0 +1,21 @@
1
+ export * from './hooks/useAnimateNumber';
2
+ export * from './hooks/useControlled';
3
+ export * from './hooks/useDebounce';
4
+ export * from './hooks/useDisclosure';
5
+ export * from './hooks/useForm';
6
+ export * from './hooks/useOutsideClick';
7
+ export * from './hooks/usePaginationState';
8
+ export * from './hooks/usePositionClose';
9
+ export * from './hooks/useValidation';
10
+ export * from './hooks/useWatchResize';
11
+ export * from './store/colors';
12
+ export * from './types/position';
13
+ export * from './types/styles';
14
+ export * from './types/utils';
15
+ export * from './utils/audio';
16
+ export * from './utils/cn';
17
+ export * from './utils/colors';
18
+ export * from './utils/images';
19
+ export * from './utils/object';
20
+ export * from './utils/position';
21
+ export * from './utils/string';
@@ -0,0 +1,98 @@
1
+ import { create } from 'zustand';
2
+ import { persist } from 'zustand/middleware';
3
+
4
+ const PREFIX = '--cleen-';
5
+
6
+ const colorVars = [
7
+ 'white',
8
+ 'black',
9
+ 'gray',
10
+ 'pink',
11
+ 'purple',
12
+ 'indigo',
13
+ 'blue',
14
+ 'primary',
15
+ 'success',
16
+ 'warning',
17
+ 'error',
18
+ 'brand',
19
+ 'sidebar',
20
+ 'background',
21
+ ] as const;
22
+
23
+ export type ColorVar = (typeof colorVars)[number];
24
+
25
+ interface UseCleenColorsStore {
26
+ colors: Partial<Record<ColorVar, string>>;
27
+ getColors: () => Partial<Record<ColorVar, string>>;
28
+ getColor: (color: ColorVar) => string;
29
+ setColor: (colorVar: ColorVar, value: string) => void;
30
+ setColors: (colors: Partial<Record<ColorVar, string>>) => void;
31
+ resetColors: () => void;
32
+ resetColor: (color: ColorVar) => void;
33
+ }
34
+
35
+ export const useCleenColors = create<UseCleenColorsStore>()(
36
+ persist(
37
+ (set, get) => ({
38
+ colors: {},
39
+ getColors: () => {
40
+ const colors = get().colors;
41
+
42
+ return colorVars.reduce<Partial<Record<ColorVar, string>>>(
43
+ (prev, colorVar) => {
44
+ if (colors[colorVar]) {
45
+ prev[colorVar] = colors[colorVar];
46
+ } else {
47
+ prev[colorVar] = getComputedStyle(
48
+ document.documentElement
49
+ ).getPropertyValue(`${PREFIX}${colorVar}`);
50
+ }
51
+
52
+ return prev;
53
+ },
54
+ {}
55
+ );
56
+ },
57
+ getColor: (color: ColorVar) => {
58
+ const colors = get().colors;
59
+
60
+ if (colors[color]) {
61
+ return colors[color];
62
+ } else {
63
+ return getComputedStyle(document.documentElement).getPropertyValue(
64
+ `${PREFIX}${color}`
65
+ );
66
+ }
67
+ },
68
+ setColors: (colors: Partial<Record<ColorVar, string>>) =>
69
+ Object.entries(colors).map(([colorVar, value]) =>
70
+ set(state => ({
71
+ colors: {
72
+ ...state.colors,
73
+ [colorVar]: value,
74
+ },
75
+ }))
76
+ ),
77
+ setColor: (colorVar: ColorVar, value: string) => {
78
+ set(state => ({
79
+ colors: {
80
+ ...state.colors,
81
+ [colorVar]: value,
82
+ },
83
+ }));
84
+ },
85
+ resetColors: () => {
86
+ set({ colors: {} });
87
+ },
88
+ resetColor: (colorVar: ColorVar) => {
89
+ const colors = get().colors;
90
+ delete colors[colorVar];
91
+ set({ colors });
92
+ },
93
+ }),
94
+ {
95
+ name: 'cleen-colors',
96
+ }
97
+ )
98
+ );
@@ -0,0 +1,9 @@
1
+ export type Position =
2
+ | 'bottom-left'
3
+ | 'bottom-right'
4
+ | 'bottom'
5
+ | 'top-left'
6
+ | 'top-right'
7
+ | 'top'
8
+ | 'left'
9
+ | 'right';
@@ -0,0 +1,24 @@
1
+ import type { CSSProperties } from 'react';
2
+
3
+ // Helper interfaces to extract types of the classNames or styles props and pass in additional reserved class/style for className/style prop
4
+ export interface ComponentClassnames<
5
+ T extends {
6
+ classNames?: {
7
+ [key: string]: string | object;
8
+ };
9
+ },
10
+ > {
11
+ className?: string;
12
+ classNames?: T['classNames'];
13
+ }
14
+
15
+ export interface ComponentStyles<
16
+ T extends {
17
+ styles?: {
18
+ [key: string]: CSSProperties | object;
19
+ };
20
+ },
21
+ > {
22
+ style?: CSSProperties;
23
+ styles?: T['styles'];
24
+ }
@@ -0,0 +1,6 @@
1
+ export type HintedString<KnownValues extends string> =
2
+ | (string & {})
3
+ | KnownValues;
4
+
5
+ export type ArrayElement<ArrayType extends readonly unknown[]> =
6
+ ArrayType extends readonly (infer ElementType)[] ? ElementType : never;