Skip to content

Commit 58630ba

Browse files
authored
♻️ [Code] Various revisions (#1)
* ♻️ [Code] Various revisions * ♻️ [Demo] Add some demo UI
1 parent 2438869 commit 58630ba

40 files changed

Lines changed: 1447 additions & 675 deletions

package-lock.json

Lines changed: 412 additions & 360 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -45,20 +45,20 @@
4545
"react-transition-state": "^2.2.0"
4646
},
4747
"devDependencies": {
48-
"@eslint/js": "^9.15.0",
49-
"@types/node": "^22.10.0",
48+
"@eslint/js": "^9.16.0",
49+
"@types/node": "^22.10.1",
5050
"@types/react": "^18.3.12",
5151
"@types/react-dom": "^18.3.1",
5252
"@vitejs/plugin-react-swc": "^3.7.2",
53-
"eslint": "^9.15.0",
53+
"eslint": "^9.16.0",
5454
"eslint-plugin-react": "^7.37.2",
5555
"eslint-plugin-react-hooks": "^5.0.0",
56-
"eslint-plugin-react-refresh": "^0.4.14",
57-
"globals": "^15.12.0",
56+
"eslint-plugin-react-refresh": "^0.4.16",
57+
"globals": "^15.13.0",
5858
"lightningcss": "^1.28.2",
59-
"typescript": "^5.6.3",
60-
"typescript-eslint": "^8.15.0",
61-
"vite": "^5.4.11",
59+
"typescript": "^5.7.2",
60+
"typescript-eslint": "^8.17.0",
61+
"vite": "^6.0.2",
6262
"vite-plugin-svgr": "^4.3.0"
6363
}
6464
}

packages/classes/TypedStorage.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
export class TypedStorage<T extends Record<string, unknown>> {
2+
private registeredKeys = new Set<string>();
3+
4+
setItem<K extends keyof T>(key: K, value: T[K]) {
5+
try {
6+
const serializedValue = JSON.stringify(value);
7+
localStorage.setItem(String(key), serializedValue);
8+
this.registeredKeys.add(String(key));
9+
} catch (error) {
10+
console.error(`Failed to set item with key "${String(key)}":`, error);
11+
}
12+
}
13+
14+
getItem<K extends keyof T>(key: K) {
15+
try {
16+
const item = localStorage.getItem(String(key));
17+
// It is possible we could consider some custom validator to be set
18+
// when calling `setItem/getItem` that allows for stronger typing.
19+
return item ? (JSON.parse(item) as T[K]) : null;
20+
} catch (error) {
21+
console.error(`Failed to get item with key "${String(key)}":`, error);
22+
return null;
23+
}
24+
}
25+
26+
removeItem<K extends keyof T>(key: K) {
27+
try {
28+
localStorage.removeItem(String(key));
29+
this.registeredKeys.delete(String(key));
30+
} catch (error) {
31+
console.error(`Failed to remove item with key "${String(key)}":`, error);
32+
}
33+
}
34+
35+
clear() {
36+
try {
37+
this.registeredKeys.forEach((key) => {
38+
localStorage.removeItem(key);
39+
});
40+
this.registeredKeys.clear();
41+
} catch (error) {
42+
console.error(
43+
'Failed to clear registered items from localStorage:',
44+
error,
45+
);
46+
}
47+
}
48+
}
49+
50+
/*
51+
// Usage example:
52+
53+
interface MyLocalStorageEntries {
54+
userSettings: { theme: string; language: string };
55+
sessionId: string;
56+
}
57+
58+
const myLocalStorage = new TypedStorage<MyLocalStorageEntries>();
59+
60+
myLocalStorage.setItem('userSettings', { theme: 'dark', language: 'en' });
61+
myLocalStorage.setItem('sessionId', 'abc123');
62+
*/

packages/classes/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export {TypedStorage} from './TypedStorage.ts';

packages/hooks/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ export {
2626
type KeyPressOptions,
2727
} from './useKeyPress.ts';
2828

29+
export {useLocalStorage} from './useLocalStorage.ts';
30+
2931
export {useMediaQuery, type MediaQueryOptions} from './useMediaQuery.ts';
3032
export {useMounted} from './useMounted.ts';
3133

