@applica-software-guru/react-admin 1.5.352 → 1.5.354

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.
Files changed (36) hide show
  1. package/dist/components/ra-lists/List.d.ts.map +1 -1
  2. package/dist/components/ra-lists/ListTabsToolbar.d.ts +1 -1
  3. package/dist/components/ra-lists/ListTabsToolbar.d.ts.map +1 -1
  4. package/dist/components/ra-lists/ListToolbar.d.ts +2 -0
  5. package/dist/components/ra-lists/ListToolbar.d.ts.map +1 -1
  6. package/dist/components/ra-lists/ListView.d.ts +17 -0
  7. package/dist/components/ra-lists/ListView.d.ts.map +1 -1
  8. package/dist/hooks/index.d.ts +1 -0
  9. package/dist/hooks/index.d.ts.map +1 -1
  10. package/dist/hooks/useLocalStorage.d.ts +26 -0
  11. package/dist/hooks/useLocalStorage.d.ts.map +1 -1
  12. package/dist/hooks/useSessionStorage.d.ts +29 -0
  13. package/dist/hooks/useSessionStorage.d.ts.map +1 -0
  14. package/dist/react-admin.cjs.js +61 -61
  15. package/dist/react-admin.cjs.js.gz +0 -0
  16. package/dist/react-admin.cjs.js.map +1 -1
  17. package/dist/react-admin.es.js +10473 -10336
  18. package/dist/react-admin.es.js.gz +0 -0
  19. package/dist/react-admin.es.js.map +1 -1
  20. package/dist/react-admin.umd.js +59 -59
  21. package/dist/react-admin.umd.js.gz +0 -0
  22. package/dist/react-admin.umd.js.map +1 -1
  23. package/dist/utils/index.d.ts +1 -0
  24. package/dist/utils/index.d.ts.map +1 -1
  25. package/dist/utils/sessionStorage.d.ts +16 -0
  26. package/dist/utils/sessionStorage.d.ts.map +1 -0
  27. package/package.json +1 -1
  28. package/src/components/ra-lists/List.tsx +0 -1
  29. package/src/components/ra-lists/ListTabsToolbar.tsx +80 -15
  30. package/src/components/ra-lists/ListToolbar.tsx +79 -36
  31. package/src/components/ra-lists/ListView.tsx +20 -2
  32. package/src/hooks/index.ts +1 -0
  33. package/src/hooks/useLocalStorage.tsx +26 -0
  34. package/src/hooks/useSessionStorage.tsx +74 -0
  35. package/src/utils/index.ts +1 -0
  36. package/src/utils/sessionStorage.ts +73 -0
@@ -2,5 +2,6 @@ export * from './lang';
2
2
  export * from './localStorage';
3
3
  export * from './localizedValue';
4
4
  export * from './react';
5
+ export * from './sessionStorage';
5
6
  export * from './time';
