Initial commit
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
*.md
|
||||
node_modules
|
||||
dist
|
||||
public
|
||||
build
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"printWidth": 150,
|
||||
"tabWidth": 2,
|
||||
"endOfLine": "lf",
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"bracketSpacing": true,
|
||||
"semi": true,
|
||||
"bracketSameLine": false,
|
||||
"jsxSingleQuote": false,
|
||||
"arrowParens": "always",
|
||||
"importOrderCaseSensitive": false
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
# [←](../../docs/README.md) @crux-kit/hooks
|
||||
|
||||
A collection of reusable React hooks for common UI and browser interaction patterns.
|
||||
|
||||
Each hook is client-side only and tree-shakeable. Detailed documentation for each hook lives alongside its source file.
|
||||
|
||||
## Available Hooks
|
||||
|
||||
| Hook | Description | Docs |
|
||||
| -------------------- | ----------------------------------------------------------------------- | ----------------------------------------------- |
|
||||
| `useBodyClass` | Adds CSS classes to `document.body` while a component is mounted | [Readme.md](./src/useBodyClass/Readme.md) |
|
||||
| `useCopyToClipboard` | Copies text to the clipboard with a timed `isCopied` flag | [Readme.md](./src/useCopyToClipboard/Readme.md) |
|
||||
| `useCountdown` | Countdown timer that fires a callback on completion | [Readme.md](./src/useCountdown/Readme.md) |
|
||||
| `useDebounceSearch` | Debounced search input with a minimum character threshold | [Readme.md](./src/useDebounceSearch/Readme.md) |
|
||||
| `useDisclosure` | Boolean open/closed state with `open`, `close`, and `toggle` helpers | [Readme.md](./src/useDisclosure/Readme.md) |
|
||||
| `useEffectOnce` | Runs a side-effect exactly once after the initial render | [Readme.md](./src/useEffectOnce/Readme.md) |
|
||||
| `useEventListener` | Declarative event listener with automatic cleanup | [Readme.md](./src/useEventListener/Readme.md) |
|
||||
| `useIsMobile` | Returns `true` when the viewport is below a breakpoint | [Readme.md](./src/useIsMobile/Readme.md) |
|
||||
| `useMounted` | Returns `true` after the component has mounted (SSR-safe) | [Readme.md](./src/useMounted/Readme.md) |
|
||||
| `useOutsideClick` | Fires a callback when a click/touch occurs outside a referenced element | [Readme.md](./src/useOutsideClick/Readme.md) |
|
||||
| `usePageMeta` | Sets page `<title>` and `<meta name="description">` while mounted | [Readme.md](./src/usePageMeta/Readme.md) |
|
||||
| `useScrollPosition` | Tracks vertical scroll position of the window or a specific element | [Readme.md](./src/useScrollPosition/Readme.md) |
|
||||
| `useViewport` | Returns current viewport `[height, width]`, updates on resize | [Readme.md](./src/useViewport/Readme.md) |
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "@crux-kit/hooks",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"types": "src/index.ts",
|
||||
"private": true,
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {},
|
||||
"dependencies": {
|
||||
"@crux-kit/types": "*"
|
||||
},
|
||||
"devDependencies": {},
|
||||
"peerDependencies": {}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export * from './useCountdown';
|
||||
export * from './useEffectOnce';
|
||||
export * from './useEventListener';
|
||||
export * from './useCopyToClipboard';
|
||||
export * from './useBodyClass';
|
||||
export * from './useDisclosure';
|
||||
export * from './useIsMobile';
|
||||
export * from './useMounted';
|
||||
export * from './useOutsideClick';
|
||||
export * from './useScrollPosition';
|
||||
export * from './useDebounceSearch';
|
||||
export * from './useViewport';
|
||||
export * from './usePageMeta';
|
||||
@@ -0,0 +1,31 @@
|
||||
# useBodyClass
|
||||
|
||||
Adds one or more CSS classes to `document.body` while the component is mounted, and removes them on unmount.
|
||||
|
||||
## Signature
|
||||
|
||||
```ts
|
||||
function useBodyClass(className: string): void;
|
||||
```
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| ----------- | -------- | ------------------------------------------------------- |
|
||||
| `className` | `string` | Space-separated class name(s) to add to `document.body` |
|
||||
|
||||
## Usage
|
||||
|
||||
```tsx
|
||||
export function ModalLayout({ children }: { children: React.ReactNode }) {
|
||||
useBodyClass('overflow-hidden bg-overlay');
|
||||
|
||||
return <div>{children}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
While `ModalLayout` is mounted, `document.body` will have the classes `overflow-hidden` and `bg-overlay`. Both are removed automatically when the component unmounts.
|
||||
|
||||
## Notes
|
||||
|
||||
- Accepts a space-separated string — multiple classes are handled individually so partial removal is safe.
|
||||
- Re-applies classes if the `className` prop changes.
|
||||
- Client-only (`'use client'`). Does not run during SSR.
|
||||
@@ -0,0 +1 @@
|
||||
export * from './useBodyClass';
|
||||
@@ -0,0 +1,22 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export function useBodyClass(className: string) {
|
||||
useEffect(() => {
|
||||
// Split classNames by spaces, including multi-line support
|
||||
const classList = className.split(/\s+/).filter(Boolean);
|
||||
|
||||
// Add each class to the body element when the component mounts
|
||||
classList.forEach((className) => {
|
||||
document.body.classList.add(className);
|
||||
});
|
||||
|
||||
// Cleanup function to remove classes when the component unmounts
|
||||
return () => {
|
||||
classList.forEach((className) => {
|
||||
document.body.classList.remove(className);
|
||||
});
|
||||
};
|
||||
}, [className]); // Re-run the effect if classNames changes
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
# useCopyToClipboard
|
||||
|
||||
Copies a string to the clipboard and exposes a temporary `isCopied` flag that resets after a configurable timeout.
|
||||
|
||||
## Signature
|
||||
|
||||
```ts
|
||||
function useCopyToClipboard(options?: { timeout?: number; onCopy?: () => void }): {
|
||||
isCopied: boolean;
|
||||
copyToClipboard: (value: string) => void;
|
||||
};
|
||||
```
|
||||
|
||||
### Options
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
| --------- | ------------ | ------- | -------------------------------------------------- |
|
||||
| `timeout` | `number` | `2000` | Milliseconds before `isCopied` resets to `false` |
|
||||
| `onCopy` | `() => void` | — | Callback fired immediately after a successful copy |
|
||||
|
||||
### Returns
|
||||
|
||||
| Property | Type | Description |
|
||||
| ----------------- | ------------------------- | --------------------------------------- |
|
||||
| `isCopied` | `boolean` | `true` for `timeout` ms after each copy |
|
||||
| `copyToClipboard` | `(value: string) => void` | Writes `value` to the clipboard |
|
||||
|
||||
## Usage
|
||||
|
||||
```tsx
|
||||
function CopyButton({ code }: { code: string }) {
|
||||
const { isCopied, copyToClipboard } = useCopyToClipboard({ timeout: 1500 });
|
||||
|
||||
return <button onClick={() => copyToClipboard(code)}>{isCopied ? 'Copied!' : 'Copy'}</button>;
|
||||
}
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Uses the `navigator.clipboard` API — no-ops silently in non-secure contexts or environments without clipboard support.
|
||||
- Client-only (`'use client'`). Does not run during SSR.
|
||||
@@ -0,0 +1 @@
|
||||
export * from './useCopyToClipboard';
|
||||
@@ -0,0 +1,35 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
export function useCopyToClipboard({
|
||||
timeout = 2000,
|
||||
onCopy,
|
||||
}: {
|
||||
timeout?: number;
|
||||
onCopy?: () => void;
|
||||
} = {}) {
|
||||
const [isCopied, setIsCopied] = React.useState(false);
|
||||
|
||||
const copyToClipboard = (value: string) => {
|
||||
if (typeof window === 'undefined' || !navigator.clipboard.writeText) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!value) return;
|
||||
|
||||
navigator.clipboard.writeText(value).then(() => {
|
||||
setIsCopied(true);
|
||||
|
||||
if (onCopy) {
|
||||
onCopy();
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
setIsCopied(false);
|
||||
}, timeout);
|
||||
}, console.error);
|
||||
};
|
||||
|
||||
return { isCopied, copyToClipboard };
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
# useCountdown Hook
|
||||
|
||||
Створює таймер зворотного відліку.
|
||||
Автоматично викликає `onTimeOut` після завершення зворотного відліку.
|
||||
По замовчуванню `delay` 10s
|
||||
|
||||
Output:
|
||||
|
||||
```ts
|
||||
export type CountdownController = {
|
||||
start: () => void;
|
||||
dismiss: () => void;
|
||||
remaining: number | null;
|
||||
};
|
||||
```
|
||||
|
||||
```ts
|
||||
|
||||
export const EmailVerification: React.FC<{
|
||||
initialValues: FormValues;
|
||||
onSubmit: (values: FormValues) => void;
|
||||
}> = ({ initialValues, onSubmit }) => {
|
||||
const form = useForm<FormValues>({
|
||||
defaultValues: initialValues,
|
||||
resolver: zodResolver(verifySchema),
|
||||
});
|
||||
|
||||
const sendCodeMutation = useSendVerificationCode();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [countdownFinished, setCountdownFinished] = React.useState(false);
|
||||
const { start, remaining } = useCountdown({
|
||||
onTimeOut: () => setCountdownFinished(true),
|
||||
delay: 59 * 1000,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
start();
|
||||
}, []);
|
||||
|
||||
const handleResendCode = () => {
|
||||
const email = form.getValues().email;
|
||||
|
||||
if (!email) return;
|
||||
|
||||
sendCodeMutation.mutate(
|
||||
{ email },
|
||||
{
|
||||
onSuccess() {
|
||||
toast.info(t('Verification code sent to email'));
|
||||
start();
|
||||
setCountdownFinished(false);
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Form></Form>
|
||||
);
|
||||
};
|
||||
|
||||
```
|
||||
@@ -0,0 +1 @@
|
||||
export * from './useCountdown';
|
||||
@@ -0,0 +1,67 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
export type UseCountdownParams = {
|
||||
onTimeOut: () => void;
|
||||
delay?: number;
|
||||
};
|
||||
|
||||
export type CountdownController = {
|
||||
start: () => void;
|
||||
dismiss: () => void;
|
||||
remaining: number | null;
|
||||
};
|
||||
|
||||
export const useCountdown = ({ onTimeOut, delay = 10000 }: UseCountdownParams): CountdownController => {
|
||||
const timeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const intervalRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [remaining, setRemaining] = React.useState<number | null>(null);
|
||||
|
||||
const start = React.useCallback(() => {
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
|
||||
const totalSeconds = Math.ceil(delay / 1000);
|
||||
setRemaining(totalSeconds);
|
||||
|
||||
intervalRef.current = setInterval(() => {
|
||||
setRemaining((prev) => {
|
||||
if (prev === null || prev <= 1) {
|
||||
clearInterval(intervalRef.current!);
|
||||
return null;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
onTimeOut();
|
||||
timeoutRef.current = null;
|
||||
setRemaining(null);
|
||||
}, delay);
|
||||
}, [delay, onTimeOut]);
|
||||
|
||||
const dismiss = React.useCallback(() => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
|
||||
setRemaining(null);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { start, dismiss, remaining };
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
# useDebounceSearch
|
||||
|
||||
Controls a search input with debouncing and a minimum character threshold. Only fires the `onTimeOut` callback after the user stops typing and the query meets the minimum length.
|
||||
|
||||
## Signature
|
||||
|
||||
```ts
|
||||
function useDebounceSearch(params: UseDebounceSearchParams): DebounceSearchController;
|
||||
```
|
||||
|
||||
### Params
|
||||
|
||||
```ts
|
||||
type UseDebounceSearchParams = {
|
||||
onTimeOut: (query: string) => void;
|
||||
minQueryNumber?: number;
|
||||
initialQuery?: string | null;
|
||||
delay?: number;
|
||||
};
|
||||
```
|
||||
|
||||
| Param | Type | Default | Description |
|
||||
| ---------------- | ------------------------- | ------- | ------------------------------------------------------------ |
|
||||
| `onTimeOut` | `(query: string) => void` | — | Called with the current query after `delay` ms of inactivity |
|
||||
| `minQueryNumber` | `number` | `3` | Minimum characters required before `onTimeOut` is invoked |
|
||||
| `initialQuery` | `string \| null` | `''` | Initial value of the controlled input |
|
||||
| `delay` | `number` | `800` | Debounce delay in milliseconds |
|
||||
|
||||
### Returns
|
||||
|
||||
```ts
|
||||
type DebounceSearchController = {
|
||||
query: string;
|
||||
handleChange: (value: string) => void;
|
||||
};
|
||||
```
|
||||
|
||||
| Property | Type | Description |
|
||||
| -------------- | ------------------------- | -------------------------------------- |
|
||||
| `query` | `string` | Current value of the search input |
|
||||
| `handleChange` | `(value: string) => void` | Pass to the input's `onChange` handler |
|
||||
|
||||
## Usage
|
||||
|
||||
```tsx
|
||||
function SearchBox() {
|
||||
const { query, handleChange } = useDebounceSearch({
|
||||
onTimeOut: (q) => fetchResults(q),
|
||||
minQueryNumber: 2,
|
||||
delay: 500,
|
||||
});
|
||||
|
||||
return <input value={query} onChange={(e) => handleChange(e.target.value)} placeholder="Search…" />;
|
||||
}
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Queries shorter than `minQueryNumber` (but not empty) cancel any pending debounced call.
|
||||
- Uses `throttle-debounce` under the hood via a stable `useRef` to avoid resetting the timer on each render.
|
||||
- Client-only (`'use client'`).
|
||||
@@ -0,0 +1 @@
|
||||
export * from './useDebounceSearch';
|
||||
@@ -0,0 +1,38 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, useState } from 'react';
|
||||
import { debounce } from 'throttle-debounce';
|
||||
|
||||
export type UseDebounceSearchParams = {
|
||||
onTimeOut: (query: string) => void;
|
||||
minQueryNumber?: number;
|
||||
initialQuery?: string | null;
|
||||
delay?: number;
|
||||
};
|
||||
|
||||
export type DebounceSearchController = {
|
||||
query: string;
|
||||
handleChange: (value: string) => void;
|
||||
};
|
||||
|
||||
export const useDebounceSearch = (params: UseDebounceSearchParams): DebounceSearchController => {
|
||||
const { onTimeOut, minQueryNumber = 3, delay = 800, initialQuery } = params;
|
||||
|
||||
const [query, setQuery] = useState(initialQuery ?? '');
|
||||
|
||||
const throttled = useRef(debounce(delay, (callback: () => void) => callback()));
|
||||
const bind = (value: string) => throttled.current(onTimeOut.bind(null, value));
|
||||
const unbind = () => throttled.current(() => {});
|
||||
|
||||
const handleChange = (value: string) => {
|
||||
setQuery(value);
|
||||
|
||||
if (value.length < minQueryNumber && value.length !== 0) {
|
||||
return unbind();
|
||||
}
|
||||
|
||||
bind(value);
|
||||
};
|
||||
|
||||
return { query, handleChange };
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
# useDisclosure
|
||||
|
||||
Manages a boolean open/closed state — useful for modals, drawers, dropdowns, and any show/hide UI pattern.
|
||||
|
||||
## Signature
|
||||
|
||||
```ts
|
||||
function useDisclosure(initial?: boolean): UseDisclosureControl;
|
||||
```
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
| --------- | --------- | ------- | ------------------------- |
|
||||
| `initial` | `boolean` | `false` | Initial open/closed state |
|
||||
|
||||
### Returns
|
||||
|
||||
```ts
|
||||
interface UseDisclosureControl {
|
||||
isOpen: boolean;
|
||||
open: () => void;
|
||||
close: () => void;
|
||||
toggle: () => void;
|
||||
}
|
||||
```
|
||||
|
||||
| Property | Type | Description |
|
||||
| -------- | ------------ | ------------------------- |
|
||||
| `isOpen` | `boolean` | Current open/closed state |
|
||||
| `open` | `() => void` | Sets `isOpen` to `true` |
|
||||
| `close` | `() => void` | Sets `isOpen` to `false` |
|
||||
| `toggle` | `() => void` | Flips the current state |
|
||||
|
||||
## Usage
|
||||
|
||||
```tsx
|
||||
function ConfirmDialog() {
|
||||
const { isOpen, open, close } = useDisclosure();
|
||||
|
||||
return (
|
||||
<>
|
||||
<button onClick={open}>Delete account</button>
|
||||
<Dialog open={isOpen} onClose={close}>
|
||||
<p>Are you sure?</p>
|
||||
<button onClick={close}>Cancel</button>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- `open`, `close`, and `toggle` are stable references (`useCallback`) and safe to pass as props without causing unnecessary re-renders.
|
||||
- Client-only (`'use client'`).
|
||||
@@ -0,0 +1 @@
|
||||
export * from './useDisclosure';
|
||||
@@ -0,0 +1,20 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
export interface UseDisclosureControl {
|
||||
isOpen: boolean;
|
||||
open: () => void;
|
||||
close: () => void;
|
||||
toggle: () => void;
|
||||
}
|
||||
|
||||
export const useDisclosure = (initial = false): UseDisclosureControl => {
|
||||
const [isOpen, setIsOpen] = useState(initial);
|
||||
|
||||
const open = useCallback(() => setIsOpen(true), []);
|
||||
const close = useCallback(() => setIsOpen(false), []);
|
||||
const toggle = useCallback(() => setIsOpen((state) => !state), []);
|
||||
|
||||
return { isOpen, open, close, toggle };
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
# useEffectOnce Hook
|
||||
|
||||
`useEffectOnce` is a custom hook in React designed to mimic the behavior of `componentDidMount` and `componentWillUnmount` lifecycle methods in class components. It's a modified version of `useEffect` that runs only once when the component mounts.
|
||||
|
||||
## Features
|
||||
|
||||
- **Single Execution:** The hook executes the provided effect function once upon the component's initial render.
|
||||
- **Cleanup Capability:** It supports an optional cleanup function, returned from the effect, which is called when the component unmounts.
|
||||
- **SSR Compatibility:** As an extension of `useEffect`, it is naturally compatible with server-side rendering environments.
|
||||
|
||||
## Usage
|
||||
|
||||
```typescript
|
||||
import React from 'react';
|
||||
import { useEffectOnce } from '@crux-kit/hooks';
|
||||
|
||||
const Component = () => {
|
||||
const [fibonacciSequence, setFibonacciSequence] = useState([]);
|
||||
|
||||
const calculateFibonacci = (n) => {
|
||||
let sequence = [0, 1];
|
||||
for (let i = 2; i < n; i++) {
|
||||
sequence[i] = sequence[i - 1] + sequence[i - 2];
|
||||
}
|
||||
return sequence.slice(0, n);
|
||||
};
|
||||
|
||||
useEffectOnce(() => {
|
||||
const sequence = calculateFibonacci(5);
|
||||
setFibonacciSequence(sequence);
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p>First {5} numbers in the Fibonacci sequence:</p>
|
||||
<ul>
|
||||
{fibonacciSequence.map((number, index) => (
|
||||
<li key={index}>{number}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
In this example, `useEffectOnce` is used to perform actions at the mounting and unmounting phases of `Component`.
|
||||
|
||||
## API Reference
|
||||
|
||||
### Parameters
|
||||
|
||||
- `effect`: A function that will be executed once when the component mounts. This function can optionally return a cleanup function, which will be executed when the component unmounts.
|
||||
|
||||
## Use Cases
|
||||
|
||||
- **Initial Setup**: Perform setup operations like fetching initial data or setting up listeners.
|
||||
- **One-time Calculations**: Compute values needed only once during the component's lifecycle.
|
||||
- **Single API Calls**: Make a single API call when a component is rendered for the first time.
|
||||
- **Non-Recurring Subscriptions**: Subscribe to a service or event listener that should only be initialized once.
|
||||
@@ -0,0 +1 @@
|
||||
export * from './useEffectOnce';
|
||||
@@ -0,0 +1,22 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
* `useEffectOnce` is a custom hook that runs a side-effect only once after the initial render.
|
||||
* It's similar to useEffect, but it guarantees that the effect runs only once.
|
||||
*
|
||||
* @param effect - The effect function to run. Can optionally return a cleanup function.
|
||||
*/
|
||||
|
||||
export function useEffectOnce(effect: () => void | (() => void | undefined)) {
|
||||
React.useEffect(() => {
|
||||
const cleanupFunction = effect();
|
||||
|
||||
return () => {
|
||||
if (typeof cleanupFunction === 'function') {
|
||||
cleanupFunction();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
# Documentation: useEventListener Hook
|
||||
|
||||
The useEventListener hook provides a declarative way to manage event listeners in React. It handles the manual setup, cleanup (removal), and potential memory leaks automatically while providing full TypeScript type-safety.
|
||||
|
||||
## Why are there 3 function signatures?
|
||||
|
||||
In TypeScript, this is called **Function Overloading**. It allows the hook to "shape-shift" based on what arguments you pass it. This ensures that the `event` object inside your callback has the correct properties (e.g., `.key` for keyboard events or `.clientX` for mouse events).
|
||||
|
||||
| Overload | Target | Use Case |
|
||||
| ---------- | -------------- | ------------------------------------------------------------------- |
|
||||
| Overload 1 | `window` | Global events like `resize`, `scroll`, or `keydown` |
|
||||
| Overload 2 | `HTMLElement` | Specific element events like clicking a button or scrolling a `div` |
|
||||
| Overload 3 | Implementation | The actual logic |
|
||||
|
||||
### 1. Window Events (Global)
|
||||
|
||||
This version is used when you don't provide a third argument. It defaults the listener to the global window object.
|
||||
|
||||
**Example: Detecting the "/" key for search**
|
||||
|
||||
```ts
|
||||
useEventListener('keydown', (event) => {
|
||||
if (event.key === '/') {
|
||||
console.log('Search shortcut triggered!');
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**Description:** Useful for global shortcuts, tracking window resizing, or detecting if the user has scrolled the entire page.
|
||||
|
||||
### 2. Element Events (Targeted)
|
||||
|
||||
This version is used when you pass a specific element (usually via a ref) as the third argument.
|
||||
|
||||
**Example: Clicking a specific button**
|
||||
|
||||
```ts
|
||||
const btnRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEventListener(
|
||||
'click',
|
||||
(event) => {
|
||||
console.log('Coordinates:', event.clientX, event.clientY);
|
||||
},
|
||||
btnRef.current,
|
||||
);
|
||||
```
|
||||
|
||||
**Description:** Perfect for tracking interactions with specific parts of your UI, such as custom dropdowns, drag-and-drop zones, or scrollable containers.
|
||||
|
||||
### 3. The Implementation Signature
|
||||
|
||||
This is the internal part of the code that makes everything work. It uses `useRef` to store your handler so that the event listener doesn't have to be reset every time your component re-renders.
|
||||
|
||||
```tsx
|
||||
const savedHandler = useRef(handler);
|
||||
|
||||
useEffect(() => {
|
||||
const target = element ?? window;
|
||||
const listener = (e) => savedHandler.current(e);
|
||||
|
||||
target.addEventListener(eventName, listener);
|
||||
return () => target.removeEventListener(eventName, listener);
|
||||
}, [eventName, element]);
|
||||
```
|
||||
|
||||
**Description:** This "engine" ensures that even if your component state changes frequently, the event listener remains attached and efficient without causing performance bottlenecks.
|
||||
@@ -0,0 +1 @@
|
||||
export * from './useEventListener';
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
/**
|
||||
* A custom hook to manage event listeners.
|
||||
* @param eventName - The name of the event (e.g., 'scroll', 'click', 'keydown')
|
||||
* @param handler - The callback function to execute
|
||||
* @param element - The target element (defaults to window)
|
||||
*/
|
||||
function useEventListener<K extends keyof WindowEventMap>(eventName: K, handler: (event: WindowEventMap[K]) => void, element?: undefined): void;
|
||||
|
||||
function useEventListener<K extends keyof HTMLElementEventMap, T extends HTMLElement>(
|
||||
eventName: K,
|
||||
handler: (event: HTMLElementEventMap[K]) => void,
|
||||
element: T | null,
|
||||
): void;
|
||||
|
||||
function useEventListener<KW extends keyof WindowEventMap, KH extends keyof HTMLElementEventMap, T extends HTMLElement>(
|
||||
eventName: KW | KH,
|
||||
handler: (event: WindowEventMap[KW] | HTMLElementEventMap[KH] | Event) => void,
|
||||
element?: T | Window | null,
|
||||
) {
|
||||
const savedHandler = useRef(handler);
|
||||
|
||||
useEffect(() => {
|
||||
savedHandler.current = handler;
|
||||
}, [handler]);
|
||||
|
||||
useEffect(() => {
|
||||
const targetElement: T | Window = element ?? window;
|
||||
|
||||
if (!(targetElement && targetElement.addEventListener)) return;
|
||||
|
||||
const eventListener: typeof handler = (event) => savedHandler.current(event);
|
||||
|
||||
targetElement.addEventListener(eventName, eventListener);
|
||||
|
||||
return () => {
|
||||
targetElement.removeEventListener(eventName, eventListener);
|
||||
};
|
||||
}, [eventName, element]);
|
||||
}
|
||||
|
||||
export { useEventListener };
|
||||
@@ -0,0 +1,34 @@
|
||||
# useIsMobile
|
||||
|
||||
Returns `true` when the viewport width is below a configurable breakpoint, and re-evaluates reactively on resize.
|
||||
|
||||
## Signature
|
||||
|
||||
```ts
|
||||
function useIsMobile(mobileBreakpoint?: number): boolean;
|
||||
```
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
| ------------------ | -------- | ------- | ------------------------------------------------------------ |
|
||||
| `mobileBreakpoint` | `number` | `1024` | Viewport width (px) at which the device is considered mobile |
|
||||
|
||||
## Usage
|
||||
|
||||
```tsx
|
||||
function Navbar() {
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
return isMobile ? <MobileNav /> : <DesktopNav />;
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// Custom breakpoint
|
||||
const isTablet = useIsMobile(768);
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Uses `window.matchMedia` for the resize listener (more efficient than `resize` event polling).
|
||||
- Returns `false` during SSR (initial state is `undefined`, coerced via `!!`).
|
||||
- Client-only (`'use client'`).
|
||||
@@ -0,0 +1 @@
|
||||
export * from './useIsMobile';
|
||||
@@ -0,0 +1,19 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
export function useIsMobile(mobileBreakpoint: number = 1024) {
|
||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined);
|
||||
|
||||
React.useEffect(() => {
|
||||
const mql = window.matchMedia(`(max-width: ${mobileBreakpoint - 1}px)`);
|
||||
const onChange = () => {
|
||||
setIsMobile(window.innerWidth < mobileBreakpoint);
|
||||
};
|
||||
mql.addEventListener('change', onChange);
|
||||
setIsMobile(window.innerWidth < mobileBreakpoint);
|
||||
return () => mql.removeEventListener('change', onChange);
|
||||
}, []);
|
||||
|
||||
return !!isMobile;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
# useMounted
|
||||
|
||||
Returns `true` after the component has mounted on the client. Useful for deferring client-only rendering to avoid SSR hydration mismatches.
|
||||
|
||||
## Signature
|
||||
|
||||
```ts
|
||||
function useMounted(): boolean;
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```tsx
|
||||
function ThemeToggle() {
|
||||
const mounted = useMounted();
|
||||
|
||||
if (!mounted) return null; // avoid SSR/hydration mismatch
|
||||
|
||||
return <button>{getCurrentTheme()}</button>;
|
||||
}
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Always returns `false` on the first render (SSR and initial client render), then `true` after `useEffect` fires.
|
||||
- Client-only (`'use client'`).
|
||||
@@ -0,0 +1 @@
|
||||
export * from './useMounted';
|
||||
@@ -0,0 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
export function useMounted() {
|
||||
const [mounted, setMounted] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
return mounted;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
# useOutsideClick
|
||||
|
||||
Detects clicks and touches outside a referenced element and calls a callback.
|
||||
|
||||
## Signature
|
||||
|
||||
```ts
|
||||
function useOutsideClick(callback: () => void): React.RefObject<HTMLDivElement>;
|
||||
```
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| ---------- | ------------ | ------------------------------------------------ |
|
||||
| `callback` | `() => void` | Called when a click/touch occurs outside the ref |
|
||||
|
||||
Returns a `ref` to attach to the element you want to monitor.
|
||||
|
||||
## Usage
|
||||
|
||||
```tsx
|
||||
function Dropdown({ onClose }: { onClose: () => void }) {
|
||||
const ref = useOutsideClick(onClose);
|
||||
|
||||
return (
|
||||
<div ref={ref}>
|
||||
<p>Dropdown content</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Listens to both `mouseup` and `touchend` to cover desktop and mobile.
|
||||
- If `callback` changes on every render, wrap it in `useCallback` to avoid resetting listeners unnecessarily.
|
||||
- Client-only (`'use client'`).
|
||||
@@ -0,0 +1 @@
|
||||
export * from './useOutsideClick';
|
||||
@@ -0,0 +1,25 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
export const useOutsideClick = (callback: () => void) => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent | TouchEvent) => {
|
||||
if (ref.current && !ref.current.contains(event.target as Node)) {
|
||||
callback();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mouseup', handleClickOutside);
|
||||
document.addEventListener('touchend', handleClickOutside);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mouseup', handleClickOutside);
|
||||
document.removeEventListener('touchend', handleClickOutside);
|
||||
};
|
||||
}, [callback]);
|
||||
|
||||
return ref;
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
# usePageMeta
|
||||
|
||||
Sets the page `<title>` and/or `<meta name="description">` while a component is mounted, then restores the previous values on unmount.
|
||||
|
||||
## Signature
|
||||
|
||||
```ts
|
||||
function usePageMeta(params: UsePageMetaParams): void;
|
||||
|
||||
interface UsePageMetaParams {
|
||||
title?: string;
|
||||
description?: string;
|
||||
}
|
||||
```
|
||||
|
||||
| Param | Type | Description |
|
||||
| ------------- | -------- | ------------------------------------------- |
|
||||
| `title` | `string` | Value to set as `document.title` |
|
||||
| `description` | `string` | Value to set in `<meta name="description">` |
|
||||
|
||||
Both params are optional — pass only the ones you need.
|
||||
|
||||
## Usage
|
||||
|
||||
```tsx
|
||||
function ArticlePage({ article }: { article: Article }) {
|
||||
usePageMeta({
|
||||
title: article.title,
|
||||
description: article.summary,
|
||||
});
|
||||
|
||||
return <main>{article.content}</main>;
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// Title only
|
||||
function DashboardPage() {
|
||||
usePageMeta({ title: 'Dashboard' });
|
||||
return <div />;
|
||||
}
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Restores the previous `document.title` and `<meta>` content when the component unmounts, so nested or sequential usage is safe.
|
||||
- If no `<meta name="description">` tag exists in the document, one is created and removed on cleanup.
|
||||
- Client-only (`'use client'`). Does not run during SSR.
|
||||
@@ -0,0 +1 @@
|
||||
export * from './usePageMeta';
|
||||
@@ -0,0 +1,45 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export interface UsePageMetaParams {
|
||||
title?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export function usePageMeta({ title, description }: UsePageMetaParams) {
|
||||
useEffect(() => {
|
||||
if (!title) return;
|
||||
|
||||
const previousTitle = document.title;
|
||||
document.title = title;
|
||||
|
||||
return () => {
|
||||
document.title = previousTitle;
|
||||
};
|
||||
}, [title]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!description) return;
|
||||
|
||||
let tag = document.querySelector<HTMLMetaElement>('meta[name="description"]');
|
||||
|
||||
const created = !tag;
|
||||
if (!tag) {
|
||||
tag = document.createElement('meta');
|
||||
tag.name = 'description';
|
||||
document.head.appendChild(tag);
|
||||
}
|
||||
|
||||
const previousContent = tag.content;
|
||||
tag.content = description;
|
||||
|
||||
return () => {
|
||||
if (created) {
|
||||
tag.remove();
|
||||
} else {
|
||||
tag.content = previousContent;
|
||||
}
|
||||
};
|
||||
}, [description]);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
# useScrollPosition
|
||||
|
||||
Tracks the vertical scroll position of the window or a specific scrollable element.
|
||||
|
||||
## Signature
|
||||
|
||||
```ts
|
||||
function useScrollPosition(options?: { targetRef?: RefObject<HTMLElement | Document> }): number;
|
||||
```
|
||||
|
||||
### Options
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
| ----------- | ------------------------------------ | ---------- | ------------------------------------------------------- |
|
||||
| `targetRef` | `RefObject<HTMLElement \| Document>` | `document` | Ref to a scrollable element; falls back to the document |
|
||||
|
||||
Returns the current `scrollY` (window) or `scrollTop` (element) as a number.
|
||||
|
||||
## Usage
|
||||
|
||||
```tsx
|
||||
// Track window scroll
|
||||
function ScrollProgress() {
|
||||
const scrollY = useScrollPosition();
|
||||
|
||||
return <div>Scrolled: {scrollY}px</div>;
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// Track a specific scrollable container
|
||||
function VirtualList() {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const scrollTop = useScrollPosition({ targetRef: containerRef });
|
||||
|
||||
return (
|
||||
<div ref={containerRef} style={{ overflowY: 'auto', height: 400 }}>
|
||||
{/* items */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Initial position is read synchronously on mount so there is no flash to `0`.
|
||||
- Automatically switches between `window.scrollY` and `element.scrollTop` based on the target.
|
||||
- Client-only (`'use client'`).
|
||||
@@ -0,0 +1 @@
|
||||
export * from './useScrollPosition';
|
||||
@@ -0,0 +1,34 @@
|
||||
'use client';
|
||||
|
||||
import { RefObject, useEffect, useState } from 'react';
|
||||
|
||||
interface UseScrollPositionProps {
|
||||
targetRef?: RefObject<HTMLElement | Document | undefined>; // Ref to the scrollable element
|
||||
}
|
||||
|
||||
export function useScrollPosition({ targetRef }: UseScrollPositionProps = {}): number {
|
||||
const [scrollPosition, setScrollPosition] = useState<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
// If the ref is not provided or its current value is null, fall back to document
|
||||
const target = targetRef?.current || document;
|
||||
const scrollable = target === document ? window : target;
|
||||
|
||||
const updatePosition = () => {
|
||||
// Determine if we're scrolling the document or a specific element
|
||||
const scrollY = target === document ? window.scrollY : (target as HTMLElement).scrollTop;
|
||||
setScrollPosition(scrollY);
|
||||
};
|
||||
|
||||
scrollable.addEventListener('scroll', updatePosition);
|
||||
|
||||
// Set the initial position
|
||||
updatePosition();
|
||||
|
||||
return () => {
|
||||
scrollable.removeEventListener('scroll', updatePosition);
|
||||
};
|
||||
}, [targetRef]);
|
||||
|
||||
return scrollPosition;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
# useViewport
|
||||
|
||||
Returns the current viewport dimensions `[height, width]` and updates them on window resize.
|
||||
|
||||
## Signature
|
||||
|
||||
```ts
|
||||
function useViewport(): [number, number];
|
||||
```
|
||||
|
||||
Returns a tuple `[height, width]` in pixels.
|
||||
|
||||
## Usage
|
||||
|
||||
```tsx
|
||||
function ResponsiveCanvas() {
|
||||
const [height, width] = useViewport();
|
||||
|
||||
return <canvas height={height} width={width} />;
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// Destructure with descriptive names
|
||||
const [viewportHeight, viewportWidth] = useViewport();
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Reads `window.innerHeight` and `window.innerWidth` — includes the scrollbar area.
|
||||
- Initialized from the current window size, so there is no flash to `[0, 0]`.
|
||||
- Client-only (`'use client'`). Will throw during SSR if accessed before mount — pair with `useMounted` if SSR is a concern.
|
||||
@@ -0,0 +1 @@
|
||||
export * from './useViewport';
|
||||
@@ -0,0 +1,23 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
type UseViewport = [number, number];
|
||||
|
||||
export function useViewport(): UseViewport {
|
||||
const [dimensions, setDimensions] = useState<UseViewport>([window.innerHeight, window.innerWidth]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = (): void => {
|
||||
setDimensions([window.innerHeight, window.innerWidth]);
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return dimensions;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2023", "DOM"],
|
||||
"skipLibCheck": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"composite": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user