packages/hooks/useLocalStorage.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import {useCallback, useEffect, useSyncExternalStore} from 'react';
2+
import type {AnyObj, Fn} from 'beeftools';
3+
4+
// Adapted from:
5+
// https://usehooks.com/uselocalstorage
6+
7+
type AcceptedTypes = string | number | boolean | AnyObj;
8+
type AcceptedFn = (arg: AnyObj) => AcceptedTypes;
9+
10+
type LocalStorageValue = AcceptedTypes | AcceptedTypes[] | AcceptedFn;
11+
type ServerSnapshotFn = Parameters<typeof useSyncExternalStore>[2];
12+
13+
type LocalStorageReturn = [
14+
state: LocalStorageValue,
15+
setter: (value: LocalStorageValue) => void,
16+
];
17+
18+
function dispatchStorageEvent(key: string, newValue?: string | null) {
19+
window.dispatchEvent(new StorageEvent('storage', {key, newValue}));
20+
}
21+
22+
function parseStore(store: unknown) {
23+
return JSON.parse(store as string) as AnyObj;
24+
}
25+
26+
function setLocalStorageItem(key: string, value: LocalStorageValue) {
27+
const stringifiedValue = JSON.stringify(value);
28+
29+
window.localStorage.setItem(key, stringifiedValue);
30+
dispatchStorageEvent(key, stringifiedValue);
31+
}
32+
33+
function removeLocalStorageItem(key: string) {
34+
window.localStorage.removeItem(key);
35+
dispatchStorageEvent(key, null);
36+
}
37+
38+
function getLocalStorageItem(key: string) {
39+
return window.localStorage.getItem(key);
40+
}
41+
42+
const getLocalStorageServerSnapshot: ServerSnapshotFn = () => {
43+
throw Error('useLocalStorage is a client-only hook');
44+
};
45+
46+
function useLocalStorageSubscribe(callback: Fn) {
47+
window.addEventListener('storage', callback);
48+
49+
return () => {
50+
window.removeEventListener('storage', callback);
51+
};
52+
}
53+
54+
// TODO: Fix this to accept a `generic` / infer the correct type from `initialValue`.
55+
// For now, I prefer using the `TypedStorage` class.
56+
export function useLocalStorage(key: string, initialValue: LocalStorageValue) {
57+
const getSnapshot = () => getLocalStorageItem(key);
58+
59+
const store = useSyncExternalStore(
60+
useLocalStorageSubscribe,
61+
getSnapshot,
62+
getLocalStorageServerSnapshot,
63+
);
64+
65+
// Handles both `set` and `remove`. By passing `undefined` or `null`,
66+
// `removeLocalStorageItem` is called on the provided `key`.
67+
const setState = useCallback(
68+
(value: LocalStorageValue) => {
69+
try {
70+
const isFn = typeof value === 'function';
71+
const parsed = isFn ? parseStore(store) : undefined;
72+
const nextState = parsed ?? value;
73+
74+
if (nextState === undefined || nextState === null) {
75+
removeLocalStorageItem(key);
76+
} else {
77+
setLocalStorageItem(key, nextState);
78+
}
79+
} catch (error) {
80+
console.warn(error);
81+
}
82+
},
83+
[key, store],
84+
);
85+
86+
useEffect(() => {
87+
if (
88+
getLocalStorageItem(key) === null &&
89+
typeof initialValue !== 'undefined'
90+
) {
91+
setLocalStorageItem(key, initialValue);
92+
}
93+
}, [key, initialValue]);
94+
95+
const finalValue = store ? parseStore(store) : initialValue;
96+
const finalTuple: LocalStorageReturn = [finalValue, setState];
97+
98+
return finalTuple;
99+
}

packages/hooks/useMediaQuery.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,11 @@ export function useMediaQuery(query = '', options?: MediaQueryOptions) {
2020
const initializeWithValue = options?.initializeWithValue ?? true;
2121

2222
function getMatches(query = '') {
23-
if (IS_CLIENT) return window.matchMedia(query).matches;
24-
return defaultValue;
23+
return IS_CLIENT ? window.matchMedia(query).matches : defaultValue;
2524
}
2625

2726
const [matches, setMatches] = useState(() => {
28-
if (initializeWithValue) return getMatches(query);
29-
return defaultValue;
27+
return initializeWithValue ? getMatches(query) : defaultValue;
3028
});
3129

3230
// Handles the change event of the media query.

src/App.module.css

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,23 @@
33
grid-template-rows: auto 1fr auto;
44
min-height: 100%;
55
}
6+
7+
.Layout {
8+
display: grid;
9+
gap: 20px;
10+
margin: 0 auto;
11+
max-width: 960px;
12+
}
13+
14+
.Card {
15+
margin: 0 auto;
16+
padding: 10px;
17+
border-radius: var(--radius);
18+
color: var(--color-text);
19+
background-color: var(--shade-light-20);
20+
}
21+
22+
.invert {
23+
color: var(--color-bg);
24+
background-color: var(--color-text);
25+
}

src/App.tsx

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,43 @@
1+
import {clx} from 'beeftools';
2+
3+
import {BreakpointProvider} from '@src/providers/BreakpointProvider.tsx';
14
import {ThemeProvider} from '@src/providers/ThemeProvider.tsx';
25

36
import {Footer} from '@src/components/sections/Footer/Footer.tsx';
47
import {Header} from '@src/components/sections/Header/Header.tsx';
58
import {Main} from '@src/components/sections/Main/Main.tsx';
69

10+
import {AccordionTest} from '@src/components/ui/Accordion/Accordion.test.tsx';
11+
import {ButtonTest} from '@src/components/ui/Button/Button.test.tsx';
12+
713
import styles from './App.module.css';
814

915
function AppContent() {
1016
// Separated from `<App />` to allow use of hooks available via "app providers".
1117
return (
1218
<div className={styles.App}>
1319
<Header />
14-
<Main />
20+
21+
<Main>
22+
<div className={styles.Layout}>
23+
<div
24+
className={clx(styles.Card, {
25+
[styles.invert]: false,
26+
})}
27+
>
28+
<AccordionTest />
29+
</div>
30+
31+
<div
32+
className={clx(styles.Card, {
33+
[styles.invert]: false,
34+
})}
35+
>
36+
<ButtonTest />
37+
</div>
38+
</div>
39+
</Main>
40+
1541
<Footer />
1642
</div>
1743
);
@@ -20,7 +46,9 @@ function AppContent() {
2046
export function App() {
2147
return (
2248
<ThemeProvider>
23-
<AppContent />
49+
<BreakpointProvider>
50+
<AppContent />
51+
</BreakpointProvider>
2452
</ThemeProvider>
2553
);
2654
}

src/components/primitives/Overlay/Overlay.module.css

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
display: grid;
77
align-items: center;
88
justify-items: center;
9-
background-color: var(--shade-black-80);
9+
background-color: var(--shade-dark-80);
1010

1111
/* Relies on `react-transition-state` hook. */
1212
opacity: 1;

0 commit comments

Comments
 (0)