6
7
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/utils/index.ts"],"names":[],"mappings":"AAAA,cAAc,QAAQ,CAAC;AACvB,cAAc,gBAAgB,CAAC;AAC/B,cAAc,kBAAkB,CAAC;AACjC,cAAc,SAAS,CAAC;AACxB,cAAc,QAAQ,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/utils/index.ts"],"names":[],"mappings":"AAAA,cAAc,QAAQ,CAAC;AACvB,cAAc,gBAAgB,CAAC;AAC/B,cAAc,kBAAkB,CAAC;AACjC,cAAc,SAAS,CAAC;AACxB,cAAc,kBAAkB,CAAC;AACjC,cAAc,QAAQ,CAAC"}
@@ -0,0 +1,16 @@
1
+ type IListener = (value: unknown) => void;
2
+ interface IStorage {
3
+ get: (key: string) => unknown | null;
4
+ set: (key: string, value: unknown) => unknown | null;
5
+ watch: (key: string, callback: IListener) => void;
6
+ unwatch: (key: string, callback?: IListener) => void;
7
+ }
8
+ declare class SessionStorage implements IStorage {
9
+ #private;
10
+ get(key: string): unknown | null;
11
+ set(key: string, value: unknown): unknown | null;
12
+ watch(key: string, callback: IListener): void;
13
+ unwatch(key: string, callback?: IListener | undefined): void;
14
+ }
15
+ export { SessionStorage };
16
+ //# sourceMappingURL=sessionStorage.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sessionStorage.d.ts","sourceRoot":"","sources":["../../../src/utils/sessionStorage.ts"],"names":[],"mappings":"AAOA,KAAK,SAAS,GAAG,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAC;AAC1C,UAAU,QAAQ;IAChB,GAAG,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,GAAG,IAAI,CAAC;IACrC,GAAG,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,KAAK,OAAO,GAAG,IAAI,CAAC;IACrD,KAAK,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,QAAQ,EAAE,SAAS,KAAK,IAAI,CAAC;IAClD,OAAO,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,SAAS,KAAK,IAAI,CAAC;CACtD;AAED,cAAM,cAAe,YAAW,QAAQ;;IAOtC,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,GAAG,IAAI;IAUhC,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,GAAG,OAAO,GAAG,IAAI;IAUhD,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,QAAQ,EAAE,SAAS,GAAG,IAAI;IAa7C,OAAO,CAAC,GAAG,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,SAAS,GAAG,SAAS,GAAG,IAAI;CAe7D;AAED,OAAO,EAAE,cAAc,EAAE,CAAC"}
package/package.json CHANGED
@@ -108,5 +108,5 @@
108
108
  "type": "module",
109
109
  "types": "dist/index.d.ts",
110
110
  "typings": "dist/index.d.ts",
111
- "version": "1.5.352"
111
+ "version": "1.5.354"
112
112
  }
