feat: init
This commit is contained in:
3
packages/hooks/.eslintignore
Normal file
3
packages/hooks/.eslintignore
Normal file
@@ -0,0 +1,3 @@
|
||||
build/*
|
||||
dist/*
|
||||
out/*
|
||||
4
packages/hooks/.eslintrc.cjs
Normal file
4
packages/hooks/.eslintrc.cjs
Normal file
@@ -0,0 +1,4 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: ["@plane/eslint-config/library.js"],
|
||||
};
|
||||
4
packages/hooks/.prettierignore
Normal file
4
packages/hooks/.prettierignore
Normal file
@@ -0,0 +1,4 @@
|
||||
.turbo
|
||||
out/
|
||||
dist/
|
||||
build/
|
||||
5
packages/hooks/.prettierrc
Normal file
5
packages/hooks/.prettierrc
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"printWidth": 120,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "es5"
|
||||
}
|
||||
39
packages/hooks/package.json
Normal file
39
packages/hooks/package.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "@plane/hooks",
|
||||
"version": "1.1.0",
|
||||
"license": "AGPL-3.0",
|
||||
"description": "React hooks that are shared across multiple apps internally",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.cjs"
|
||||
},
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsdown",
|
||||
"dev": "tsdown --watch",
|
||||
"check:lint": "eslint . --max-warnings 6",
|
||||
"check:types": "tsc --noEmit",
|
||||
"check:format": "prettier --check \"**/*.{ts,tsx,md,json,css,scss}\"",
|
||||
"fix:lint": "eslint . --fix",
|
||||
"fix:format": "prettier --write \"**/*.{ts,tsx,md,json,css,scss}\"",
|
||||
"clean": "rm -rf .turbo && rm -rf .next && rm -rf node_modules && rm -rf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@plane/eslint-config": "workspace:*",
|
||||
"@plane/typescript-config": "workspace:*",
|
||||
"@types/node": "catalog:",
|
||||
"@types/react": "catalog:",
|
||||
"tsdown": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
"main": "./dist/index.cjs",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.cts"
|
||||
}
|
||||
4
packages/hooks/src/index.ts
Normal file
4
packages/hooks/src/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from "./use-hash-scroll";
|
||||
export * from "./use-local-storage";
|
||||
export * from "./use-outside-click-detector";
|
||||
export * from "./use-platform-os";
|
||||
128
packages/hooks/src/use-hash-scroll.ts
Normal file
128
packages/hooks/src/use-hash-scroll.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
type TArgs = {
|
||||
elementId: string;
|
||||
pathname: string;
|
||||
scrollDelay?: number;
|
||||
};
|
||||
|
||||
type TReturnType = {
|
||||
isHashMatch: boolean;
|
||||
hashIds: string[];
|
||||
scrollToElement: () => boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Custom hook for handling hash-based scrolling to a specific element
|
||||
* Supports multiple IDs in URL hash (comma-separated, space-separated, or other delimiters)
|
||||
*
|
||||
* @param {TArgs} args - The ID of the element to scroll to
|
||||
* @returns {TReturnType} Object containing hash match status and scroll function
|
||||
*/
|
||||
export const useHashScroll = (args: TArgs): TReturnType => {
|
||||
const { elementId, pathname, scrollDelay = 200 } = args;
|
||||
// State to track if the current hash contains the provided element ID
|
||||
const [isHashMatch, setIsHashMatch] = useState(false);
|
||||
// State to track all IDs found in the hash
|
||||
const [hashIds, setHashIds] = useState<string[]>([]);
|
||||
|
||||
/**
|
||||
* Scrolls to the element with the provided ID
|
||||
* @returns {boolean} - Whether the scroll was successful
|
||||
*/
|
||||
const scrollToElement = useCallback((): boolean => {
|
||||
try {
|
||||
const element = document.getElementById(elementId);
|
||||
|
||||
if (element) {
|
||||
setTimeout(() => {
|
||||
element.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "nearest",
|
||||
});
|
||||
}, scrollDelay);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.warn("Hash scroll error:", error);
|
||||
return false;
|
||||
}
|
||||
}, [elementId, scrollDelay]);
|
||||
|
||||
/**
|
||||
* Extracts multiple IDs from hash string
|
||||
* Supports various delimiters: comma, space, pipe, semicolon
|
||||
* @param {string} hashString - The hash part of the URL
|
||||
* @returns {string[]} - Array of clean ID strings
|
||||
*/
|
||||
const extractIdsFromHash = (hashString: string | null): string[] => {
|
||||
if (!hashString) return [];
|
||||
|
||||
// Split by common delimiters and clean up
|
||||
return hashString
|
||||
.split(/[,\s|;]+/) // Split by comma, space, pipe, or semicolon
|
||||
.map((id) => id.trim()) // Remove whitespace
|
||||
.filter((id) => id.length > 0); // Remove empty strings
|
||||
};
|
||||
|
||||
/**
|
||||
* Get current hash from window.location
|
||||
* @returns {string | null} - Current hash without the # symbol
|
||||
*/
|
||||
const getCurrentHash = (): string | null => {
|
||||
if (typeof window === "undefined") return null;
|
||||
const hash = window.location.hash;
|
||||
return hash ? hash.slice(1) : null; // Remove the # symbol
|
||||
};
|
||||
|
||||
// Effect to handle hash changes and initial load
|
||||
useEffect(() => {
|
||||
if (!elementId) {
|
||||
setIsHashMatch(false);
|
||||
setHashIds([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const handleHashChange = () => {
|
||||
const hash = getCurrentHash();
|
||||
|
||||
// Extract all IDs from the hash
|
||||
const idsInHash = extractIdsFromHash(hash);
|
||||
setHashIds(idsInHash);
|
||||
|
||||
// Check if provided element ID is present in the hash
|
||||
const hashMatches = idsInHash.includes(elementId);
|
||||
setIsHashMatch(hashMatches);
|
||||
|
||||
// If hash matches, attempt to scroll to the element
|
||||
if (hashMatches) {
|
||||
scrollToElement();
|
||||
}
|
||||
};
|
||||
|
||||
// Handle initial load
|
||||
handleHashChange();
|
||||
|
||||
// Listen for hash changes
|
||||
window.addEventListener("hashchange", handleHashChange);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("hashchange", handleHashChange);
|
||||
};
|
||||
}, [elementId, pathname, scrollToElement]); // Include pathname to handle route changes
|
||||
|
||||
// Return object with hash match status and utility functions
|
||||
return {
|
||||
// Whether the current URL hash contains the provided element ID
|
||||
isHashMatch,
|
||||
|
||||
// Array of all IDs found in the current hash
|
||||
hashIds,
|
||||
|
||||
// Manually trigger scroll to the element
|
||||
scrollToElement,
|
||||
};
|
||||
};
|
||||
55
packages/hooks/src/use-local-storage.tsx
Normal file
55
packages/hooks/src/use-local-storage.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
|
||||
export const getValueFromLocalStorage = (key: string, defaultValue: any) => {
|
||||
if (typeof window === undefined || typeof window === "undefined") return defaultValue;
|
||||
try {
|
||||
const item = window.localStorage.getItem(key);
|
||||
return item ? JSON.parse(item) : defaultValue;
|
||||
} catch (_error) {
|
||||
window.localStorage.removeItem(key);
|
||||
return defaultValue;
|
||||
}
|
||||
};
|
||||
|
||||
export const setValueIntoLocalStorage = (key: string, value: any) => {
|
||||
if (typeof window === undefined || typeof window === "undefined") return false;
|
||||
try {
|
||||
window.localStorage.setItem(key, JSON.stringify(value));
|
||||
return true;
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const useLocalStorage = <T,>(key: string, initialValue: T) => {
|
||||
const [storedValue, setStoredValue] = useState<T | null>(() => getValueFromLocalStorage(key, initialValue));
|
||||
|
||||
const setValue = useCallback(
|
||||
(value: T) => {
|
||||
window.localStorage.setItem(key, JSON.stringify(value));
|
||||
setStoredValue(value);
|
||||
window.dispatchEvent(new Event(`local-storage:${key}`));
|
||||
},
|
||||
[key]
|
||||
);
|
||||
|
||||
const clearValue = useCallback(() => {
|
||||
window.localStorage.removeItem(key);
|
||||
setStoredValue(null);
|
||||
window.dispatchEvent(new Event(`local-storage:${key}`));
|
||||
}, [key]);
|
||||
|
||||
const reHydrate = useCallback(() => {
|
||||
const data = getValueFromLocalStorage(key, initialValue);
|
||||
setStoredValue(data);
|
||||
}, [key, initialValue]);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener(`local-storage:${key}`, reHydrate);
|
||||
return () => {
|
||||
window.removeEventListener(`local-storage:${key}`, reHydrate);
|
||||
};
|
||||
}, [key, reHydrate]);
|
||||
|
||||
return { storedValue, setValue, clearValue } as const;
|
||||
};
|
||||
29
packages/hooks/src/use-outside-click-detector.tsx
Normal file
29
packages/hooks/src/use-outside-click-detector.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import React, { useEffect } from "react";
|
||||
|
||||
export const useOutsideClickDetector = (
|
||||
ref: React.RefObject<HTMLElement> | any,
|
||||
callback: () => void,
|
||||
useCapture = false
|
||||
) => {
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(event.target as any)) {
|
||||
// check for the closest element with attribute name data-prevent-outside-click
|
||||
const preventOutsideClickElement = (event.target as unknown as HTMLElement | undefined)?.closest(
|
||||
"[data-prevent-outside-click]"
|
||||
);
|
||||
// if the closest element with attribute name data-prevent-outside-click is found, return
|
||||
if (preventOutsideClickElement) {
|
||||
return;
|
||||
}
|
||||
// else call the callback
|
||||
callback();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener("mousedown", handleClick, useCapture);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClick, useCapture);
|
||||
};
|
||||
});
|
||||
};
|
||||
34
packages/hooks/src/use-platform-os.tsx
Normal file
34
packages/hooks/src/use-platform-os.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
export const usePlatformOS = () => {
|
||||
const [platformData, setPlatformData] = useState({
|
||||
isMobile: false,
|
||||
platform: "",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const detectPlatform = () => {
|
||||
const userAgent = window.navigator.userAgent;
|
||||
const isMobile = /iPhone|iPad|iPod|Android/i.test(userAgent);
|
||||
let platform = "";
|
||||
|
||||
if (!isMobile) {
|
||||
if (userAgent.indexOf("Win") !== -1) {
|
||||
platform = "Windows";
|
||||
} else if (userAgent.indexOf("Mac") !== -1) {
|
||||
platform = "MacOS";
|
||||
} else if (userAgent.indexOf("Linux") !== -1) {
|
||||
platform = "Linux";
|
||||
} else {
|
||||
platform = "Unknown";
|
||||
}
|
||||
}
|
||||
|
||||
setPlatformData({ isMobile, platform });
|
||||
};
|
||||
|
||||
detectPlatform();
|
||||
}, []);
|
||||
|
||||
return platformData;
|
||||
};
|
||||
9
packages/hooks/tsconfig.json
Normal file
9
packages/hooks/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "@plane/typescript-config/react-library.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "react",
|
||||
"lib": ["esnext", "dom"]
|
||||
},
|
||||
"include": ["./src"],
|
||||
"exclude": ["dist", "build", "node_modules"]
|
||||
}
|
||||
11
packages/hooks/tsdown.config.ts
Normal file
11
packages/hooks/tsdown.config.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from "tsdown";
|
||||
|
||||
export default defineConfig({
|
||||
entry: ["src/index.ts"],
|
||||
outDir: "dist",
|
||||
format: ["esm", "cjs"],
|
||||
exports: true,
|
||||
dts: true,
|
||||
clean: true,
|
||||
sourcemap: true,
|
||||
});
|
||||
Reference in New Issue
Block a user