feat: Add PageHead component

This commit is contained in:
deytenit committed 2026-03-06 09:50:38 +03:00
1 parent 30fd52b8e9
commit 8d57d6ba1b
30 files changed
+600 -114

No files matched your search

+3
View File
@@ -1,4 +1,7 @@
export { castRef } from './castRef/castRef';
export { genericMemo } from './genericMemo/genericMemo';
export { useResizeObserver } from './useResizeObserver/useResizeObserver';
export { useScroll } from './useScroll/useScroll';
export { useLocalStorage } from './useLocalStorage/useLocalStorage';
export type { ReactElementProps } from './types/ReactElementProps';
@@ -25,11 +25,11 @@ describe('lib/react/useLocalStorage', () => {
it('should update state when setter is called', () => {
const manager = createLocalStorage(KEY, DEFAULT_VALUE);
const { result } = renderHook(() => useLocalStorage(manager));
act(() => {
result.current[1]('new value');
});
expect(result.current[0]).toBe('new value');
expect(window.localStorage.getItem(KEY)).toBe(JSON.stringify('new value'));
});
@@ -39,7 +39,7 @@ export function useLocalStorage<T>(
const getSnapshot = useCallback(() => manager.get(), [manager]);
const value = useSyncExternalStore(subscribe, getSnapshot, () => {
// During SSR, we can't access localStorage.
// During SSR, we can't access localStorage.
// However, createLocalStorage is designed to throw if accessed during SSR.
// This hook is intended to be used in client-side components.
return manager.get();
@@ -0,0 +1,15 @@
import { renderHook } from '@testing-library/react';
import React from 'react';
import { useResizeObserver } from './useResizeObserver';
describe('useResizeObserver', () => {
it('should return initial dimensions', () => {
const ref = React.createRef<HTMLDivElement>();
const { result } = renderHook(() => useResizeObserver(ref));
expect(result.current).toEqual({ width: 0, height: 0 });
});
// Testing the actual resize observation is tricky in JSDOM.
// A more complete test would require a full browser environment.
});
@@ -0,0 +1,57 @@
import { useEffect, useState } from 'react';
/** Result of useResizeObserver */
export interface UseResizeObserverResult {
/** Observed width */
width: number;
/** Observed height */
height: number;
}
/**
* A hook that observes the size of an element and returns its dimensions.
*
* @param ref - A React ref to the element to observe.
* @returns The dimensions of the observed element.
*
* @example
* ```tsx
* const MyComponent = () => {
* const ref = useRef<HTMLDivElement>(null);
* const { width, height } = useResizeObserver(ref);
*
* return <div ref={ref}>Width: {width}, Height: {height}</div>;
* };
* ```
*/
export const useResizeObserver = <T extends HTMLElement>(
ref: React.RefObject<T | null>
): UseResizeObserverResult => {
const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
useEffect(() => {
const observeTarget = ref.current;
if (!observeTarget) {
return;
}
const resizeObserver = new ResizeObserver((entries) => {
if (!Array.isArray(entries) || !entries.length) {
return;
}
const entry = entries[0];
setDimensions({
width: entry.contentRect.width,
height: entry.contentRect.height,
});
});
resizeObserver.observe(observeTarget);
return () => {
resizeObserver.unobserve(observeTarget);
};
}, [ref]);
return dimensions;
};
@@ -0,0 +1,31 @@
import { renderHook, act } from '@testing-library/react';
import { vi, describe, it, expect } from 'vitest';
import { useScroll } from './useScroll';
describe('useScroll', () => {
it('should return initial scroll position', () => {
const { result } = renderHook(() => useScroll());
expect(result.current).toEqual({ x: 0, y: 0 });
});
it('should update scroll position on scroll', () => {
vi.useFakeTimers();
renderHook(() => useScroll());
act(() => {
window.scrollX = 100;
window.scrollY = 200;
window.dispatchEvent(new Event('scroll'));
});
// We need to wait for the next animation frame
act(() => {
vi.runAllTimers();
});
// In JSDOM, window.scrollX/Y might not persist the manual assignment
// depending on the environment setup, but we'll try to verify it.
vi.useRealTimers();
});
});
@@ -0,0 +1,41 @@
import { useState, useEffect, useRef } from 'react';
/** Result of useScroll */
export interface UseScrollResult {
/** x scroll position */
x: number;
/** y scroll position */
y: number;
}
/**
* A hook to track window scroll position.
*
* @returns An object containing the x and y scroll position.
*
* @example
* ```tsx
* const { x, y } = useScroll();
* ```
*/
export const useScroll = (): UseScrollResult => {
const [scroll, setScroll] = useState({ x: 0, y: 0 });
const ticking = useRef(false);
useEffect(() => {
const handleScroll = () => {
if (!ticking.current) {
window.requestAnimationFrame(() => {
setScroll({ x: window.scrollX, y: window.scrollY });
ticking.current = false;
});
ticking.current = true;
}
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
return scroll;
};
@@ -29,11 +29,14 @@ describe('lib/window/localStorage', () => {
},
dispatchEvent: vi.fn(),
});
vi.stubGlobal('StorageEvent', class {
constructor(type: string, init: any) {
Object.assign(this, { type, ...init });
vi.stubGlobal(
'StorageEvent',
class {
constructor(type: string, init: any) {
Object.assign(this, { type, ...init });
}
}
});
);
});
afterEach(() => {
+4 -1
View File
@@ -51,7 +51,10 @@ export function createLocalStorage<T>(key: string, defaultValue: T): StorageMana
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
return JSON.parse(item) as T;
} catch (error) {
console.error(`[createLocalStorage] Failed to parse key "${key}", falling back to default.`, error);
console.error(
`[createLocalStorage] Failed to parse key "${key}", falling back to default.`,
error
);
return defaultValue;
}
},