@@ -125,7 +125,6 @@ const StyledList = styled(RaList, { slot: 'root' })(({ theme }) => ({
125
125
  '& .RaList-actions': {
126
126
  minHeight: 80,
127
127
  alignItems: 'center',
128
- padding: theme.spacing(2.5),
129
128
  borderTopLeftRadius: `${theme.shape.borderRadius}px`,
130
129
  borderTopRightRadius: `${theme.shape.borderRadius}px`,
131
130
  borderBottomLeftRadius: 0,
@@ -1,7 +1,10 @@
1
- import { Tab, Tabs } from '@mui/material';
2
- import { useListContext } from 'react-admin';
3
- import { ReactElement, useCallback, useEffect, useMemo, useRef, useState } from 'react';
1
+ import { Box, Tab, Tabs } from '@mui/material';
2
+ import { useTheme } from '@mui/material/styles';
3
+ import { useListContext, useResourceContext } from 'react-admin';
4
+ import { ReactElement, useCallback, useEffect, useMemo, useRef } from 'react';
4
5
  import { useListViewContext } from './ListViewProvider';
6
+ import { useSessionStorage } from '@/hooks';
7
+ import { useSearchParams } from 'react-router-dom';
5
8
 
6
9
  type ListTabToolbarConfig = {
7
10
  label: string;
@@ -19,14 +22,21 @@ function ListTabsToolbar(props: ListTabsToolbarProps) {
19
22
  const { tabs = [] } = props;
20
23
  const { setFilters, filterValues } = useListContext();
21
24
  const defaultTabIndex = useMemo(() => tabs.findIndex((tab) => tab.default) ?? 0, [tabs]);
22
- const [currentTab, setCurrentTab] = useState(defaultTabIndex);
23
- const initialized = useRef(false);
24
25
  const { setCurrentTabKey } = useListViewContext();
26
+ const resource = useResourceContext();
27
+ const theme = useTheme();
28
+
29
+ const [searchParams, setSearchParams] = useSearchParams();
25
30
 
26
31
  if (tabs.length > 0 && tabs.some((tab) => !tab.key)) {
27
32
  throw new Error('ListTabsToolbar: Each tab must have a unique key.');
28
33
  }
29
34
 
35
+ const tabGroupKey = useMemo(() => `${resource.replace(/^entities\//, '')}-tab-group`, [resource]);
36
+
37
+ const [currentTab, setCurrentTab] = useSessionStorage(tabGroupKey, defaultTabIndex);
38
+ const initialized = useRef(false);
39
+
30
40
  const buildFilters = useCallback(
31
41
  (index: number, debounce: boolean) => {
32
42
  if (setCurrentTabKey) {
@@ -41,28 +51,83 @@ function ListTabsToolbar(props: ListTabsToolbarProps) {
41
51
  [filterValues, setFilters, setCurrentTabKey, tabs]
42
52
  );
43
53
 
54
+ const updateUrlParam = useCallback(
55
+ (index: number) => {
56
+ const newParams = new URLSearchParams(searchParams.toString());
57
+ newParams.set(tabGroupKey, String(index));
58
+ setSearchParams(newParams, { replace: true });
59
+ },
60
+ [searchParams, setSearchParams, tabGroupKey]
61
+ );
62
+
63
+ useEffect(() => {
64
+ const param = searchParams.get(tabGroupKey);
65
+ if (String(param) !== String(currentTab)) {
66
+ updateUrlParam(Number(currentTab));
67
+ }
68
+ // eslint-disable-next-line react-hooks/exhaustive-deps
69
+ }, [filterValues]);
70
+
44
71
  useEffect(() => {
45
72
  if (!initialized.current) {
46
- buildFilters(defaultTabIndex, false);
73
+ const param = searchParams.get(tabGroupKey);
74
+ const initialIndex = param != null ? Number(param) : Number(currentTab);
75
+
76
+ if (param != null && String(initialIndex) !== String(currentTab)) {
77
+ setCurrentTab(initialIndex);
78
+ }
79
+
80
+ buildFilters(initialIndex, false);
47
81
  initialized.current = true;
48
82
  }
49
- }, [defaultTabIndex, buildFilters]);
83
+ // eslint-disable-next-line react-hooks/exhaustive-deps
84
+ }, [searchParams, setSearchParams, tabGroupKey, currentTab, setCurrentTab, buildFilters]);
50
85
 
51
86
  const handleChange = useCallback(
52
87
  (_: any, newIndex: number) => {
53
88
  setCurrentTab(newIndex);
54
89
  buildFilters(newIndex, true);
55
90
  },
56
- [buildFilters]
91
+ [buildFilters, setCurrentTab]
57
92
  );
58
93
 
59
- return tabs.length > 0 ? (
60
- <Tabs value={currentTab} onChange={handleChange}>
61
- {tabs.map((tab) => (
62
- <Tab icon={tab.icon} key={tab.label} label={tab.label} />
63
- ))}
64
- </Tabs>
65
- ) : null;
94
+ return (
95
+ <Box sx={{ width: '100%' }}>
96
+ {tabs.length > 0 ? (
97
+ <Tabs
98
+ value={Number(currentTab)}
99
+ onChange={handleChange}
100
+ allowScrollButtonsMobile
101
+ variant="scrollable"
102
+ scrollButtons="auto"
103
+ sx={{
104
+ width: '100%',
105
+ '.MuiTabs-scrollButtons.Mui-disabled': {
106
+ opacity: 0.2
107
+ }
108
+ }}
109
+ >
110
+ {tabs.map((tab) => (
111
+ <Tab
112
+ icon={tab.icon}
113
+ key={tab.key}
114
+ label={tab.label}
115
+ sx={{
116
+ flex: '0 0 auto',
117
+ lineHeight: 1,
118
+ whiteSpace: 'nowrap',
119
+ overflow: 'hidden',
120
+ textOverflow: 'ellipsis',
121
+ '& .MuiTab-iconWrapper': {
122
+ marginRight: theme.spacing(0.5)
123
+ }
124
+ }}
125
+ />
126
+ ))}
127
+ </Tabs>
128
+ ) : null}
129
+ </Box>
130
+ );
66
131
  }
67
132
 
68
133
  export { type ListTabToolbarConfig, ListTabsToolbar };
@@ -1,3 +1,4 @@
1
+ // ListToolbar.tsx
1
2
  import React, { ReactElement, memo } from 'react';
2
3
  import { styled, useTheme } from '@mui/material/styles';
3
4
  import PropTypes from 'prop-types';
@@ -6,62 +7,103 @@ import { Exporter, useListContext } from 'ra-core';
6
7
  import { FilterContext, FilterForm } from 'react-admin';
7
8
  import { ActiveFiltersChips, FilterSidebarContext } from './FilterSidebar';
8
9
  import { isEmpty } from 'lodash';
10
+ import { ListTabToolbarConfig, ListTabsToolbar } from './ListTabsToolbar';
9
11
 
10
12
  interface ListToolbarPropsExtended extends ListToolbarProps {
11
13
  hasFilterSidebar?: boolean;
14
+ tabs?: ListTabToolbarConfig[];
12
15
  }
13
16
 
14
17
  function ListToolbarComp(props: ListToolbarPropsExtended): ReactElement | null {
15
- const { filters, actions, className, hasFilterSidebar, ...rest } = props;
18
+ const { filters, actions, className, hasFilterSidebar, tabs, ...rest } = props;
16
19
  const theme = useTheme() as any;
17
20
  const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
18
21
  const Actions = actions ? React.cloneElement(actions, { ...rest, ...actions.props }) : null;
19
22
  const { filterValues, data, isLoading } = useListContext();
20
23
  const shouldRenderEmptyList = !isLoading && data && data.length === 0;
21
24
 
25
+ const hasTabs = tabs && tabs.length > 0;
26
+
22
27
  return Array.isArray(filters) ? (
23
28
  <FilterSidebarContext.Provider value={{ hasFilterSidebar }}>
24
29
  <FilterContext.Provider value={filters}>
25
- {isMobile && hasFilterSidebar ? (
26
- <Box
27
- sx={{
28
- border: isMobile ? (shouldRenderEmptyList ? '1px solid' : 0) : 0,
29
- borderColor: theme.palette.mode === 'dark' ? theme.palette.divider : theme.palette.grey.A800
30
- }}
31
- >
32
- {Actions}
33
- {!isEmpty(filterValues) ? (
34
- <div>
35
- <Divider />
36
- <ActiveFiltersChips />
37
- </div>
38
- ) : null}
30
+ <Root className={className}>
31
+ <Box sx={{ display: 'flex', flex: 1, flexDirection: 'column', gap: 2, width: '100%' }}>
32
+ {tabs ? <ListTabsToolbar tabs={tabs} /> : null}
33
+ <Box
34
+ sx={{
35
+ display: 'flex',
36
+ flexDirection: 'row',
37
+ alignItems: 'center',
38
+ justifyContent: 'flex-end',
39
+ gap: 2,
40
+ p: hasTabs ? 0 : theme.spacing(2.5),
41
+ px: theme.spacing(2.5),
42
+ pb: theme.spacing(2.5),
43
+ width: '100%'
44
+ }}
45
+ >
46
+ {isMobile && hasFilterSidebar ? (
47
+ <Box
48
+ sx={{
49
+ border: shouldRenderEmptyList ? '1px solid' : 0,
50
+ borderColor: theme.palette.mode === 'dark' ? theme.palette.divider : theme.palette.grey.A800,
51
+ width: '100%'
52
+ }}
53
+ >
54
+ {Actions}
55
+ {!isEmpty(filterValues) && (
56
+ <>
57
+ <Divider sx={{ my: 1 }} />
58
+ <ActiveFiltersChips />
59
+ </>
60
+ )}
61
+ </Box>
62
+ ) : (
63
+ <>
64
+ {hasFilterSidebar ? <ActiveFiltersChips /> : <FilterForm />}
65
+ <Box sx={{ flex: 1 }} />
66
+ {Actions}
67
+ </>
68
+ )}
69
+ </Box>
39
70
  </Box>
40
- ) : (
41
- <Root className={className}>
42
- {hasFilterSidebar ? <ActiveFiltersChips /> : <FilterForm />}
43
- <span />
44
- {Actions}
45
- </Root>
46
- )}
71
+ </Root>
47
72
  </FilterContext.Provider>
48
73
  </FilterSidebarContext.Provider>
49
74
  ) : (
50
75
  <Root className={className}>
51
- {filters
52
- ? React.cloneElement(filters, {
53
- ...rest,
54
- context: 'form'
55
- })
56
- : null}
57
- <span />
58
- {actions
59
- ? React.cloneElement(actions, {
60
- ...rest,
61
- filters,
62
- ...actions.props
63
- })
64
- : null}
76
+ <Box sx={{ display: 'flex', flex: 1, flexDirection: 'column', gap: 2, width: '100%' }}>
77
+ {tabs ? <ListTabsToolbar tabs={tabs} /> : null}
78
+ <Box
79
+ sx={{
80
+ display: 'flex',
81
+ flexDirection: 'row',
82
+ alignItems: 'center',
83
+ justifyContent: 'flex-end',
84
+ gap: 2,
85
+ p: hasTabs ? 0 : theme.spacing(2.5),
86
+ px: theme.spacing(2.5),
87
+ pb: theme.spacing(2.5),
88
+ width: '100%'
89
+ }}
90
+ >
91
+ {filters
92
+ ? React.cloneElement(filters, {
93
+ ...rest,
94
+ context: 'form'
95
+ })
96
+ : null}
97
+ <span />
98
+ {actions
99
+ ? React.cloneElement(actions, {
100
+ ...rest,
101
+ filters,
102
+ ...actions.props
103
+ })
104
+ : null}
105
+ </Box>
106
+ </Box>
65
107
  </Root>
66
108
  );
67
109
  }
@@ -92,6 +134,7 @@ const Root = styled('div', {
92
134
  justifyContent: 'space-between',
93
135
  alignItems: 'flex-end',
94
136
  width: '100%',
137
+ padding: 0,
95
138
  // [theme.breakpoints.down('md')]: {
96
139
  // flexWrap: 'wrap'
97
140
  // },
@@ -17,7 +17,7 @@ import { ListToolbar } from './ListToolbar';
17
17
  import { FilterSidebar } from './FilterSidebar';
18
18
  import { MainCard } from '@/components/MainCard';
19
19
  import { Empty } from '@/components/ra-lists/Empty';
20
- import { type ListTabToolbarConfig, ListTabsToolbar } from './ListTabsToolbar';
20
+ import { type ListTabToolbarConfig } from './ListTabsToolbar';
21
21
 
22
22
  const defaultActions = <DefaultActions />;
23
23
  const defaultPagination = <DefaultPagination />;
@@ -65,7 +65,6 @@ function ListView<RecordType extends RaRecord = any>(props: ListViewProps): Reac
65
65
  backgroundColor: hasAsideSidebar ? theme.palette.background.paper : 'transparent'
66
66
  }}
67
67
  >
68
- {tabs ? <ListTabsToolbar tabs={tabs} /> : null}
69
68
  {filters || actions ? (
70
69
  <ListToolbar
71
70
  className={ListClasses.actions}
@@ -73,6 +72,7 @@ function ListView<RecordType extends RaRecord = any>(props: ListViewProps): Reac
73
72
  actions={actions}
74
73
  hasCreate={hasCreate}
75
74
  hasFilterSidebar={hasFilterSidebar}
75
+ tabs={tabs}
76
76
  />
77
77
  ) : null}
78
78
  {!error && (
@@ -219,6 +219,24 @@ interface ListViewProps {
219
219
  */
220
220
  tabs?: ListTabToolbarConfig[];
221
221
 
222
+ /**
223
+ * A unique key to identify the tab group. Used to persist the selected tab in session storage.
224
+ * Must be stable across renders and unique among different List components.
225
+ *
226
+ * @example
227
+ * import { List, ListTabToolbarConfig } from 'react-admin';
228
+ * const tabs: ListTabToolbarConfig[] = [
229
+ * { label: 'Published', key: 'published', icon: <IconPublished />, filter: { status: 'published' }, default: true },
230
+ * { label: 'Drafts', key: 'drafts', icon: <IconDrafts />, filter: { status: 'draft' } },
231
+ * ];
232
+ * const PostList = () => (
233
+ * <List tabs={tabs} tabGroupKey="post-list-tabs">
234
+ * ...
235
+ * </List>
236
+ * );
237
+ */
238
+ tabGroupKey?: string;
239
+
222
240
  /**
223
241
  * The actions to display in the toolbar. defaults to Filter + Create + Export.
224
242
  *
@@ -6,5 +6,6 @@ export * from './useMemoizedObject';
6
6
  export * from './usePopoverState';
7
7
  export * from './useRefDimensions';
8
8
  export * from './useResourceTitle';
9
+ export * from './useSessionStorage';
9
10
  export * from './useSystemTheme';
10
11
  export * from './useUuid';
@@ -16,6 +16,32 @@ function buildSetter(key: string, currentValue: unknown, callback: ISetter): ISe
16
16
  };
17
17
  }
18
18
 
19
+ /**
20
+ * React hook for synchronizing a state value with browser LocalStorage.
21
+ *
22
+ * This hook provides a stateful value and a setter function, similar to `useState`,
23
+ * but persists the value in LocalStorage under the specified key. The value is
24
+ * automatically updated when LocalStorage changes (including from other tabs/windows).
25
+ *
26
+ * Use this hook only when you need to persist state across sessions or tabs.
27
+ * If you only need to share state within a single session, consider using `useSessionStorage`
28
+ *
29
+ * Example:
30
+ * ```tsx
31
+ * const [value, setValue] = useLocalStorage('myKey', 'defaultValue');
32
+ *
33
+ * // Read the value
34
+ * console.log(value);
35
+ *
36
+ * // Update the value (persists to LocalStorage)
37
+ * setValue('newValue');
38
+ * ```
39
+ *
40
+ * @param key - The LocalStorage key to store the value under. Must be a non-empty string.
41
+ * @param defaultValue - The default value to use if the key does not exist in LocalStorage.
42
+ * @returns A tuple containing the current value and a setter function to update it.
43
+ *
44
+ */
19
45
  function useLocalStorage(key: string, defaultValue?: unknown): [unknown, (newValue: unknown) => unknown] {
20
46
  yup.string().required().validateSync(key);
21
47
  const [state, setState] = useState(localStorageInstance.get(key) ?? defaultValue);
@@ -0,0 +1,74 @@
1
+ import { SessionStorage } from '@/utils';
2
+ import _ from 'lodash';
3
+ import { useEffect, useRef, useState } from 'react';
4
+ import * as yup from 'yup';
5
+
6
+ const sessionStorageInstance = new SessionStorage();
7
+
8
+ type ISetter = (newValue: unknown) => unknown;
9
+
10
+ function buildSetter(key: string, currentValue: unknown, callback: ISetter): ISetter {
11
+ return function (newValue: unknown) {
12
+ const updatedValue = _.isFunction(newValue) ? newValue(currentValue) : newValue;
13
+ sessionStorageInstance.set(key, updatedValue);
14
+ callback(updatedValue);
15
+ return updatedValue;
16
+ };
17
+ }
18
+
19
+ /**
20
+ * React hook for synchronizing a state value with browser SessionStorage.
21
+ *
22
+ * This hook provides a stateful value and a setter function, similar to `useState`,
23
+ * but persists the value in SessionStorage under the specified key across the same session.
24
+ *
25
+ * Use this hook only when you need to persist state across tabs within the same session.
26
+ * If you need to persist state across sessions, consider using `useLocalStorage`
27
+ * instead.
28
+ *
29
+ * Example:
30
+ * ```tsx
31
+ * const [value, setValue] = useSessionStorage('myKey', 'defaultValue');
32
+ *
33
+ * // Read the value
34
+ * console.log(value);
35
+ *
36
+ * // Update the value (persists to SessionStorage)
37
+ * setValue('newValue');
38
+ * ```
39
+ *
40
+ * @param key - The SessionStorage key to store the value under. Must be a non-empty string.
41
+ * @param defaultValue - The default value to use if the key does not exist in SessionStorage.
42
+ * @returns A tuple containing the current value and a setter function to update it.
43
+ *
44
+ */
45
+ function useSessionStorage(key: string, defaultValue?: unknown): [unknown, (newValue: unknown) => unknown] {
46
+ yup.string().required().validateSync(key);
47
+ const [state, setState] = useState(sessionStorageInstance.get(key) ?? defaultValue);
48
+ const defaultValueRef = useRef<unknown | undefined>(defaultValue);
49
+ const storedDefaultValue = _.isEqual(defaultValueRef.current, defaultValue) ? defaultValueRef.current : defaultValue;
50
+ const setterRef = useRef<{ key: string; value: unknown; setter: ISetter } | undefined>();
51
+ const storedSetter = setterRef.current;
52
+
53
+ useEffect(() => {
54
+ setState(sessionStorageInstance.get(key) ?? storedDefaultValue);
55
+ }, [key, storedDefaultValue, setState]);
56
+
57
+ useEffect(() => {
58
+ function listener(value: unknown) {
59
+ setState(value);
60
+ }
61
+ sessionStorageInstance.watch(key, listener);
62
+ return () => {
63
+ sessionStorageInstance.unwatch(key, listener);
64
+ };
65
+ }, [key, setState]);
66
+
67
+ if (key !== storedSetter?.key || !_.isEqual(state, storedSetter?.value)) {
68
+ setterRef.current = { key, value: state, setter: buildSetter(key, state, setState) };
69
+ }
70
+
71
+ return [state, setterRef.current?.setter ?? _.noop];
72
+ }
73
+
74
+ export { useSessionStorage };
@@ -2,4 +2,5 @@ export * from './lang';
2
2
  export * from './localStorage';
3
3
  export * from './localizedValue';
4
4
  export * from './react';
5
+ export * from './sessionStorage';
5
6
  export * from './time';
@@ -0,0 +1,73 @@
1
+ import _ from 'lodash';
2
+ import * as yup from 'yup';
3
+
4
+ const listenerSchema = yup.mixed().test('function', 'Invalid function', (value) => {
5
+ return _.isFunction(value);
6
+ });
7
+
8
+ type IListener = (value: unknown) => void;
9
+ interface IStorage {
10
+ get: (key: string) => unknown | null;
11
+ set: (key: string, value: unknown) => unknown | null;
12
+ watch: (key: string, callback: IListener) => void;
13
+ unwatch: (key: string, callback?: IListener) => void;
14
+ }
15
+
16
+ class SessionStorage implements IStorage {
17
+ #listeners: Array<{ key: string; callback: IListener }> = [];
18
+
19
+ #canAccessSessionStorage(): boolean {
20
+ return typeof window !== 'undefined';
21
+ }
22
+
23
+ get(key: string): unknown | null {
24
+ yup.string().required().validateSync(key);
25
+ if (!this.#canAccessSessionStorage()) {
26
+ return null;
27
+ } else {
28
+ const value = sessionStorage.getItem(key);
29
+ return _.isNil(value) ? null : JSON.parse(value);
30
+ }
31
+ }
32
+
33
+ set(key: string, value: unknown): unknown | null {
34
+ yup.string().required().validateSync(key);
35
+ if (this.#canAccessSessionStorage()) {
36
+ sessionStorage.setItem(key, JSON.stringify(value));
37
+ return value;
38
+ } else {
39
+ return null;
40
+ }
41
+ }
42
+
43
+ watch(key: string, callback: IListener): void {
44
+ yup.string().required().validateSync(key);
45
+ listenerSchema.validateSync(callback);
46
+ if (this.#canAccessSessionStorage()) {
47
+ this.#listeners.push({ key: key, callback: callback });
48
+ window.addEventListener('storage', (e) => {
49
+ if (e.storageArea === sessionStorage && e.key === key) {
50
+ callback(this.get(key));
51
+ }
52
+ });
53
+ }
54
+ }
55
+
56
+ unwatch(key: string, callback?: IListener | undefined): void {
57
+ yup.string().required().validateSync(key);
58
+ listenerSchema.optional().default(_.identity).validateSync(callback);
59
+ if (this.#canAccessSessionStorage()) {
60
+ if (callback !== undefined) {
61
+ this.#listeners = _.reject(this.#listeners, (listener) => {
62
+ return listener.key === key && listener.callback === callback;
63
+ });
64
+ } else {
65
+ this.#listeners = _.reject(this.#listeners, (listener) => {
66
+ return listener.key === key;
67
+ });
68
+ }
69
+ }
70
+ }
71
+ }
72
+
73
+ export { SessionStorage };