Add initial project files

This commit is contained in:
Igor Martyniuk
2026-09-01 13:03:41 +03:00
parent 22e111bb44
commit 74cb5b6176
1841 changed files with 242819 additions and 30 deletions
+5
View File
@@ -0,0 +1,5 @@
*.md
node_modules
dist
public
build
+12
View File
@@ -0,0 +1,12 @@
{
"printWidth": 150,
"tabWidth": 2,
"endOfLine": "lf",
"singleQuote": true,
"trailingComma": "all",
"bracketSpacing": true,
"semi": true,
"bracketSameLine": false,
"jsxSingleQuote": false,
"arrowParens": "always"
}
+23
View File
@@ -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) |
+16
View File
@@ -0,0 +1,16 @@
{
"name": "@crux-kit/hooks",
"version": "1.3.1",
"type": "module",
"types": "src/index.ts",
"private": true,
"exports": {
".": "./src/index.ts"
},
"scripts": {},
"dependencies": {
"@crux-kit/types": "*"
},
"devDependencies": {},
"peerDependencies": {}
}
+13
View File
@@ -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';
+31
View File
@@ -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.
+1
View File
@@ -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 };
}
+64
View File
@@ -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>
);
};
```
+1
View File
@@ -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 };
+34
View File
@@ -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'`).
+1
View File
@@ -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;
}
+26
View File
@@ -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'`).
+1
View File
@@ -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;
};
+48
View File
@@ -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.
+1
View File
@@ -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;
}
+32
View File
@@ -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.
+1
View File
@@ -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;
}
+20
View File
@@ -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"]
}