diff --git a/packages/stl/package.json b/packages/stl/package.json index 8789532..f369603 100644 --- a/packages/stl/package.json +++ b/packages/stl/package.json @@ -39,6 +39,9 @@ "@ermnvldmr/eslint-config": "workspace:*", "@ermnvldmr/rsbuild-config": "workspace:*", "@rsbuild/core": "^1.2.11", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.1", + "@testing-library/user-event": "^14.6.1", "@types/react": "^19.1.2", "@types/react-dom": "^19.1.2", "@vitest/coverage-v8": "^4.0.18", diff --git a/packages/stl/src/lib/index.ts b/packages/stl/src/lib/index.ts index 43a975a..2918aa4 100644 --- a/packages/stl/src/lib/index.ts +++ b/packages/stl/src/lib/index.ts @@ -1,3 +1,4 @@ export * from './types/index.js'; export * from './functions/index.js'; +export * from './window/localStorage.js'; export * from './react/index.js'; diff --git a/packages/stl/src/lib/react/index.ts b/packages/stl/src/lib/react/index.ts index 772764c..1df17a2 100644 --- a/packages/stl/src/lib/react/index.ts +++ b/packages/stl/src/lib/react/index.ts @@ -1,11 +1,4 @@ export { castRef } from './castRef/castRef'; export { genericMemo } from './genericMemo/genericMemo'; - -import type React from 'react'; - -/** - * Common props for any React element. - * Useful for forwarding props to dynamic components. - */ -export type ReactElementProps = React.HTMLAttributes & - React.AllHTMLAttributes; +export { useLocalStorage } from './useLocalStorage/useLocalStorage'; +export type { ReactElementProps } from './types/ReactElementProps'; diff --git a/packages/stl/src/lib/react/types/ReactElementProps.ts b/packages/stl/src/lib/react/types/ReactElementProps.ts new file mode 100644 index 0000000..4cf6397 --- /dev/null +++ b/packages/stl/src/lib/react/types/ReactElementProps.ts @@ -0,0 +1,8 @@ +import type React from 'react'; + +/** + * Common props for any React element. + * Useful for forwarding props to dynamic components. + */ +export type ReactElementProps = React.HTMLAttributes & + React.AllHTMLAttributes; diff --git a/packages/stl/src/lib/react/useLocalStorage/index.ts b/packages/stl/src/lib/react/useLocalStorage/index.ts new file mode 100644 index 0000000..01cda12 --- /dev/null +++ b/packages/stl/src/lib/react/useLocalStorage/index.ts @@ -0,0 +1 @@ +export * from './useLocalStorage'; diff --git a/packages/stl/src/lib/react/useLocalStorage/useLocalStorage.test.tsx b/packages/stl/src/lib/react/useLocalStorage/useLocalStorage.test.tsx new file mode 100644 index 0000000..4091eac --- /dev/null +++ b/packages/stl/src/lib/react/useLocalStorage/useLocalStorage.test.tsx @@ -0,0 +1,65 @@ +import { renderHook, act } from '@testing-library/react'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +import { useLocalStorage } from './useLocalStorage'; +import { createLocalStorage } from '../../window/localStorage'; + +describe('lib/react/useLocalStorage', () => { + const KEY = 'ermnvldmr/stl/lib/react/useLocalStorage/test'; + const DEFAULT_VALUE = 'default'; + + beforeEach(() => { + window.localStorage.clear(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should return default value initially', () => { + const manager = createLocalStorage(KEY, DEFAULT_VALUE); + const { result } = renderHook(() => useLocalStorage(manager)); + expect(result.current[0]).toBe(DEFAULT_VALUE); + }); + + 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')); + }); + + it('should sync across tabs via storage event', () => { + const manager = createLocalStorage(KEY, DEFAULT_VALUE); + const { result } = renderHook(() => useLocalStorage(manager)); + + act(() => { + // Simulate external localStorage update + window.localStorage.setItem(KEY, JSON.stringify('external update')); + window.dispatchEvent( + new StorageEvent('storage', { + key: KEY, + newValue: JSON.stringify('external update'), + }) + ); + }); + + expect(result.current[0]).toBe('external update'); + }); + + it('should support functional updates', () => { + const manager = createLocalStorage(KEY, 0); + const { result } = renderHook(() => useLocalStorage(manager)); + + act(() => { + result.current[1]((prev) => prev + 1); + }); + + expect(result.current[0]).toBe(1); + }); +}); diff --git a/packages/stl/src/lib/react/useLocalStorage/useLocalStorage.ts b/packages/stl/src/lib/react/useLocalStorage/useLocalStorage.ts new file mode 100644 index 0000000..62ea2c0 --- /dev/null +++ b/packages/stl/src/lib/react/useLocalStorage/useLocalStorage.ts @@ -0,0 +1,56 @@ +import { useCallback, useSyncExternalStore } from 'react'; + +import type { StorageManager } from '../../window/localStorage'; + +/** + * A React hook that synchronizes state with a StorageManager. + * + * It provides reactivity across the component lifecycle and synchronizes + * values across multiple browser tabs using the 'storage' event. + * + * @template T - The type of the stored value. + * @param manager - The StorageManager instance returned by createLocalStorage. + * @returns A tuple containing the current value and a setter function. + * + * @example + * ```tsx + * const [theme, setTheme] = useLocalStorage(themeStorage); + * ``` + */ +export function useLocalStorage( + manager: StorageManager +): [T, (value: T | ((prev: T) => T)) => void] { + const subscribe = useCallback( + (callback: () => void) => { + const handleStorageChange = (event: StorageEvent): void => { + if (event.key === manager.key) { + callback(); + } + }; + + window.addEventListener('storage', handleStorageChange); + return () => { + window.removeEventListener('storage', handleStorageChange); + }; + }, + [manager.key] + ); + + const getSnapshot = useCallback(() => manager.get(), [manager]); + + const value = useSyncExternalStore(subscribe, getSnapshot, () => { + // 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(); + }); + + const setValue = useCallback( + (nextValue: T | ((prev: T) => T)) => { + manager.set(nextValue); + }, + [manager] + ); + + return [value, setValue]; +} diff --git a/packages/stl/src/lib/window/localStorage.test.ts b/packages/stl/src/lib/window/localStorage.test.ts new file mode 100644 index 0000000..5167ed0 --- /dev/null +++ b/packages/stl/src/lib/window/localStorage.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +import { createLocalStorage } from './localStorage'; + +describe('lib/window/localStorage', () => { + const mockStorage: Record = {}; + const KEY = 'ermnvldmr/stl/lib/window/test'; + const DEFAULT_VALUE = { foo: 'bar' }; + + const getItem = vi.fn((key: string) => mockStorage[key] || null); + const setItem = vi.fn((key: string, value: string) => { + mockStorage[key] = value; + }); + const removeItem = vi.fn((key: string) => { + delete mockStorage[key]; + }); + + beforeEach(() => { + getItem.mockClear(); + setItem.mockClear(); + removeItem.mockClear(); + + // Mock localStorage + vi.stubGlobal('window', { + localStorage: { + getItem, + setItem, + removeItem, + }, + dispatchEvent: vi.fn(), + }); + vi.stubGlobal('StorageEvent', class { + constructor(type: string, init: any) { + Object.assign(this, { type, ...init }); + } + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + for (const key in mockStorage) delete mockStorage[key]; + }); + + it('should throw if window is undefined', () => { + vi.stubGlobal('window', undefined); + expect(() => createLocalStorage(KEY, DEFAULT_VALUE)).toThrow(); + }); + + it('should get default value if empty', () => { + const manager = createLocalStorage(KEY, DEFAULT_VALUE); + expect(manager.get()).toEqual(DEFAULT_VALUE); + }); + + it('should set and get values', () => { + const manager = createLocalStorage(KEY, DEFAULT_VALUE); + const newValue = { foo: 'updated' }; + manager.set(newValue); + expect(manager.get()).toEqual(newValue); + expect(setItem).toHaveBeenCalledWith(KEY, JSON.stringify(newValue)); + }); + + it('should support functional updates', () => { + const manager = createLocalStorage(KEY, 0); + manager.set((prev) => prev + 1); + expect(manager.get()).toBe(1); + manager.set((prev) => prev + 1); + expect(manager.get()).toBe(2); + }); + + it('should remove items', () => { + const manager = createLocalStorage(KEY, DEFAULT_VALUE); + manager.set({ foo: 'baz' }); + manager.remove(); + expect(manager.get()).toEqual(DEFAULT_VALUE); + expect(removeItem).toHaveBeenCalledWith(KEY); + }); + + it('should dispatch storage event on set', () => { + const manager = createLocalStorage(KEY, DEFAULT_VALUE); + manager.set({ foo: 'event' }); + expect(window.dispatchEvent).toHaveBeenCalled(); + }); + + it('should fallback to default value on parse error', () => { + mockStorage[KEY] = 'invalid-json'; + const manager = createLocalStorage(KEY, DEFAULT_VALUE); + expect(manager.get()).toEqual(DEFAULT_VALUE); + }); +}); diff --git a/packages/stl/src/lib/window/localStorage.ts b/packages/stl/src/lib/window/localStorage.ts new file mode 100644 index 0000000..2142278 --- /dev/null +++ b/packages/stl/src/lib/window/localStorage.ts @@ -0,0 +1,88 @@ +/** + * Manages type-safe interaction with window.localStorage. + * + * @template T - The type of the value to store (must be JSON serializable). + */ +export interface StorageManager { + /** Retrieves the current value or the default value if none exists. */ + get: () => T; + /** Updates the stored value and persists it. */ + set: (value: T | ((prev: T) => T)) => void; + /** Removes the item from storage. */ + remove: () => void; + /** The key used in localStorage. */ + readonly key: string; +} + +/** + * Creates a type-safe manager for a localStorage key. + * + * This utility is browser-only and will throw if accessed during SSR. + * Ensure you use a strict namespace for the key to avoid monorepo collisions. + * Recommended format: `ermnvldmr/{package}/{path}/{module}` + * Example: `ermnvldmr/ui/lib/theme` + * + * @param key - The unique key for storage. + * @param defaultValue - The fallback value if storage is empty or corrupted. + * @returns A StorageManager object. + * + * @example + * ```typescript + * const theme = createLocalStorage('ermnvldmr/ui/lib/theme', 'system'); + * console.log(theme.get()); + * ``` + * + * @throws Error if window or localStorage is undefined. + */ +export function createLocalStorage(key: string, defaultValue: T): StorageManager { + if (typeof window === 'undefined' || typeof window.localStorage === 'undefined') { + throw new Error( + `[createLocalStorage] Attempted to access storage for key "${key}" outside of a browser environment. ` + + 'This utility is strictly for client-side use.' + ); + } + + const manager: StorageManager = { + key, + get: () => { + try { + const item = window.localStorage.getItem(key); + if (!item) return defaultValue; + // 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); + return defaultValue; + } + }, + set: (value) => { + try { + const nextValue = value instanceof Function ? value(manager.get()) : value; + window.localStorage.setItem(key, JSON.stringify(nextValue)); + + // Dispatch a custom event to notify other listeners in the same tab + window.dispatchEvent( + new StorageEvent('storage', { + key, + newValue: JSON.stringify(nextValue), + storageArea: window.localStorage, + }) + ); + } catch (error) { + console.error(`[createLocalStorage] Failed to set key "${key}".`, error); + } + }, + remove: () => { + window.localStorage.removeItem(key); + window.dispatchEvent( + new StorageEvent('storage', { + key, + newValue: null, + storageArea: window.localStorage, + }) + ); + }, + }; + + return manager; +} diff --git a/packages/stl/tsconfig.json b/packages/stl/tsconfig.json index aad0bbf..48706b5 100644 --- a/packages/stl/tsconfig.json +++ b/packages/stl/tsconfig.json @@ -4,7 +4,7 @@ "outDir": "./dist", "declaration": true, "declarationMap": true, - "types": ["vitest/globals"] + "types": ["vitest/globals", "@testing-library/jest-dom"] }, "include": ["src/**/*"], "exclude": ["dist"] diff --git a/packages/stl/vitest.config.ts b/packages/stl/vitest.config.ts index 7b421ab..76c0153 100644 --- a/packages/stl/vitest.config.ts +++ b/packages/stl/vitest.config.ts @@ -3,6 +3,6 @@ import { mergeConfig } from 'vitest/config'; export default mergeConfig(baseVitestConfig, { test: { - // Package specific overrides + environment: 'jsdom', }, }); diff --git a/packages/ui/.config/storybook/main.ts b/packages/ui/.config/storybook/main.ts index afba678..cac63cf 100644 --- a/packages/ui/.config/storybook/main.ts +++ b/packages/ui/.config/storybook/main.ts @@ -3,13 +3,13 @@ import { resolve } from 'path'; import type { StorybookConfig } from 'storybook-react-rsbuild'; const config: StorybookConfig = { - stories: ['../src/**/*.stories.@(js|jsx|ts|tsx|mdx)'], + stories: ['../../src/**/*.stories.@(js|jsx|ts|tsx|mdx)'], addons: [ '@storybook/addon-essentials', '@storybook/addon-interactions', '@storybook/addon-a11y', '@storybook/addon-themes', - '@storybook/addon-coverage', + // '@storybook/addon-coverage', ], framework: { name: 'storybook-react-rsbuild', diff --git a/packages/ui/package.json b/packages/ui/package.json index 864d081..8f6e3c9 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -46,6 +46,7 @@ "lucide-react": "^0.503.0", "react-aria": "^3.45.0", "react-icons": "^5.5.0", + "react-stately": "^3.44.0", "tw-animate-css": "^1.2.8" }, "devDependencies": { diff --git a/packages/ui/src/components/Button/Button.tsx b/packages/ui/src/components/Button/Button.tsx index 25ef07b..16cba40 100644 --- a/packages/ui/src/components/Button/Button.tsx +++ b/packages/ui/src/components/Button/Button.tsx @@ -173,7 +173,7 @@ const ButtonComponent = forwardRef(function Button(pro }; const sharedClasses = cn( - 'inline-flex items-center justify-center font-medium transition-all duration-200 select-none outline-none relative overflow-hidden', + 'inline-flex items-center justify-center font-medium transition-all duration-200 select-none outline-none relative overflow-hidden cursor-pointer', 'active:scale-[0.98]', sizeClasses[size], roundedClasses[rounded], @@ -181,7 +181,7 @@ const ButtonComponent = forwardRef(function Button(pro fullWidth && 'w-full', isDisabled && 'opacity-50 cursor-not-allowed grayscale-[0.5] active:scale-100', isFocusVisible && 'ring-2 ring-[var(--rb-ring)] ring-offset-2', - isHovered && !isDisabled && 'brightness-95 dark:brightness-105', + isHovered && !isDisabled && 'brightness-90 dark:brightness-110', className ); diff --git a/packages/ui/src/components/Switch/Switch.stories.tsx b/packages/ui/src/components/Switch/Switch.stories.tsx new file mode 100644 index 0000000..03f689a --- /dev/null +++ b/packages/ui/src/components/Switch/Switch.stories.tsx @@ -0,0 +1,45 @@ +import React, { useState } from 'react'; + +import { Switch } from './Switch'; + +import type { Meta, StoryObj } from '@storybook/react'; + +const meta: Meta = { + title: 'Components/Switch', + component: Switch, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + children: 'Label', + }, +}; + +export const Selected: Story = { + args: { + children: 'Selected State', + isSelected: true, + }, +}; + +export const Disabled: Story = { + args: { + children: 'Disabled State', + isDisabled: true, + }, +}; + +export const Controlled: Story = { + render: function ControlledStory() { + const [isSelected, setSelected] = useState(false); + return ( + + {isSelected ? 'On' : 'Off'} + + ); + }, +}; diff --git a/packages/ui/src/components/Switch/Switch.test.tsx b/packages/ui/src/components/Switch/Switch.test.tsx new file mode 100644 index 0000000..d7b4940 --- /dev/null +++ b/packages/ui/src/components/Switch/Switch.test.tsx @@ -0,0 +1,41 @@ +import userEvent from '@testing-library/user-event'; + +import { Switch } from './Switch'; +import { render, screen } from '../../lib/testing'; + +describe('components/Switch', () => { + it('renders correctly with label', () => { + render(Toggle Label); + expect(screen.getByLabelText('Toggle Label')).toBeInTheDocument(); + expect(screen.getByText('Toggle Label')).toBeInTheDocument(); + }); + + it('handles change events', async () => { + const user = userEvent.setup(); + const handleChange = vi.fn(); + render(Switch); + + const checkbox = screen.getByRole('switch'); + await user.click(checkbox); + + expect(handleChange).toHaveBeenCalledWith(true); + }); + + it('respects isSelected prop', () => { + render( {}}>Switch); + const checkbox = screen.getByRole('switch'); + expect(checkbox).toBeChecked(); + }); + + it('respects isDisabled prop', async () => { + const user = userEvent.setup(); + const handleChange = vi.fn(); + render(Switch); + + const checkbox = screen.getByRole('switch'); + expect(checkbox).toBeDisabled(); + + await user.click(checkbox); + expect(handleChange).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/ui/src/components/Switch/Switch.tsx b/packages/ui/src/components/Switch/Switch.tsx new file mode 100644 index 0000000..94f3ca8 --- /dev/null +++ b/packages/ui/src/components/Switch/Switch.tsx @@ -0,0 +1,168 @@ +import { cn } from '@ermnvldmr/stl'; +import React, { useRef } from 'react'; +import { useSwitch, useFocusRing, VisuallyHidden } from 'react-aria'; +import { useToggleState } from 'react-stately'; + +import { Text } from '../Text/Text'; + +import type { AriaSwitchProps } from 'react-aria'; + +/** + * Switch visual style variants. + */ +export type SwitchVariant = 'solid' | 'outline' | 'ghost'; + +/** + * Switch color themes. + */ +export type SwitchColor = + | 'primary' + | 'primary-negative' + | 'secondary' + | 'secondary-negative' + | 'tertiary' + | 'tertiary-negative' + | 'error' + | 'error-negative' + | 'neutral' + | 'neutral-negative'; + +/** + * Props for the Switch component. + */ +export interface SwitchProps extends AriaSwitchProps { + /** Optional class name for the outer container */ + className?: string; + /** Optional label for the switch */ + children?: React.ReactNode; + /** Visual style variant */ + variant?: SwitchVariant; + /** Color theme */ + color?: SwitchColor; +} + +/** + * A highly accessible toggle switch component. + * + * @param props - The component props. + * @returns A React element representing the switch. + * @example + * ```tsx + * console.log(isSelected)}> + * Toggle Me + * + * ``` + */ +export function Switch(props: SwitchProps): React.JSX.Element { + const { children, className, variant = 'solid', color = 'primary' } = props; + const state = useToggleState(props); + const ref = useRef(null); + const { inputProps } = useSwitch(props, state, ref); + const { isFocusVisible, focusProps } = useFocusRing(); + + const getVariantClasses = (): string => { + const isNegative = color.endsWith('-negative'); + const baseColor = color.replace('-negative', ''); + + const colors: Record = { + primary: { base: 'var(--rb-primary-base)', text: 'var(--rb-primary-text)' }, + secondary: { base: 'var(--rb-secondary-base)', text: 'var(--rb-secondary-text)' }, + tertiary: { base: 'var(--rb-tertiary-base)', text: 'var(--rb-tertiary-text)' }, + error: { base: 'var(--rb-error-base)', text: 'var(--rb-error-text)' }, + neutral: { base: 'var(--rb-muted-base)', text: 'var(--rb-text)' }, + }; + + const colorValues = colors[baseColor]; + const backgroundColor = isNegative ? colorValues.text : colorValues.base; + + if (!state.isSelected) { + switch (variant) { + case 'solid': + return 'bg-[var(--rb-muted-base)]'; + case 'outline': + return 'bg-transparent border-[var(--rb-muted-base)] border-2'; + case 'ghost': + return 'bg-transparent border-transparent'; + default: + return 'bg-[var(--rb-muted-base)]'; + } + } + + switch (variant) { + case 'solid': + return `bg-[${backgroundColor}]`; + case 'outline': + return `bg-transparent border-[${backgroundColor}] border-2`; + case 'ghost': + return `bg-[${backgroundColor}]/20 border-transparent`; + default: + return `bg-[${backgroundColor}]`; + } + }; + + const getThumbClasses = (): string => { + const isNegative = color.endsWith('-negative'); + const baseColor = color.replace('-negative', ''); + + const colors: Record = { + primary: { text: 'var(--rb-primary-text)' }, + secondary: { text: 'var(--rb-secondary-text)' }, + tertiary: { text: 'var(--rb-tertiary-text)' }, + error: { text: 'var(--rb-error-text)' }, + neutral: { text: 'var(--rb-text)' }, + }; + + const thumbColor = isNegative ? 'var(--rb-background)' : colors[baseColor].text; + + if (!state.isSelected) { + return 'bg-[var(--rb-text)]'; + } + + switch (variant) { + case 'solid': + return `bg-[${thumbColor}]`; + case 'outline': + case 'ghost': + return `bg-[${isNegative ? 'var(--rb-text)' : 'var(--rb-' + baseColor + '-base)'}]`; + default: + return `bg-[${thumbColor}]`; + } + }; + + return ( +