diff --git a/components/flex/package.json b/components/flex/package.json index ee5398e2..f1df30a4 100644 --- a/components/flex/package.json +++ b/components/flex/package.json @@ -23,7 +23,6 @@ "scripts": { "build": "rollup --config", "clean": "rimraf dist && rimraf .turbo && rimraf node_modules && rimraf package-lock.json", - "test": "jest --config ../../jest.config.js --roots components/flex/src", "lint:check": "npm run eslint:check && npm run prettier:check && npm run stylelint:check", "lint:fix": "npm run eslint:fix && npm run prettier:fix && npm run stylelint:fix", "eslint:check": "eslint src --config ../../eslint.config.js", diff --git a/hooks/use-intersection-observer/package.json b/hooks/use-intersection-observer/package.json index 5b1ed032..c67e9d43 100644 --- a/hooks/use-intersection-observer/package.json +++ b/hooks/use-intersection-observer/package.json @@ -21,8 +21,7 @@ "scripts": { "build": "tsc --project tsconfig.build.json", "clean": "rimraf dist && rimraf .turbo && rimraf node_modules && rimraf package-lock.json", - "lint": "eslint src --config ../../eslint.config.js", - "test": "jest --config ../../jest.config.js --roots hooks/use-intersection-observer/src" + "lint": "eslint src --config ../../eslint.config.js" }, "bugs": { "url": "https://github.com/Byndyusoft/ui/issues" diff --git a/hooks/use-isomorphic-layout-effect/package.json b/hooks/use-isomorphic-layout-effect/package.json index c62c87f8..f6319d0e 100644 --- a/hooks/use-isomorphic-layout-effect/package.json +++ b/hooks/use-isomorphic-layout-effect/package.json @@ -20,8 +20,7 @@ "scripts": { "build": "tsc --project tsconfig.build.json", "clean": "rimraf dist && rimraf .turbo && rimraf node_modules && rimraf package-lock.json", - "lint": "eslint src --config ../../eslint.config.js", - "test": "jest --config ../../jest.config.js" + "lint": "eslint src --config ../../eslint.config.js" }, "bugs": { "url": "https://github.com/Byndyusoft/ui/issues" diff --git a/hooks/use-local-storage/README.md b/hooks/use-local-storage/README.md new file mode 100644 index 00000000..e1f3e654 --- /dev/null +++ b/hooks/use-local-storage/README.md @@ -0,0 +1,11 @@ +# `byndyusoft-ui/use-local-storage` + +> A custom hook that uses Local Storage API to persist state. + +## Installation + +```sh +npm i @byndyusoft-ui/use-local-storage +# or +yarn add @byndyusoft-ui/use-local-storage +``` diff --git a/hooks/use-local-storage/package.json b/hooks/use-local-storage/package.json new file mode 100644 index 00000000..915933f5 --- /dev/null +++ b/hooks/use-local-storage/package.json @@ -0,0 +1,36 @@ +{ + "name": "@byndyusoft-ui/use-local-storage", + "version": "0.1.0", + "description": "Byndyusoft UI Local Storage React Hook", + "keywords": [ + "byndyusoft", + "byndyusoft-ui", + "react", + "hook", + "local-storage" + ], + "author": "Eugene Abrosimov ", + "homepage": "https://github.com/Byndyusoft/ui/tree/master/hooks/use-local-storage#readme", + "license": "ISC", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "repository": { + "type": "git", + "url": "git+https://github.com/Byndyusoft/ui.git" + }, + "scripts": { + "build": "tsc", + "clean": "rimraf dist", + "lint": "eslint src --config ../../eslint.config.js" + }, + "bugs": { + "url": "https://github.com/Byndyusoft/ui/issues" + }, + "publishConfig": { + "access": "public" + }, + "dependencies": { + "@byndyusoft-ui/local-storage": "*", + "@byndyusoft-ui/use-event-listener": "*" + } +} diff --git a/hooks/use-local-storage/src/index.ts b/hooks/use-local-storage/src/index.ts new file mode 100644 index 00000000..f7c505a2 --- /dev/null +++ b/hooks/use-local-storage/src/index.ts @@ -0,0 +1,4 @@ +import useLocalStorage, { IUseLocalStorageActions, IUseLocalStorageOptions, TUseLocalStorage } from './useLocalStorage'; + +export { IUseLocalStorageActions, IUseLocalStorageOptions, TUseLocalStorage }; +export default useLocalStorage; diff --git a/hooks/use-local-storage/src/useLocalStorage.stories.mdx b/hooks/use-local-storage/src/useLocalStorage.stories.mdx new file mode 100644 index 00000000..3e6b09ea --- /dev/null +++ b/hooks/use-local-storage/src/useLocalStorage.stories.mdx @@ -0,0 +1,7 @@ +import { Meta } from '@storybook/addon-docs'; +import { Markdown } from '@storybook/blocks'; +import Readme from '../README.md'; + + + +{Readme} diff --git a/hooks/use-local-storage/src/useLocalStorage.stories.tsx b/hooks/use-local-storage/src/useLocalStorage.stories.tsx new file mode 100644 index 00000000..a878c43d --- /dev/null +++ b/hooks/use-local-storage/src/useLocalStorage.stories.tsx @@ -0,0 +1,42 @@ +import React, { useCallback, useRef, useState } from 'react'; +import useLocalStorage from './useLocalStorage'; +import type { Meta, StoryObj } from '@storybook/react'; + +type TTokenTemplateStory = StoryObj; + +const TokenTemplate = (): JSX.Element => { + const [token, { setValue: setToken, removeValue: removeToken }] = useLocalStorage('access', ''); + const [inputValue, setInputValue] = useState('Brand new token'); + const handleClick = useCallback(() => { + setToken(inputValue); + }, [setToken, inputValue]); + + const handleClickDelete = () => { + removeToken(); + }; + + return ( +
+
Access token: {token ?? 'no token'}
+

+ setInputValue(e.target.value)} /> +

+ + +

+ +

+
+ ); +}; +export const Token: TTokenTemplateStory = { + decorators: [() => ] +}; + +const meta: Meta = { + title: 'hooks/useLocalStorage' +}; + +export default meta; diff --git a/hooks/use-local-storage/src/useLocalStorage.tests.ts b/hooks/use-local-storage/src/useLocalStorage.tests.ts new file mode 100644 index 00000000..3c4d9e4f --- /dev/null +++ b/hooks/use-local-storage/src/useLocalStorage.tests.ts @@ -0,0 +1,133 @@ +import { act, renderHook } from '@testing-library/react-hooks'; +import useLocalStorage from './useLocalStorage'; + +describe('hooks/useLocalStorage', () => { + const KEY = 'ls-test-key'; + + beforeEach(() => { + window.localStorage.clear(); + }); + + test('initializes with default value when storage is empty', () => { + const { result } = renderHook(() => useLocalStorage(KEY, 'default')); + + expect(result.current[0]).toBe('default'); + }); + + test('reads existing value from localStorage on init', () => { + window.localStorage.setItem(KEY, JSON.stringify('stored')); + + const { result } = renderHook(() => useLocalStorage(KEY, 'default')); + + expect(result.current[0]).toBe('stored'); + }); + + test('setValue updates state and writes to localStorage', () => { + const { result } = renderHook(() => useLocalStorage(KEY, 'default')); + + act(() => { + result.current[1].setValue('next'); + }); + + expect(result.current[0]).toBe('next'); + expect(window.localStorage.getItem(KEY)).toBe(JSON.stringify('next')); + }); + + test('removeValue clears storage and resets to default', () => { + window.localStorage.setItem(KEY, JSON.stringify('stored')); + + const { result } = renderHook(() => useLocalStorage(KEY, 'default')); + + act(() => { + result.current[1].removeValue(); + }); + + expect(result.current[0]).toBe('default'); + expect(window.localStorage.getItem(KEY)).toBeNull(); + }); + + test('respects custom serializer and deserializer from options', () => { + const serialize = vi.fn((value: { foo: string }) => `(${value.foo})`); + const deserialize = vi.fn((raw: string) => ({ foo: raw.slice(1, -1) })); + + window.localStorage.setItem(KEY, '(baz)'); + + const { result, rerender } = renderHook( + (props: { initial: { foo: string } }) => + useLocalStorage<{ foo: string }>(KEY, props.initial, { serialize, deserialize }), + { + initialProps: { initial: { foo: 'default' } } + } + ); + + expect(deserialize).toHaveBeenCalledWith('(baz)'); + + act(() => { + result.current[1].setValue({ foo: 'bar' }); + }); + + expect(window.localStorage.getItem(KEY)).toBe('(bar)'); + + expect(serialize).toHaveBeenCalledWith({ foo: 'bar' }); + }); + + test('updates value when storage event for the same key and same storage area is dispatched', () => { + const { result } = renderHook(() => useLocalStorage(KEY, 'default')); + + const event = new Event('storage') as StorageEvent; + (event as any).key = KEY; + (event as any).newValue = JSON.stringify('from-event'); + (event as any).storageArea = window.localStorage; + + act(() => { + window.dispatchEvent(event); + }); + + expect(result.current[0]).toBe('from-event'); + }); + + test('do not update value when storage event for the same key and other storage area is dispatched (syncTab = false)', () => { + const { result } = renderHook(() => useLocalStorage(KEY, 'default')); + + const event = new Event('storage') as StorageEvent; + (event as any).key = KEY; + (event as any).newValue = JSON.stringify('from-event'); + (event as any).storageArea = {} as Storage; + + act(() => { + window.dispatchEvent(event); + }); + + expect(result.current[0]).toBe('default'); + }); + + test('do not update value when storage event for the other key and same storage area is dispatched (syncTab = false)', () => { + const { result } = renderHook(() => useLocalStorage(KEY, 'default')); + + const event = new Event('storage') as StorageEvent; + (event as any).key = 'other-key'; + (event as any).newValue = JSON.stringify('from-event'); + (event as any).storageArea = window.localStorage; + + act(() => { + window.dispatchEvent(event); + }); + + expect(result.current[0]).toBe('default'); + }); + + test('updates value when storage event for the same key and other storage area is dispatched (syncTab = true)', () => { + const { result } = renderHook(() => useLocalStorage(KEY, 'default', { syncTabs: true })); + + const event = new Event('storage') as StorageEvent; + (event as any).key = KEY; + (event as any).newValue = JSON.stringify('from-event'); + (event as any).storageArea = {} as Storage; + + act(() => { + window.dispatchEvent(event); + }); + + expect(result.current[0]).toBe('from-event'); + }); +}); diff --git a/hooks/use-local-storage/src/useLocalStorage.ts b/hooks/use-local-storage/src/useLocalStorage.ts new file mode 100644 index 00000000..0bfb6977 --- /dev/null +++ b/hooks/use-local-storage/src/useLocalStorage.ts @@ -0,0 +1,68 @@ +import { useCallback, useMemo, useState } from 'react'; +import { TSerializeValue, TDeserializeValue, LocalStorageService } from '@byndyusoft-ui/local-storage'; +import useEventListener from '@byndyusoft-ui/use-event-listener'; + +export interface IUseLocalStorageActions { + setValue: (value: TValue) => void; + removeValue: () => void; +} + +export interface IUseLocalStorageOptions { + serialize?: TSerializeValue; + deserialize?: TDeserializeValue; + syncTabs?: boolean; +} + +export type TUseLocalStorage = [TValue, IUseLocalStorageActions]; + +export default function useLocalStorage( + key: string, + defaultValue: TValue, + options?: IUseLocalStorageOptions +): TUseLocalStorage { + const service = useMemo( + () => + new LocalStorageService(key, defaultValue, { + serialize: options?.serialize, + deserialize: options?.deserialize + }), + [key, defaultValue, options] + ); + + const [storedValue, setStoredValue] = useState(() => service.getValue()); + + const setValue = useCallback( + (nextValue: TValue) => { + service.setValue(nextValue); + setStoredValue(nextValue); + }, + [key, options] + ); + + const removeValue = useCallback(() => { + service.removeValue(); + setStoredValue(defaultValue); + }, [key, defaultValue]); + + const handleEvent = useCallback( + (event: StorageEvent) => { + if (event.key !== key) { + return; + } + + if (window.localStorage === event.storageArea || options?.syncTabs) { + setStoredValue(event.newValue ? service.deserialize(event.newValue) : defaultValue); + } + }, + [key, options] + ); + + useEventListener('storage', handleEvent); + + const methods = useMemo>( + () => ({ setValue, removeValue }), + [setValue, removeValue] + ); + + return [storedValue, methods]; +} diff --git a/hooks/use-local-storage/tsconfig.json b/hooks/use-local-storage/tsconfig.json new file mode 100644 index 00000000..65b0e8ab --- /dev/null +++ b/hooks/use-local-storage/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "declaration": true, + "declarationDir": "dist", + "outDir": "dist", + "module": "commonjs" + }, + "include": [ + "../../types.d.ts", + "src" + ], + "exclude": [ + "node_modules" + ] +} diff --git a/package-lock.json b/package-lock.json index cf8997c6..a34f2b2d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "hasInstallScript": true, "license": "Apache-2.0", "workspaces": [ + "packages/*", "components/*", "hooks/*", "packages/*", @@ -62,7 +63,8 @@ "typescript": "^4.9.5", "vite": "^6.2.1", "vite-plugin-svgr": "^4.3.0", - "vitest": "^3.2.4" + "vitest": "^3.2.4", + "vitest-localstorage-mock": "^0.1.2" }, "peerDependencies": { "classnames": "^2.3.1", @@ -193,7 +195,7 @@ }, "hooks/use-interval": { "name": "@byndyusoft-ui/use-interval", - "version": "0.0.1", + "version": "0.1.0", "license": "Apache-2.0", "dependencies": { "@byndyusoft-ui/types": "^0.3.0", @@ -218,6 +220,15 @@ "version": "1.0.0", "license": "Apache-2.0" }, + "hooks/use-local-storage": { + "name": "@byndyusoft-ui/use-local-storage", + "version": "0.1.0", + "license": "ISC", + "dependencies": { + "@byndyusoft-ui/local-storage": "*", + "@byndyusoft-ui/use-event-listener": "*" + } + }, "hooks/use-previous": { "name": "@byndyusoft-ui/use-previous", "version": "1.0.0", @@ -228,6 +239,14 @@ "version": "0.1.0", "license": "Apache-2.0" }, + "hooks/use-session-storage": { + "name": "@byndyusoft-ui/use-session-storage", + "version": "0.1.0", + "license": "ISC", + "dependencies": { + "@byndyusoft-ui/session-storage": "*" + } + }, "hooks/use-throttled-callback": { "name": "@byndyusoft-ui/use-throttled-callback", "version": "0.2.0", @@ -248,7 +267,7 @@ }, "hooks/use-timeout": { "name": "@byndyusoft-ui/use-timeout", - "version": "2.0.1", + "version": "2.1.0", "license": "Apache-2.0", "dependencies": { "@byndyusoft-ui/use-latest-ref": "^1.0.0" @@ -757,6 +776,10 @@ "resolved": "styles/keyframes-css", "link": true }, + "node_modules/@byndyusoft-ui/local-storage": { + "resolved": "services/local-storage", + "link": true + }, "node_modules/@byndyusoft-ui/modals-provider": { "resolved": "components/modals-provider", "link": true @@ -769,6 +792,10 @@ "resolved": "styles/reset-css", "link": true }, + "node_modules/@byndyusoft-ui/session-storage": { + "resolved": "services/session-storage", + "link": true + }, "node_modules/@byndyusoft-ui/types": { "resolved": "packages/types", "link": true @@ -813,6 +840,10 @@ "resolved": "hooks/use-latest-ref", "link": true }, + "node_modules/@byndyusoft-ui/use-local-storage": { + "resolved": "hooks/use-local-storage", + "link": true + }, "node_modules/@byndyusoft-ui/use-previous": { "resolved": "hooks/use-previous", "link": true @@ -821,6 +852,10 @@ "resolved": "hooks/use-scroll-lock", "link": true }, + "node_modules/@byndyusoft-ui/use-session-storage": { + "resolved": "hooks/use-session-storage", + "link": true + }, "node_modules/@byndyusoft-ui/use-throttled-callback": { "resolved": "hooks/use-throttled-callback", "link": true @@ -1817,8 +1852,6 @@ }, "node_modules/@csstools/css-parser-algorithms": { "version": "2.7.1", - "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-2.7.1.tgz", - "integrity": "sha512-2SJS42gxmACHgikc1WGesXLIT8d/q2l0UFM7TaEeIzdFCE/FPMtTiizcPGGJtlPo2xuQzY09OhrLTzRxqJqwGw==", "dev": true, "funding": [ { @@ -1840,8 +1873,6 @@ }, "node_modules/@csstools/css-tokenizer": { "version": "2.4.1", - "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-2.4.1.tgz", - "integrity": "sha512-eQ9DIktFJBhGjioABJRtUucoWR2mwllurfnM8LuNGAqX3ViZXaUchqk+1s7jjtkFiT9ySdACsFEA3etErkALUg==", "dev": true, "funding": [ { @@ -1860,8 +1891,6 @@ }, "node_modules/@csstools/media-query-list-parser": { "version": "2.1.13", - "resolved": "https://registry.npmjs.org/@csstools/media-query-list-parser/-/media-query-list-parser-2.1.13.tgz", - "integrity": "sha512-XaHr+16KRU9Gf8XLi3q8kDlI18d5vzKSKCY510Vrtc9iNR0NJzbY9hhTmwhzYZj/ZwGL4VmB3TA9hJW0Um2qFA==", "dev": true, "funding": [ { @@ -1884,8 +1913,6 @@ }, "node_modules/@dual-bundle/import-meta-resolve": { "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@dual-bundle/import-meta-resolve/-/import-meta-resolve-4.1.0.tgz", - "integrity": "sha512-+nxncfwHM5SgAtrVzgpzJOI1ol0PkumhVo469KCf9lUi21IGcY90G98VuHm9VRrUypmAzawAHO9bs6hqeADaVg==", "dev": true, "license": "MIT", "funding": { @@ -4209,8 +4236,6 @@ }, "node_modules/@stylistic/stylelint-plugin": { "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@stylistic/stylelint-plugin/-/stylelint-plugin-2.1.3.tgz", - "integrity": "sha512-/KUcqX36AbbUk7KvNuM0dWv2XSlPa1M12CPcC//eA4MNEFsZFl+2Kf8UZCLjlIWIrDNitd591vaVkXfOwUtsFQ==", "dev": true, "license": "MIT", "dependencies": { @@ -6069,8 +6094,6 @@ }, "node_modules/braces": { "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dev": true, "license": "MIT", "dependencies": { @@ -6754,8 +6777,6 @@ }, "node_modules/css-functions-list": { "version": "3.2.3", - "resolved": "https://registry.npmjs.org/css-functions-list/-/css-functions-list-3.2.3.tgz", - "integrity": "sha512-IQOkD3hbR5KrN93MtcYuad6YPuTSUhntLHDuLEbFWE+ff2/XSZNdZG+LcbbIW5AXKg/WFIfYItIzVoHngHXZzA==", "dev": true, "license": "MIT", "engines": { @@ -8742,8 +8763,6 @@ }, "node_modules/fast-glob": { "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", "dev": true, "license": "MIT", "dependencies": { @@ -8803,8 +8822,6 @@ }, "node_modules/fastest-levenshtein": { "version": "1.0.16", - "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", - "integrity": "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==", "dev": true, "license": "MIT", "engines": { @@ -8851,8 +8868,6 @@ }, "node_modules/fill-range": { "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "dev": true, "license": "MIT", "dependencies": { @@ -9240,8 +9255,6 @@ }, "node_modules/global-modules": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/global-modules/-/global-modules-2.0.0.tgz", - "integrity": "sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A==", "dev": true, "license": "MIT", "dependencies": { @@ -9253,8 +9266,6 @@ }, "node_modules/global-prefix": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-3.0.0.tgz", - "integrity": "sha512-awConJSVCHVGND6x3tmMaKcQvwXLhjdkmomy2W+Goaui8YPgYgXJZewhg3fWC+DlfqqQuWg8AwqjGTD2nAPVWg==", "dev": true, "license": "MIT", "dependencies": { @@ -9268,8 +9279,6 @@ }, "node_modules/global-prefix/node_modules/which": { "version": "1.3.1", - "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", - "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", "dev": true, "license": "ISC", "dependencies": { @@ -9554,8 +9563,6 @@ }, "node_modules/html-tags": { "version": "3.3.1", - "resolved": "https://registry.npmjs.org/html-tags/-/html-tags-3.3.1.tgz", - "integrity": "sha512-ztqyC3kLto0e9WbNp0aeP+M3kTt+nbaIveGmUxAtZa+8iFgKLUOD4YKM5j+f3QD89bra7UeumolZHKuOXnTmeQ==", "dev": true, "license": "MIT", "engines": { @@ -10145,16 +10152,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, "node_modules/is-number-object": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", @@ -11322,8 +11319,6 @@ }, "node_modules/mathml-tag-names": { "version": "2.1.3", - "resolved": "https://registry.npmjs.org/mathml-tag-names/-/mathml-tag-names-2.1.3.tgz", - "integrity": "sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg==", "dev": true, "license": "MIT", "funding": { @@ -12844,8 +12839,6 @@ }, "node_modules/postcss-safe-parser": { "version": "7.0.1", - "resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-7.0.1.tgz", - "integrity": "sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A==", "dev": true, "funding": [ { @@ -12871,8 +12864,6 @@ }, "node_modules/postcss-scss": { "version": "4.0.9", - "resolved": "https://registry.npmjs.org/postcss-scss/-/postcss-scss-4.0.9.tgz", - "integrity": "sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A==", "dev": true, "funding": [ { @@ -14954,8 +14945,6 @@ }, "node_modules/stylelint/node_modules/@csstools/selector-specificity": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-5.0.0.tgz", - "integrity": "sha512-PCqQV3c4CoVm3kdPhyeZ07VmBRdH2EpMFA/pd9OASpOEC3aXNGoqPDAZ80D0cLpMBxnmk0+yNhGsEx31hq7Gtw==", "dev": true, "funding": [ { @@ -14984,8 +14973,6 @@ }, "node_modules/stylelint/node_modules/cosmiconfig": { "version": "9.0.0", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz", - "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", "dev": true, "license": "MIT", "dependencies": { @@ -15073,8 +15060,6 @@ }, "node_modules/stylelint/node_modules/postcss-selector-parser": { "version": "7.1.0", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", - "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", "dev": true, "license": "MIT", "dependencies": { @@ -15152,8 +15137,6 @@ }, "node_modules/svg-tags": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/svg-tags/-/svg-tags-1.0.0.tgz", - "integrity": "sha512-ovssysQTa+luh7A5Weu3Rta6FJlFBBbInjOh722LIt6klpU2/HtdUbszju/G4devcvk8PGt7FCLv5wftu3THUA==", "dev": true }, "node_modules/svgo": { @@ -15218,8 +15201,6 @@ }, "node_modules/table": { "version": "6.9.0", - "resolved": "https://registry.npmjs.org/table/-/table-6.9.0.tgz", - "integrity": "sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -15425,7 +15406,7 @@ "dev": true, "license": "MIT", "dependencies": { - "is-number": "^7.0.0" + "punycode": "^2.3.1" }, "engines": { "node": ">=8.0" @@ -16151,6 +16132,16 @@ } } }, + "node_modules/vitest-localstorage-mock": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/vitest-localstorage-mock/-/vitest-localstorage-mock-0.1.2.tgz", + "integrity": "sha512-1oee6iDWhhquzVogssbpwQi6a2F3L+nCKF2+qqyCs5tH0sOYRyTqnsfj2dtmEQiL4xtJkHLn42hEjHGESlsJHw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "vitest": "*" + } + }, "node_modules/vitest/node_modules/@vitest/expect": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", @@ -16585,8 +16576,6 @@ }, "node_modules/write-file-atomic": { "version": "5.0.1", - "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-5.0.1.tgz", - "integrity": "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==", "dev": true, "license": "ISC", "dependencies": { @@ -16599,8 +16588,6 @@ }, "node_modules/write-file-atomic/node_modules/signal-exit": { "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", "dev": true, "license": "ISC", "engines": { @@ -16780,6 +16767,22 @@ "version": "0.3.0", "license": "Apache-2.0" }, + "services/local-storage": { + "name": "@byndyusoft-ui/local-storage", + "version": "0.0.1", + "license": "ISC", + "dependencies": { + "@byndyusoft-ui/types": "^0.3.0" + } + }, + "services/session-storage": { + "name": "@byndyusoft-ui/session-storage", + "version": "0.0.1", + "license": "ISC", + "dependencies": { + "@byndyusoft-ui/types": "^0.3.0" + } + }, "styles/css-utilities": { "name": "@byndyusoft-ui/css-utilities", "version": "0.0.1", diff --git a/package.json b/package.json index 3efc09bd..726a481d 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "packageManager": "npm@10.2.4", "private": true, "workspaces": [ + "packages/*", "components/*", "hooks/*", "packages/*", @@ -15,7 +16,7 @@ "scripts": { "build": "npm run clean && turbo build", "clean": "turbo clean && rimraf .turbo", - "test": "vitest run --typecheck", + "test": "vitest run --typecheck --reporter=verbose", "test:watch": "vitest watch --typecheck", "lint:check": "turbo lint:check", "lint:fix": "turbo lint:fix", @@ -91,7 +92,8 @@ "typescript": "^4.9.5", "vite": "^6.2.1", "vite-plugin-svgr": "^4.3.0", - "vitest": "^3.2.4" + "vitest": "^3.2.4", + "vitest-localstorage-mock": "^0.1.2" }, "peerDependencies": { "classnames": "^2.3.1", diff --git a/packages/.gitkeep b/packages/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/services/.gitkeep b/services/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/services/local-storage/.npmignore b/services/local-storage/.npmignore new file mode 100644 index 00000000..85de9cf9 --- /dev/null +++ b/services/local-storage/.npmignore @@ -0,0 +1 @@ +src diff --git a/services/local-storage/README.md b/services/local-storage/README.md new file mode 100644 index 00000000..0757abc8 --- /dev/null +++ b/services/local-storage/README.md @@ -0,0 +1,9 @@ +# `@byndyusoft-ui/local-storage` + +### Installation + +```sh +npm i @byndyusoft-ui/local-storage +# or +yarn add @byndyusoft-ui/local-storage +``` diff --git a/services/local-storage/package.json b/services/local-storage/package.json new file mode 100644 index 00000000..fe74a877 --- /dev/null +++ b/services/local-storage/package.json @@ -0,0 +1,33 @@ +{ + "name": "@byndyusoft-ui/local-storage", + "version": "0.0.1", + "description": "Byndyusoft UI Local Storage Service", + "keywords": [ + "byndyusoft", + "byndyusoft-ui", + "local-storage" + ], + "author": "Eugene Abrosimov ", + "homepage": "https://github.com/Byndyusoft/ui/tree/master/services/local-storage#readme", + "license": "ISC", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "repository": { + "type": "git", + "url": "git+https://github.com/Byndyusoft/ui.git" + }, + "scripts": { + "build": "tsc", + "clean": "rimraf dist && rimraf .turbo && rimraf node_modules && rimraf package-lock.json", + "lint": "eslint src --config ../../eslint.config.js" + }, + "bugs": { + "url": "https://github.com/Byndyusoft/ui/issues" + }, + "publishConfig": { + "access": "public" + }, + "dependencies": { + "@byndyusoft-ui/types": "^0.3.0" + } +} diff --git a/services/local-storage/src/LocalStorageService.tests.ts b/services/local-storage/src/LocalStorageService.tests.ts new file mode 100644 index 00000000..f55d73da --- /dev/null +++ b/services/local-storage/src/LocalStorageService.tests.ts @@ -0,0 +1,76 @@ +import { LocalStorageService } from './LocalStorageService'; +import { TDeserializeValue, TSerializeValue } from './LocalStorageService.types'; + +describe('services/local-storage', () => { + const KEY = 'ls-test-key'; + + beforeEach(() => { + window.localStorage.clear(); + }); + + test('get returns default value when key is missing', () => { + const service = new LocalStorageService(KEY, 'default'); + + const value = service.getValue(); + + expect(value).toBe('default'); + }); + + test('set writes JSON value and get reads it back by default', () => { + const service = new LocalStorageService(KEY, 0); + + service.setValue(42); + + expect(window.localStorage.getItem(KEY)).toBe(JSON.stringify(42)); + expect(service.getValue()).toBe(42); + }); + + test('has reflects presence and removal of value', () => { + const service = new LocalStorageService(KEY, 'default'); + + expect(service.hasValue()).toBe(false); + + service.setValue('value'); + expect(service.hasValue()).toBe(true); + + service.removeValue(); + expect(service.hasValue()).toBe(false); + }); + + test('remove deletes only the specific key', () => { + const service = new LocalStorageService(KEY, 'default'); + + window.localStorage.setItem('other-key', 'other'); + service.setValue('value'); + + service.removeValue(); + + expect(window.localStorage.getItem(KEY)).toBeNull(); + expect(window.localStorage.getItem('other-key')).toBe('other'); + }); + + test('uses custom serializer and deserializer when provided', () => { + interface IValue { + foo: string; + } + + const serialize = vi.fn((value: IValue) => `(${value.foo})`) as TSerializeValue; + const deserialize = vi.fn((raw: string) => ({ foo: raw.slice(1, -1) })) as TDeserializeValue; + + const service = new LocalStorageService(KEY, { foo: 'default' }, { serialize, deserialize }); + + service.setValue({ foo: 'bar' }); + + expect(window.localStorage.getItem(KEY)).toBe('(bar)'); + + // эмулируем новое значение, чтобы протестировать десериализацию + window.localStorage.setItem(KEY, '(baz)'); + + const value = service.getValue(); + + expect(value).toEqual({ foo: 'baz' }); + + expect(serialize).toHaveBeenCalledTimes(1); + expect(deserialize).toHaveBeenCalledTimes(1); + }); +}); diff --git a/services/local-storage/src/LocalStorageService.ts b/services/local-storage/src/LocalStorageService.ts new file mode 100644 index 00000000..3041c13b --- /dev/null +++ b/services/local-storage/src/LocalStorageService.ts @@ -0,0 +1,32 @@ +import { IOptions, TDeserializeValue, TSerializeValue } from './LocalStorageService.types'; +import { defaultDeserializer, defaultSerializer, hasValue, removeValue, setValue, getValue } from './utilities'; + +export class LocalStorageService { + private readonly key: string; + private readonly defaultValue: TValue; + readonly serialize: TSerializeValue; + readonly deserialize: TDeserializeValue; + + constructor(key: string, defaultValue: TValue, options?: IOptions) { + this.key = key; + this.defaultValue = defaultValue; + this.serialize = options?.serialize ?? defaultSerializer; + this.deserialize = options?.deserialize ?? defaultDeserializer; + } + + getValue(): TValue { + return getValue(this.key, this.defaultValue, this.deserialize); + } + + hasValue(): boolean { + return hasValue(this.key); + } + + removeValue(): void { + removeValue(this.key); + } + + setValue(value: TValue): void { + setValue(this.key, value, this.serialize); + } +} diff --git a/services/local-storage/src/LocalStorageService.types.ts b/services/local-storage/src/LocalStorageService.types.ts new file mode 100644 index 00000000..5897e944 --- /dev/null +++ b/services/local-storage/src/LocalStorageService.types.ts @@ -0,0 +1,8 @@ +export type TSerializeValue = (value: V) => string; + +export type TDeserializeValue = (value: string) => V; + +export interface IOptions { + serialize?: TSerializeValue; + deserialize?: TDeserializeValue; +} diff --git a/services/local-storage/src/index.ts b/services/local-storage/src/index.ts new file mode 100644 index 00000000..237f0e25 --- /dev/null +++ b/services/local-storage/src/index.ts @@ -0,0 +1,3 @@ +export * from './utilities'; +export * from './LocalStorageService'; +export * from './LocalStorageService.types'; diff --git a/services/local-storage/src/utilities/clear.ts b/services/local-storage/src/utilities/clear.ts new file mode 100644 index 00000000..8ac031e8 --- /dev/null +++ b/services/local-storage/src/utilities/clear.ts @@ -0,0 +1,3 @@ +export function clear(): void { + window.localStorage.clear(); +} diff --git a/services/local-storage/src/utilities/defaultDeserializer.ts b/services/local-storage/src/utilities/defaultDeserializer.ts new file mode 100644 index 00000000..3fc34474 --- /dev/null +++ b/services/local-storage/src/utilities/defaultDeserializer.ts @@ -0,0 +1,3 @@ +export function defaultDeserializer(raw: string): TValue { + return JSON.parse(raw) as TValue; +} diff --git a/services/local-storage/src/utilities/defaultSerializer.ts b/services/local-storage/src/utilities/defaultSerializer.ts new file mode 100644 index 00000000..e7f2f046 --- /dev/null +++ b/services/local-storage/src/utilities/defaultSerializer.ts @@ -0,0 +1,3 @@ +export function defaultSerializer(value: TValue): string { + return JSON.stringify(value); +} diff --git a/services/local-storage/src/utilities/getValue.ts b/services/local-storage/src/utilities/getValue.ts new file mode 100644 index 00000000..4cdc3e54 --- /dev/null +++ b/services/local-storage/src/utilities/getValue.ts @@ -0,0 +1,15 @@ +import { defaultDeserializer } from './defaultDeserializer'; + +export function getValue(key: string, defaultValue: TValue, deserialize = defaultDeserializer): TValue { + const raw = window.localStorage.getItem(key); + + if (raw !== null) { + try { + return deserialize(raw); + } catch { + throw new Error('Local Storage Service: Failed to deserialize the value'); + } + } + + return defaultValue; +} diff --git a/services/local-storage/src/utilities/hasValue.ts b/services/local-storage/src/utilities/hasValue.ts new file mode 100644 index 00000000..39dcd9ba --- /dev/null +++ b/services/local-storage/src/utilities/hasValue.ts @@ -0,0 +1,3 @@ +export function hasValue(key: string): boolean { + return window.localStorage.getItem(key) !== null; +} diff --git a/services/local-storage/src/utilities/index.ts b/services/local-storage/src/utilities/index.ts new file mode 100644 index 00000000..167a275a --- /dev/null +++ b/services/local-storage/src/utilities/index.ts @@ -0,0 +1,7 @@ +export * from './clear'; +export * from './defaultDeserializer'; +export * from './defaultSerializer'; +export * from './getValue'; +export * from './hasValue'; +export * from './removeValue'; +export * from './setValue'; diff --git a/services/local-storage/src/utilities/removeValue.ts b/services/local-storage/src/utilities/removeValue.ts new file mode 100644 index 00000000..80818eac --- /dev/null +++ b/services/local-storage/src/utilities/removeValue.ts @@ -0,0 +1,3 @@ +export function removeValue(key: string): void { + window.localStorage.removeItem(key); +} diff --git a/services/local-storage/src/utilities/setValue.ts b/services/local-storage/src/utilities/setValue.ts new file mode 100644 index 00000000..aca270a9 --- /dev/null +++ b/services/local-storage/src/utilities/setValue.ts @@ -0,0 +1,9 @@ +import { defaultSerializer } from './defaultSerializer'; + +export function setValue(key: string, value: TValue, serialize = defaultSerializer): void { + try { + window.localStorage.setItem(key, serialize(value)); + } catch { + throw new Error('Local Storage Service: Failed to set the serialized value'); + } +} diff --git a/services/local-storage/tsconfig.json b/services/local-storage/tsconfig.json new file mode 100644 index 00000000..65b0e8ab --- /dev/null +++ b/services/local-storage/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "declaration": true, + "declarationDir": "dist", + "outDir": "dist", + "module": "commonjs" + }, + "include": [ + "../../types.d.ts", + "src" + ], + "exclude": [ + "node_modules" + ] +} diff --git a/setupTests.ts b/setupTests.ts index 7b0828bf..aa20487b 100644 --- a/setupTests.ts +++ b/setupTests.ts @@ -1 +1,2 @@ import '@testing-library/jest-dom'; +import 'vitest-localstorage-mock';