@cccsaurora/howler-ui 2.18.0-dev.648 → 2.18.0-dev.667

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,137 @@
1
+ /// <reference types="vitest" />
2
+ import { renderHook } from '@testing-library/react';
3
+ import { setupLocalStorageMock } from '@cccsaurora/howler-ui/tests/mocks';
4
+ import { describe, expect, it, vi } from 'vitest';
5
+ import useLocalStorage from './useLocalStorage';
6
+ const mockLocalStorage = setupLocalStorageMock();
7
+ beforeEach(() => {
8
+ mockLocalStorage.clear();
9
+ vi.mocked(mockLocalStorage.setItem).mockClear();
10
+ vi.mocked(mockLocalStorage.removeItem).mockClear();
11
+ });
12
+ describe('useLocalStorage (no prefix)', () => {
13
+ describe('get', () => {
14
+ it('returns null when key is absent', () => {
15
+ const { result } = renderHook(() => useLocalStorage());
16
+ expect(result.current.get('testkey')).toBeNull();
17
+ });
18
+ it('returns the parsed value when key exists', () => {
19
+ mockLocalStorage.setItem('testkey', JSON.stringify(42));
20
+ const { result } = renderHook(() => useLocalStorage());
21
+ expect(result.current.get('testkey')).toBe(42);
22
+ });
23
+ it('returns null when the raw value is not valid JSON', () => {
24
+ mockLocalStorage.setItem('testkey', 'not-json{');
25
+ const { result } = renderHook(() => useLocalStorage());
26
+ expect(result.current.get('testkey')).toBeNull();
27
+ });
28
+ });
29
+ describe('set', () => {
30
+ it('serializes and writes a value', () => {
31
+ const { result } = renderHook(() => useLocalStorage());
32
+ result.current.set('testkey', { a: 1 });
33
+ expect(mockLocalStorage.setItem).toHaveBeenCalledWith('testkey', JSON.stringify({ a: 1 }));
34
+ });
35
+ it('written value can be read back', () => {
36
+ const { result } = renderHook(() => useLocalStorage());
37
+ result.current.set('testkey', 'hello');
38
+ expect(result.current.get('testkey')).toBe('hello');
39
+ });
40
+ });
41
+ describe('remove', () => {
42
+ it('removes the key', () => {
43
+ const { result } = renderHook(() => useLocalStorage());
44
+ result.current.set('testkey', 'val');
45
+ result.current.remove('testkey');
46
+ expect(mockLocalStorage.removeItem).toHaveBeenCalledWith('testkey');
47
+ expect(result.current.has('testkey')).toBe(false);
48
+ });
49
+ it('removes with a raw key when withPrefix=true', () => {
50
+ const { result } = renderHook(() => useLocalStorage('myprefix'));
51
+ result.current.remove('raw.key', true);
52
+ expect(mockLocalStorage.removeItem).toHaveBeenCalledWith('raw.key');
53
+ });
54
+ });
55
+ describe('has', () => {
56
+ it('returns false when key is absent', () => {
57
+ const { result } = renderHook(() => useLocalStorage());
58
+ expect(result.current.has('testkey')).toBe(false);
59
+ });
60
+ it('returns true when key exists', () => {
61
+ const { result } = renderHook(() => useLocalStorage());
62
+ result.current.set('testkey', 0);
63
+ expect(result.current.has('testkey')).toBe(true);
64
+ });
65
+ });
66
+ describe('keys', () => {
67
+ it('returns an empty array when storage is empty', () => {
68
+ const { result } = renderHook(() => useLocalStorage());
69
+ expect(result.current.keys()).toEqual([]);
70
+ });
71
+ it('returns all stored keys', () => {
72
+ const { result } = renderHook(() => useLocalStorage());
73
+ result.current.set('a', 1);
74
+ result.current.set('b', 2);
75
+ expect(result.current.keys()).toEqual(expect.arrayContaining(['a', 'b']));
76
+ });
77
+ });
78
+ describe('items', () => {
79
+ it('returns key-value pairs for all stored items', () => {
80
+ const { result } = renderHook(() => useLocalStorage());
81
+ result.current.set('x', 10);
82
+ result.current.set('y', 20);
83
+ const items = result.current.items();
84
+ expect(items).toEqual(expect.arrayContaining([
85
+ { key: 'x', value: 10 },
86
+ { key: 'y', value: 20 }
87
+ ]));
88
+ });
89
+ });
90
+ describe('clear', () => {
91
+ it('removes all keys when no prefix is set', () => {
92
+ const { result } = renderHook(() => useLocalStorage());
93
+ result.current.set('a', 1);
94
+ result.current.set('b', 2);
95
+ result.current.clear();
96
+ expect(result.current.keys()).toEqual([]);
97
+ });
98
+ });
99
+ });
100
+ describe('useLocalStorage (with prefix)', () => {
101
+ const PREFIX = 'ns';
102
+ describe('get / set', () => {
103
+ it('writes the key under the prefix', () => {
104
+ const { result } = renderHook(() => useLocalStorage(PREFIX));
105
+ result.current.set('item', 'val');
106
+ expect(mockLocalStorage.setItem).toHaveBeenCalledWith('ns.item', JSON.stringify('val'));
107
+ });
108
+ it('reads the key under the prefix', () => {
109
+ mockLocalStorage.setItem('ns.item', JSON.stringify('val'));
110
+ const { result } = renderHook(() => useLocalStorage(PREFIX));
111
+ expect(result.current.get('item')).toBe('val');
112
+ });
113
+ it('does not read a key stored without the prefix', () => {
114
+ mockLocalStorage.setItem('item', JSON.stringify('val'));
115
+ const { result } = renderHook(() => useLocalStorage(PREFIX));
116
+ expect(result.current.get('item')).toBeNull();
117
+ });
118
+ });
119
+ describe('remove', () => {
120
+ it('removes the key under the prefix', () => {
121
+ const { result } = renderHook(() => useLocalStorage(PREFIX));
122
+ result.current.remove('item');
123
+ expect(mockLocalStorage.removeItem).toHaveBeenCalledWith('ns.item');
124
+ });
125
+ });
126
+ describe('clear', () => {
127
+ it('only removes keys that start with the prefix', () => {
128
+ const { result } = renderHook(() => useLocalStorage(PREFIX));
129
+ result.current.set('item', 1);
130
+ mockLocalStorage.setItem('other.item', JSON.stringify(2));
131
+ vi.mocked(mockLocalStorage.removeItem).mockClear();
132
+ result.current.clear();
133
+ expect(mockLocalStorage.removeItem).toHaveBeenCalledWith('ns.item');
134
+ expect(mockLocalStorage.removeItem).not.toHaveBeenCalledWith('other.item');
135
+ });
136
+ });
137
+ });
@@ -0,0 +1,6 @@
1
+ type Primitive = string | number | boolean | null;
2
+ declare const useParamState: {
3
+ <T extends Primitive>(key: string, defaultValue?: T, list?: false): [T, (value: T) => void];
4
+ <T extends Exclude<Primitive, null>>(key: string, defaultValue: T, list: true): [T[], (value: T[]) => void];
5
+ };
6
+ export default useParamState;
@@ -0,0 +1,48 @@
1
+ import { useCallback, useState } from 'react';
2
+ import { useSearchParams } from 'react-router-dom';
3
+ const parseValue = (raw, defaultValue) => {
4
+ if (raw === null)
5
+ return defaultValue;
6
+ if (typeof defaultValue === 'boolean')
7
+ return (raw === 'true');
8
+ if (typeof defaultValue === 'number') {
9
+ const n = Number(raw);
10
+ return (isNaN(n) ? defaultValue : n);
11
+ }
12
+ return raw;
13
+ };
14
+ const serializeValue = (value) => {
15
+ if (value === null || value === undefined)
16
+ return '';
17
+ return String(value);
18
+ };
19
+ // Scalar mode
20
+ const useParamState = (key, defaultValue = null, list = false) => {
21
+ const [searchParams, setSearchParams] = useSearchParams();
22
+ const [value, setValue] = useState(() => {
23
+ if (list) {
24
+ const raws = searchParams.getAll(key);
25
+ return raws.map(r => parseValue(r, defaultValue));
26
+ }
27
+ return parseValue(searchParams.get(key), defaultValue);
28
+ });
29
+ const setter = useCallback((newValue) => {
30
+ setValue(newValue);
31
+ setSearchParams(prev => {
32
+ const next = new URLSearchParams(prev);
33
+ next.delete(key);
34
+ if (list) {
35
+ newValue.forEach(item => next.append(key, serializeValue(item)));
36
+ }
37
+ else {
38
+ const scalar = newValue;
39
+ if (scalar !== defaultValue && scalar !== null && scalar !== undefined) {
40
+ next.set(key, serializeValue(scalar));
41
+ }
42
+ }
43
+ return next;
44
+ }, { replace: true });
45
+ }, [key, defaultValue, list, setSearchParams]);
46
+ return [value, setter];
47
+ };
48
+ export default useParamState;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,167 @@
1
+ /// <reference types="vitest" />
2
+ import { act, renderHook } from '@testing-library/react';
3
+ import { createElement } from 'react';
4
+ import { MemoryRouter, useSearchParams } from 'react-router-dom';
5
+ import { describe, expect, it } from 'vitest';
6
+ import useParamState from './useParamState';
7
+ // Creates a MemoryRouter wrapper using createElement to avoid JSX in a .ts file
8
+ const makeWrapper = (search = '') => {
9
+ // eslint-disable-next-line react/function-component-definition
10
+ return ({ children }) => createElement(MemoryRouter, { initialEntries: [search ? `/?${search}` : '/'] }, children);
11
+ };
12
+ // Composite hook: exposes the param state AND the live URL params for URL-level assertions
13
+ const useParamStateWithUrl = (key, defaultValue) => {
14
+ const [value, setValue] = useParamState(key, defaultValue);
15
+ const [params] = useSearchParams();
16
+ return { value, setValue, params };
17
+ };
18
+ describe('useParamState', () => {
19
+ describe('scalar mode – initialization', () => {
20
+ it('returns the default value when the param is absent from the URL', () => {
21
+ const { result } = renderHook(() => useParamState('tab', 'dashboard'), { wrapper: makeWrapper() });
22
+ expect(result.current[0]).toBe('dashboard');
23
+ });
24
+ it('returns null when no default is provided and the param is absent', () => {
25
+ const { result } = renderHook(() => useParamState('tab'), { wrapper: makeWrapper() });
26
+ expect(result.current[0]).toBeNull();
27
+ });
28
+ it('reads a string value present in the URL', () => {
29
+ const { result } = renderHook(() => useParamState('tab', 'dashboard'), {
30
+ wrapper: makeWrapper('tab=settings')
31
+ });
32
+ expect(result.current[0]).toBe('settings');
33
+ });
34
+ it('coerces a URL string to a number when defaultValue is a number', () => {
35
+ const { result } = renderHook(() => useParamState('page', 0), { wrapper: makeWrapper('page=3') });
36
+ expect(result.current[0]).toBe(3);
37
+ });
38
+ it('returns the default number when the URL value is non-numeric', () => {
39
+ const { result } = renderHook(() => useParamState('page', 0), {
40
+ wrapper: makeWrapper('page=notanumber')
41
+ });
42
+ expect(result.current[0]).toBe(0);
43
+ });
44
+ it("coerces 'true' string to boolean true when defaultValue is boolean", () => {
45
+ const { result } = renderHook(() => useParamState('active', false), {
46
+ wrapper: makeWrapper('active=true')
47
+ });
48
+ expect(result.current[0]).toBe(true);
49
+ });
50
+ it("coerces 'false' string to boolean false when defaultValue is boolean", () => {
51
+ const { result } = renderHook(() => useParamState('active', true), {
52
+ wrapper: makeWrapper('active=false')
53
+ });
54
+ expect(result.current[0]).toBe(false);
55
+ });
56
+ });
57
+ describe('scalar mode – setter', () => {
58
+ it('updates the in-state value', () => {
59
+ const { result } = renderHook(() => useParamState('tab', 'dashboard'), { wrapper: makeWrapper() });
60
+ act(() => result.current[1]('settings'));
61
+ expect(result.current[0]).toBe('settings');
62
+ });
63
+ it('writes the new value to the URL', () => {
64
+ const { result } = renderHook(() => useParamStateWithUrl('tab', 'dashboard'), { wrapper: makeWrapper() });
65
+ act(() => result.current.setValue('settings'));
66
+ expect(result.current.params.get('tab')).toBe('settings');
67
+ });
68
+ it('removes the param from the URL when set to the default value', () => {
69
+ const { result } = renderHook(() => useParamStateWithUrl('tab', 'dashboard'), {
70
+ wrapper: makeWrapper('tab=settings')
71
+ });
72
+ act(() => result.current.setValue('dashboard'));
73
+ expect(result.current.params.has('tab')).toBe(false);
74
+ });
75
+ it('removes the param from the URL when set to null', () => {
76
+ const { result } = renderHook(() => useParamStateWithUrl('tab', null), {
77
+ wrapper: makeWrapper('tab=settings')
78
+ });
79
+ act(() => result.current.setValue(null));
80
+ expect(result.current.params.has('tab')).toBe(false);
81
+ });
82
+ it('serializes a number to the URL', () => {
83
+ const { result } = renderHook(() => useParamStateWithUrl('page', 0), { wrapper: makeWrapper() });
84
+ act(() => result.current.setValue(5));
85
+ expect(result.current.params.get('page')).toBe('5');
86
+ });
87
+ it('removes the param when a number is set back to its default', () => {
88
+ const { result } = renderHook(() => useParamStateWithUrl('page', 0), { wrapper: makeWrapper('page=3') });
89
+ act(() => result.current.setValue(0));
90
+ expect(result.current.params.has('page')).toBe(false);
91
+ });
92
+ it('serializes boolean true to the URL', () => {
93
+ const { result } = renderHook(() => useParamStateWithUrl('active', false), { wrapper: makeWrapper() });
94
+ act(() => result.current.setValue(true));
95
+ expect(result.current.params.get('active')).toBe('true');
96
+ });
97
+ it('removes boolean param when set back to its default', () => {
98
+ const { result } = renderHook(() => useParamStateWithUrl('active', false), {
99
+ wrapper: makeWrapper('active=true')
100
+ });
101
+ act(() => result.current.setValue(false));
102
+ expect(result.current.params.has('active')).toBe(false);
103
+ });
104
+ it('does not clobber unrelated URL params when writing', () => {
105
+ const { result } = renderHook(() => useParamStateWithUrl('tab', 'dashboard'), {
106
+ wrapper: makeWrapper('sort=asc')
107
+ });
108
+ act(() => result.current.setValue('settings'));
109
+ expect(result.current.params.get('sort')).toBe('asc');
110
+ });
111
+ });
112
+ describe('list mode – initialization', () => {
113
+ it('returns an empty array when no list params are present', () => {
114
+ const { result } = renderHook(() => useParamState('tag', 'x', true), { wrapper: makeWrapper() });
115
+ expect(result.current[0]).toEqual([]);
116
+ });
117
+ it('reads multiple values from the URL', () => {
118
+ const { result } = renderHook(() => useParamState('tag', 'x', true), {
119
+ wrapper: makeWrapper('tag=a&tag=b&tag=c')
120
+ });
121
+ expect(result.current[0]).toEqual(['a', 'b', 'c']);
122
+ });
123
+ });
124
+ describe('list mode – setter', () => {
125
+ it('updates the in-state array value', () => {
126
+ const { result } = renderHook(() => useParamState('tag', '', true), { wrapper: makeWrapper() });
127
+ act(() => result.current[1](['alpha', 'beta']));
128
+ expect(result.current[0]).toEqual(['alpha', 'beta']);
129
+ });
130
+ it('writes multiple values as repeated URL params', () => {
131
+ const { result } = renderHook(() => {
132
+ const [value, setValue] = useParamState('tag', '', true);
133
+ const [params] = useSearchParams();
134
+ return { value, setValue, params };
135
+ }, { wrapper: makeWrapper() });
136
+ act(() => result.current.setValue(['x', 'y', 'z']));
137
+ expect(result.current.params.getAll('tag')).toEqual(['x', 'y', 'z']);
138
+ });
139
+ it('clears all repeated params when set to an empty array', () => {
140
+ const { result } = renderHook(() => {
141
+ const [value, setValue] = useParamState('tag', '', true);
142
+ const [params] = useSearchParams();
143
+ return { value, setValue, params };
144
+ }, { wrapper: makeWrapper('tag=a&tag=b') });
145
+ act(() => result.current.setValue([]));
146
+ expect(result.current.params.getAll('tag')).toEqual([]);
147
+ });
148
+ it('replaces existing params entirely on update', () => {
149
+ const { result } = renderHook(() => {
150
+ const [value, setValue] = useParamState('tag', '', true);
151
+ const [params] = useSearchParams();
152
+ return { value, setValue, params };
153
+ }, { wrapper: makeWrapper('tag=old1&tag=old2') });
154
+ act(() => result.current.setValue(['new1']));
155
+ expect(result.current.params.getAll('tag')).toEqual(['new1']);
156
+ });
157
+ it('does not clobber unrelated URL params when writing list values', () => {
158
+ const { result } = renderHook(() => {
159
+ const [value, setValue] = useParamState('tag', '', true);
160
+ const [params] = useSearchParams();
161
+ return { value, setValue, params };
162
+ }, { wrapper: makeWrapper('sort=asc') });
163
+ act(() => result.current.setValue(['x']));
164
+ expect(result.current.params.get('sort')).toBe('asc');
165
+ });
166
+ });
167
+ });
package/package.json CHANGED
@@ -101,7 +101,7 @@
101
101
  "internal-slot": "1.0.7"
102
102
  },
103
103
  "type": "module",
104
- "version": "2.18.0-dev.648",
104
+ "version": "2.18.0-dev.667",
105
105
  "exports": {
106
106
  "./i18n": "./i18n.js",
107
107
  "./index.css": "./index.css",