@variocube/app-ui 1.16.1 → 1.16.12
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.
- package/esm/storage/useStorage.js +3 -3
- package/esm/storage/useStorage.js.map +1 -1
- package/esm/storage/useStorage.spec.d.ts +4 -0
- package/esm/storage/useStorage.spec.js +61 -0
- package/esm/storage/useStorage.spec.js.map +1 -0
- package/esm/tabs/Tabs.js +93 -31
- package/esm/tabs/Tabs.js.map +1 -1
- package/package.json +6 -2
- package/src/storage/useStorage.spec.tsx +86 -0
- package/src/storage/useStorage.ts +3 -3
- package/src/tabs/Tabs.tsx +106 -38
|
@@ -16,16 +16,16 @@ export function useStorage(key, defaultValue, storageType) {
|
|
|
16
16
|
}, [key, updateStateFromStorage]);
|
|
17
17
|
const typedValue = useMemo(() => {
|
|
18
18
|
if (value === undefined) {
|
|
19
|
-
return
|
|
19
|
+
return defaultValueSerialized !== undefined ? JSON.parse(defaultValueSerialized) : undefined;
|
|
20
20
|
}
|
|
21
21
|
try {
|
|
22
22
|
return JSON.parse(value);
|
|
23
23
|
}
|
|
24
24
|
catch (e) {
|
|
25
25
|
console.warn(`Failed to parse storage value for key "${key}", falling back to default value.`, e);
|
|
26
|
-
return
|
|
26
|
+
return defaultValueSerialized !== undefined ? JSON.parse(defaultValueSerialized) : undefined;
|
|
27
27
|
}
|
|
28
|
-
}, [value, key,
|
|
28
|
+
}, [value, key, defaultValueSerialized]);
|
|
29
29
|
const setTypedValue = useCallback((newValue) => {
|
|
30
30
|
const value = JSON.stringify(newValue);
|
|
31
31
|
if (value != defaultValueSerialized) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useStorage.js","sourceRoot":"","sources":["../../src/storage/useStorage.ts"],"names":[],"mappings":"AAAA,OAAO,EAAC,WAAW,EAAE,eAAe,EAAE,OAAO,EAAE,QAAQ,EAAC,MAAM,OAAO,CAAC;AACtE,OAAO,EAAC,OAAO,EAAC,MAAM,WAAW,CAAC;AAGlC,MAAM,UAAU,UAAU,CAAI,GAAW,EAAE,YAAe,EAAE,WAAyB;IACpF,MAAM,sBAAsB,GAAG,OAAO,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,SAAS,CAAC,YAAY,CAAC,EAAE,CAAC,YAAY,CAAC,CAAC,CAAC;IAE3F,MAAM,oBAAoB,GAAG,WAAW,CAAC,GAAG,EAAE;QAC7C,MAAM,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC,GAAG,EAAE,WAAW,CAAC,CAAC;QACpD,OAAO,YAAY,aAAZ,YAAY,cAAZ,YAAY,GAAI,sBAAsB,CAAC;IAC/C,CAAC,EAAE,CAAC,GAAG,EAAE,sBAAsB,EAAE,WAAW,CAAC,CAAC,CAAC;IAE/C,MAAM,CAAC,KAAK,EAAE,QAAQ,CAAC,GAAG,QAAQ,CAAC,oBAAoB,CAAC,CAAC;IAEzD,MAAM,sBAAsB,GAAG,WAAW,CAAC,GAAG,EAAE;QAC/C,QAAQ,CAAC,oBAAoB,EAAE,CAAC,CAAC;IAClC,CAAC,EAAE,CAAC,oBAAoB,CAAC,CAAC,CAAC;IAE3B,eAAe,CAAC,GAAG,EAAE;QACpB,OAAO,CAAC,iBAAiB,CAAC,GAAG,EAAE,sBAAsB,CAAC,CAAC;QACvD,OAAO,GAAG,EAAE,CAAC,OAAO,CAAC,oBAAoB,CAAC,GAAG,EAAE,sBAAsB,CAAC,CAAC;IACxE,CAAC,EAAE,CAAC,GAAG,EAAE,sBAAsB,CAAC,CAAC,CAAC;IAElC,MAAM,UAAU,GAAG,OAAO,CAAC,GAAG,EAAE;QAC/B,IAAI,KAAK,KAAK,SAAS,EAAE,CAAC;YACzB,OAAO,
|
|
1
|
+
{"version":3,"file":"useStorage.js","sourceRoot":"","sources":["../../src/storage/useStorage.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import * as React from "react";
|
|
5
|
+
import { useRef } from "react";
|
|
6
|
+
import { act, create } from "react-test-renderer";
|
|
7
|
+
import { useStorage } from "./useStorage";
|
|
8
|
+
// Reset storage between tests
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
localStorage.clear();
|
|
11
|
+
sessionStorage.clear();
|
|
12
|
+
});
|
|
13
|
+
function TestComponent({ storageKey }) {
|
|
14
|
+
const renderCount = useRef(0);
|
|
15
|
+
renderCount.current++;
|
|
16
|
+
// Inline object literal: new reference every render (the bug trigger)
|
|
17
|
+
const [value] = useStorage(storageKey, { page: 0, size: 25 });
|
|
18
|
+
return (React.createElement("div", { "data-render-count": renderCount.current, "data-value": JSON.stringify(value) }));
|
|
19
|
+
}
|
|
20
|
+
function getTestResult(renderer) {
|
|
21
|
+
const div = renderer.root.findByType("div");
|
|
22
|
+
return {
|
|
23
|
+
renderCount: Number(div.props["data-render-count"]),
|
|
24
|
+
value: JSON.parse(div.props["data-value"]),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
describe("useStorage", () => {
|
|
28
|
+
test("does not cause infinite re-renders with unstable object defaultValue", () => {
|
|
29
|
+
let renderer;
|
|
30
|
+
act(() => {
|
|
31
|
+
renderer = create(React.createElement(TestComponent, { storageKey: "test-key" }));
|
|
32
|
+
});
|
|
33
|
+
const afterMount = getTestResult(renderer);
|
|
34
|
+
// Force a re-render with the same props (simulates parent re-render)
|
|
35
|
+
act(() => {
|
|
36
|
+
renderer.update(React.createElement(TestComponent, { storageKey: "test-key" }));
|
|
37
|
+
});
|
|
38
|
+
const afterUpdate = getTestResult(renderer);
|
|
39
|
+
// Render count should stay small, not grow unboundedly
|
|
40
|
+
expect(afterUpdate.renderCount).toBeLessThanOrEqual(3);
|
|
41
|
+
expect(afterUpdate.value).toEqual({ page: 0, size: 25 });
|
|
42
|
+
});
|
|
43
|
+
test("returns default value when no stored value exists", () => {
|
|
44
|
+
let renderer;
|
|
45
|
+
act(() => {
|
|
46
|
+
renderer = create(React.createElement(TestComponent, { storageKey: "missing-key" }));
|
|
47
|
+
});
|
|
48
|
+
const result = getTestResult(renderer);
|
|
49
|
+
expect(result.value).toEqual({ page: 0, size: 25 });
|
|
50
|
+
});
|
|
51
|
+
test("returns stored value when it exists", () => {
|
|
52
|
+
localStorage.setItem("stored-key", JSON.stringify({ page: 2, size: 50 }));
|
|
53
|
+
let renderer;
|
|
54
|
+
act(() => {
|
|
55
|
+
renderer = create(React.createElement(TestComponent, { storageKey: "stored-key" }));
|
|
56
|
+
});
|
|
57
|
+
const result = getTestResult(renderer);
|
|
58
|
+
expect(result.value).toEqual({ page: 2, size: 50 });
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
//# sourceMappingURL=useStorage.spec.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"useStorage.spec.js","sourceRoot":"","sources":["../../src/storage/useStorage.spec.tsx"],"names":[],"mappings":"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"}
|
package/esm/tabs/Tabs.js
CHANGED
|
@@ -13,7 +13,8 @@ import MoreHorizIcon from "@mui/icons-material/MoreHoriz";
|
|
|
13
13
|
import { IconButton, ListItemIcon, ListItemText, Menu, MenuItem, Stack } from "@mui/material";
|
|
14
14
|
import MuiTab from "@mui/material/Tab";
|
|
15
15
|
import MuiTabs from "@mui/material/Tabs";
|
|
16
|
-
import React, { Fragment, useEffect, useRef, useState } from "react";
|
|
16
|
+
import React, { Fragment, useCallback, useEffect, useRef, useState } from "react";
|
|
17
|
+
const OVERFLOW_BUTTON_FALLBACK_WIDTH = 48;
|
|
17
18
|
const hiddenSx = {
|
|
18
19
|
visibility: "hidden",
|
|
19
20
|
height: 0,
|
|
@@ -25,42 +26,103 @@ export function Tabs(props) {
|
|
|
25
26
|
const { orientation = "horizontal", scrollButtons = "auto", allowScrollButtonsMobile = true, sx, onChange, items: inItems, value } = props, rest = __rest(props, ["orientation", "scrollButtons", "allowScrollButtonsMobile", "sx", "onChange", "items", "value"]);
|
|
26
27
|
const items = inItems.map((item, i) => (Object.assign(Object.assign({}, item), { value: typeof item.value == "undefined" ? i : item.value })));
|
|
27
28
|
const ref = useRef(null);
|
|
29
|
+
const stackRef = useRef(null);
|
|
30
|
+
const overflowButtonRef = useRef(null);
|
|
28
31
|
const [dropdownEl, setDropdownEl] = useState(null);
|
|
29
32
|
const [overflowIndex, setOverflowIndex] = useState(items.length);
|
|
30
33
|
const selectedIndex = items.findIndex((item) => item.value == value);
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
34
|
+
// Store measured tab widths from when all tabs are visible.
|
|
35
|
+
// Uses a ref so the ResizeObserver callback always sees the latest values.
|
|
36
|
+
const tabWidthsRef = useRef([]);
|
|
37
|
+
// Guard to prevent re-entrant measurement loops when showing all tabs to measure hidden ones
|
|
38
|
+
const measuringRef = useRef(false);
|
|
39
|
+
// Dependencies: only refs (stable across renders), so [] is correct.
|
|
40
|
+
const computeOverflow = useCallback(() => {
|
|
41
|
+
if (!ref.current)
|
|
42
|
+
return;
|
|
43
|
+
if (measuringRef.current)
|
|
44
|
+
return;
|
|
45
|
+
const root = Array.from(ref.current.children).find(e => e.classList.contains("tabRoot"));
|
|
46
|
+
if (!root)
|
|
47
|
+
return;
|
|
48
|
+
const list = Array.from(root.children).find(e => e.classList.contains("tabList"));
|
|
49
|
+
if (!list)
|
|
50
|
+
return;
|
|
51
|
+
// Measure tab widths from visible tabs and cache them.
|
|
52
|
+
// Hidden tabs (display: none) have 0 width, so we skip them and keep cached values.
|
|
53
|
+
const children = Array.from(list.children);
|
|
54
|
+
let hasAllWidths = true;
|
|
55
|
+
for (let i = 0; i < children.length; i++) {
|
|
56
|
+
const w = children[i].getBoundingClientRect().width;
|
|
57
|
+
if (w > 0) {
|
|
58
|
+
tabWidthsRef.current[i] = w;
|
|
59
|
+
}
|
|
60
|
+
else if (!tabWidthsRef.current[i]) {
|
|
61
|
+
hasAllWidths = false;
|
|
44
62
|
}
|
|
45
63
|
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
64
|
+
const widths = tabWidthsRef.current;
|
|
65
|
+
// If we don't have widths for all tabs yet (e.g. some were hidden before measurement),
|
|
66
|
+
// we can't make a correct decision. Show all tabs to allow measurement on next frame.
|
|
67
|
+
// Use measuringRef guard to prevent re-entrant loops from the ResizeObserver.
|
|
68
|
+
if (!hasAllWidths || widths.length < children.length) {
|
|
69
|
+
measuringRef.current = true;
|
|
70
|
+
setOverflowIndex(children.length);
|
|
71
|
+
requestAnimationFrame(() => {
|
|
72
|
+
measuringRef.current = false;
|
|
73
|
+
computeOverflow();
|
|
74
|
+
});
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const totalTabWidth = widths.reduce((sum, w) => sum + w, 0);
|
|
78
|
+
// Get the full available width from the parent Stack.
|
|
79
|
+
// When the overflow button is shown, the MuiTabs (flex: 1) shrinks to accommodate it.
|
|
80
|
+
// We need the Stack width to know the total available space.
|
|
81
|
+
const fullWidth = stackRef.current ? stackRef.current.clientWidth : root.clientWidth;
|
|
82
|
+
// Two-pass approach:
|
|
83
|
+
// 1. If all tabs fit in the full available width, no overflow needed
|
|
84
|
+
if (totalTabWidth <= fullWidth) {
|
|
85
|
+
setOverflowIndex(children.length);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
// 2. Some tabs don't fit — reserve space for the overflow button and calculate
|
|
89
|
+
let overflowButtonWidth = OVERFLOW_BUTTON_FALLBACK_WIDTH;
|
|
90
|
+
if (overflowButtonRef.current) {
|
|
91
|
+
const margin = parseFloat(getComputedStyle(overflowButtonRef.current).marginLeft) || 0;
|
|
92
|
+
overflowButtonWidth = overflowButtonRef.current.getBoundingClientRect().width + margin;
|
|
93
|
+
}
|
|
94
|
+
const availableWidth = fullWidth - overflowButtonWidth;
|
|
95
|
+
let newOverflowIndex = 0;
|
|
96
|
+
let usedWidth = 0;
|
|
97
|
+
for (let i = 0; i < widths.length; i++) {
|
|
98
|
+
usedWidth += widths[i];
|
|
99
|
+
if (usedWidth <= availableWidth) {
|
|
100
|
+
newOverflowIndex++;
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
break;
|
|
60
104
|
}
|
|
61
105
|
}
|
|
62
|
-
|
|
63
|
-
|
|
106
|
+
// Ensure at least one tab is visible
|
|
107
|
+
setOverflowIndex(Math.max(1, newOverflowIndex));
|
|
108
|
+
}, []);
|
|
109
|
+
useEffect(() => {
|
|
110
|
+
if (!ref.current)
|
|
111
|
+
return;
|
|
112
|
+
const root = Array.from(ref.current.children).find(e => e.classList.contains("tabRoot"));
|
|
113
|
+
if (!root)
|
|
114
|
+
return;
|
|
115
|
+
const resizeObserver = new ResizeObserver(() => computeOverflow());
|
|
116
|
+
resizeObserver.observe(root);
|
|
117
|
+
return () => resizeObserver.disconnect();
|
|
118
|
+
}, [computeOverflow]);
|
|
119
|
+
// Recompute when items change (e.g., conditional tabs added/removed)
|
|
120
|
+
useEffect(() => {
|
|
121
|
+
// Reset cached widths when item count changes so stale entries are cleared
|
|
122
|
+
tabWidthsRef.current = [];
|
|
123
|
+
computeOverflow();
|
|
124
|
+
}, [inItems.length, computeOverflow]);
|
|
125
|
+
return (React.createElement(Stack, { ref: stackRef, flex: 1, direction: "row", alignItems: "center", sx: {
|
|
64
126
|
borderBottom: 1,
|
|
65
127
|
borderColor: "divider",
|
|
66
128
|
} },
|
|
@@ -70,7 +132,7 @@ export function Tabs(props) {
|
|
|
70
132
|
}, value: value, onChange: onChange }, rest), items
|
|
71
133
|
.map((tabProps, i) => (React.createElement(MuiTab, Object.assign({ key: "muiTab-" + i, iconPosition: "start" }, tabProps, { sx: Object.assign(Object.assign(Object.assign({}, tabProps.sx), (i >= overflowIndex ? { display: "none" } : undefined)), { minHeight: 48 }) }))))),
|
|
72
134
|
(overflowIndex < items.length) && (React.createElement(Fragment, null,
|
|
73
|
-
React.createElement(IconButton, { sx: {
|
|
135
|
+
React.createElement(IconButton, { ref: overflowButtonRef, sx: {
|
|
74
136
|
ml: 1,
|
|
75
137
|
}, onClick: ev => setDropdownEl(ev.currentTarget), color: selectedIndex >= overflowIndex ? "primary" : "default" },
|
|
76
138
|
React.createElement(MoreHorizIcon, { fontSize: "inherit" })),
|
package/esm/tabs/Tabs.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Tabs.js","sourceRoot":"","sources":["../../src/tabs/Tabs.tsx"],"names":[],"mappings":";;;;;;;;;;;AAAA,OAAO,aAAa,MAAM,+BAA+B,CAAC;AAC1D,OAAO,EAAC,UAAU,EAAE,YAAY,EAAE,YAAY,EAAE,IAAI,EAAE,QAAQ,EAAE,KAAK,EAAC,MAAM,eAAe,CAAC;AAC5F,OAAO,MAAiC,MAAM,mBAAmB,CAAC;AAClE,OAAO,OAAoC,MAAM,oBAAoB,CAAC;AACtE,OAAO,KAAK,EAAE,EAAC,QAAQ,EAAkB,SAAS,EAAE,MAAM,EAAE,QAAQ,EAAC,MAAM,OAAO,CAAC;
|
|
1
|
+
{"version":3,"file":"Tabs.js","sourceRoot":"","sources":["../../src/tabs/Tabs.tsx"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@variocube/app-ui",
|
|
3
|
-
"version": "1.16.
|
|
3
|
+
"version": "1.16.12",
|
|
4
4
|
"description": "Common UI components for Variocube applications.",
|
|
5
5
|
"module": "esm/index.js",
|
|
6
6
|
"types": "esm/index.d.ts",
|
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
"react-dom": "^17.0.2",
|
|
59
59
|
"react-router": "^6.3.0",
|
|
60
60
|
"react-router-dom": "^6.3.0",
|
|
61
|
-
"react-syntax-highlighter": "^
|
|
61
|
+
"react-syntax-highlighter": "^16.1.0",
|
|
62
62
|
"react-test-renderer": "^17.0.2",
|
|
63
63
|
"rimraf": "^3.0.2",
|
|
64
64
|
"ts-jest": "^29.1.2",
|
|
@@ -66,6 +66,10 @@
|
|
|
66
66
|
"vite": "^7.1.7",
|
|
67
67
|
"vite-plugin-checker": "^0.8.0"
|
|
68
68
|
},
|
|
69
|
+
"repository": {
|
|
70
|
+
"type": "git",
|
|
71
|
+
"url": "https://github.com/variocube/app-ui.git"
|
|
72
|
+
},
|
|
69
73
|
"publishConfig": {
|
|
70
74
|
"access": "public"
|
|
71
75
|
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import * as React from "react";
|
|
6
|
+
import {useRef} from "react";
|
|
7
|
+
import {act, create, ReactTestRenderer} from "react-test-renderer";
|
|
8
|
+
import {useStorage} from "./useStorage";
|
|
9
|
+
|
|
10
|
+
// Reset storage between tests
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
localStorage.clear();
|
|
13
|
+
sessionStorage.clear();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
interface TestResult<T> {
|
|
17
|
+
value: T;
|
|
18
|
+
renderCount: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function TestComponent({storageKey}: { storageKey: string }) {
|
|
22
|
+
const renderCount = useRef(0);
|
|
23
|
+
renderCount.current++;
|
|
24
|
+
|
|
25
|
+
// Inline object literal: new reference every render (the bug trigger)
|
|
26
|
+
const [value] = useStorage(storageKey, {page: 0, size: 25});
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<div
|
|
30
|
+
data-render-count={renderCount.current}
|
|
31
|
+
data-value={JSON.stringify(value)}
|
|
32
|
+
/>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function getTestResult<T>(renderer: ReactTestRenderer): TestResult<T> {
|
|
37
|
+
const div = renderer.root.findByType("div");
|
|
38
|
+
return {
|
|
39
|
+
renderCount: Number(div.props["data-render-count"]),
|
|
40
|
+
value: JSON.parse(div.props["data-value"]),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe("useStorage", () => {
|
|
45
|
+
test("does not cause infinite re-renders with unstable object defaultValue", () => {
|
|
46
|
+
let renderer: ReactTestRenderer;
|
|
47
|
+
act(() => {
|
|
48
|
+
renderer = create(<TestComponent storageKey="test-key" />);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const afterMount = getTestResult(renderer!);
|
|
52
|
+
|
|
53
|
+
// Force a re-render with the same props (simulates parent re-render)
|
|
54
|
+
act(() => {
|
|
55
|
+
renderer.update(<TestComponent storageKey="test-key" />);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const afterUpdate = getTestResult(renderer!);
|
|
59
|
+
|
|
60
|
+
// Render count should stay small, not grow unboundedly
|
|
61
|
+
expect(afterUpdate.renderCount).toBeLessThanOrEqual(3);
|
|
62
|
+
expect(afterUpdate.value).toEqual({page: 0, size: 25});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("returns default value when no stored value exists", () => {
|
|
66
|
+
let renderer: ReactTestRenderer;
|
|
67
|
+
act(() => {
|
|
68
|
+
renderer = create(<TestComponent storageKey="missing-key" />);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const result = getTestResult(renderer!);
|
|
72
|
+
expect(result.value).toEqual({page: 0, size: 25});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("returns stored value when it exists", () => {
|
|
76
|
+
localStorage.setItem("stored-key", JSON.stringify({page: 2, size: 50}));
|
|
77
|
+
|
|
78
|
+
let renderer: ReactTestRenderer;
|
|
79
|
+
act(() => {
|
|
80
|
+
renderer = create(<TestComponent storageKey="stored-key" />);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const result = getTestResult(renderer!);
|
|
84
|
+
expect(result.value).toEqual({page: 2, size: 50});
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -23,15 +23,15 @@ export function useStorage<T>(key: string, defaultValue: T, storageType?: Storag
|
|
|
23
23
|
|
|
24
24
|
const typedValue = useMemo(() => {
|
|
25
25
|
if (value === undefined) {
|
|
26
|
-
return
|
|
26
|
+
return defaultValueSerialized !== undefined ? JSON.parse(defaultValueSerialized) : undefined;
|
|
27
27
|
}
|
|
28
28
|
try {
|
|
29
29
|
return JSON.parse(value);
|
|
30
30
|
} catch (e) {
|
|
31
31
|
console.warn(`Failed to parse storage value for key "${key}", falling back to default value.`, e);
|
|
32
|
-
return
|
|
32
|
+
return defaultValueSerialized !== undefined ? JSON.parse(defaultValueSerialized) : undefined;
|
|
33
33
|
}
|
|
34
|
-
}, [value, key,
|
|
34
|
+
}, [value, key, defaultValueSerialized]);
|
|
35
35
|
|
|
36
36
|
const setTypedValue = useCallback((newValue: T) => {
|
|
37
37
|
const value = JSON.stringify(newValue);
|
package/src/tabs/Tabs.tsx
CHANGED
|
@@ -2,12 +2,9 @@ import MoreHorizIcon from "@mui/icons-material/MoreHoriz";
|
|
|
2
2
|
import {IconButton, ListItemIcon, ListItemText, Menu, MenuItem, Stack} from "@mui/material";
|
|
3
3
|
import MuiTab, {TabProps as MuiTabProps} from "@mui/material/Tab";
|
|
4
4
|
import MuiTabs, {TabsProps as MuiTabsProps} from "@mui/material/Tabs";
|
|
5
|
-
import React, {Fragment, SyntheticEvent, useEffect, useRef, useState} from "react";
|
|
5
|
+
import React, {Fragment, SyntheticEvent, useCallback, useEffect, useRef, useState} from "react";
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
index: number;
|
|
9
|
-
width: number;
|
|
10
|
-
}
|
|
7
|
+
const OVERFLOW_BUTTON_FALLBACK_WIDTH = 48;
|
|
11
8
|
|
|
12
9
|
interface TabProps extends MuiTabProps<React.ElementType> {
|
|
13
10
|
}
|
|
@@ -41,53 +38,123 @@ export function Tabs(props: TabsProps) {
|
|
|
41
38
|
value: typeof item.value == "undefined" ? i : item.value,
|
|
42
39
|
} as TabProps));
|
|
43
40
|
|
|
44
|
-
const ref = useRef(null);
|
|
41
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
42
|
+
const stackRef = useRef<HTMLDivElement>(null);
|
|
43
|
+
const overflowButtonRef = useRef<HTMLButtonElement>(null);
|
|
45
44
|
const [dropdownEl, setDropdownEl] = useState<null | HTMLElement>(null);
|
|
46
45
|
|
|
47
46
|
const [overflowIndex, setOverflowIndex] = useState<number>(items.length);
|
|
48
47
|
|
|
49
48
|
const selectedIndex = items.findIndex((item) => item.value == value);
|
|
50
49
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
50
|
+
// Store measured tab widths from when all tabs are visible.
|
|
51
|
+
// Uses a ref so the ResizeObserver callback always sees the latest values.
|
|
52
|
+
const tabWidthsRef = useRef<number[]>([]);
|
|
53
|
+
// Guard to prevent re-entrant measurement loops when showing all tabs to measure hidden ones
|
|
54
|
+
const measuringRef = useRef(false);
|
|
55
|
+
|
|
56
|
+
// Dependencies: only refs (stable across renders), so [] is correct.
|
|
57
|
+
const computeOverflow = useCallback(() => {
|
|
58
|
+
if (!ref.current) return;
|
|
59
|
+
if (measuringRef.current) return;
|
|
60
|
+
|
|
61
|
+
const root = Array.from(ref.current.children).find(e => e.classList.contains("tabRoot")) as
|
|
62
|
+
| HTMLElement
|
|
63
|
+
| undefined;
|
|
64
|
+
if (!root) return;
|
|
65
|
+
|
|
66
|
+
const list = Array.from(root.children).find(e => e.classList.contains("tabList")) as HTMLElement | undefined;
|
|
67
|
+
if (!list) return;
|
|
68
|
+
|
|
69
|
+
// Measure tab widths from visible tabs and cache them.
|
|
70
|
+
// Hidden tabs (display: none) have 0 width, so we skip them and keep cached values.
|
|
71
|
+
const children = Array.from(list.children) as HTMLElement[];
|
|
72
|
+
let hasAllWidths = true;
|
|
73
|
+
for (let i = 0; i < children.length; i++) {
|
|
74
|
+
const w = children[i].getBoundingClientRect().width;
|
|
75
|
+
if (w > 0) {
|
|
76
|
+
tabWidthsRef.current[i] = w;
|
|
77
|
+
}
|
|
78
|
+
else if (!tabWidthsRef.current[i]) {
|
|
79
|
+
hasAllWidths = false;
|
|
65
80
|
}
|
|
66
81
|
}
|
|
67
|
-
setOverflowIndex(overflowIndex);
|
|
68
|
-
}
|
|
69
82
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
83
|
+
const widths = tabWidthsRef.current;
|
|
84
|
+
|
|
85
|
+
// If we don't have widths for all tabs yet (e.g. some were hidden before measurement),
|
|
86
|
+
// we can't make a correct decision. Show all tabs to allow measurement on next frame.
|
|
87
|
+
// Use measuringRef guard to prevent re-entrant loops from the ResizeObserver.
|
|
88
|
+
if (!hasAllWidths || widths.length < children.length) {
|
|
89
|
+
measuringRef.current = true;
|
|
90
|
+
setOverflowIndex(children.length);
|
|
91
|
+
requestAnimationFrame(() => {
|
|
92
|
+
measuringRef.current = false;
|
|
93
|
+
computeOverflow();
|
|
94
|
+
});
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const totalTabWidth = widths.reduce((sum, w) => sum + w, 0);
|
|
99
|
+
|
|
100
|
+
// Get the full available width from the parent Stack.
|
|
101
|
+
// When the overflow button is shown, the MuiTabs (flex: 1) shrinks to accommodate it.
|
|
102
|
+
// We need the Stack width to know the total available space.
|
|
103
|
+
const fullWidth = stackRef.current ? stackRef.current.clientWidth : root.clientWidth;
|
|
104
|
+
|
|
105
|
+
// Two-pass approach:
|
|
106
|
+
// 1. If all tabs fit in the full available width, no overflow needed
|
|
107
|
+
if (totalTabWidth <= fullWidth) {
|
|
108
|
+
setOverflowIndex(children.length);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// 2. Some tabs don't fit — reserve space for the overflow button and calculate
|
|
113
|
+
let overflowButtonWidth = OVERFLOW_BUTTON_FALLBACK_WIDTH;
|
|
114
|
+
if (overflowButtonRef.current) {
|
|
115
|
+
const margin = parseFloat(getComputedStyle(overflowButtonRef.current).marginLeft) || 0;
|
|
116
|
+
overflowButtonWidth = overflowButtonRef.current.getBoundingClientRect().width + margin;
|
|
117
|
+
}
|
|
118
|
+
const availableWidth = fullWidth - overflowButtonWidth;
|
|
119
|
+
|
|
120
|
+
let newOverflowIndex = 0;
|
|
121
|
+
let usedWidth = 0;
|
|
122
|
+
for (let i = 0; i < widths.length; i++) {
|
|
123
|
+
usedWidth += widths[i];
|
|
124
|
+
if (usedWidth <= availableWidth) {
|
|
125
|
+
newOverflowIndex++;
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
break;
|
|
85
129
|
}
|
|
86
130
|
}
|
|
87
|
-
|
|
131
|
+
// Ensure at least one tab is visible
|
|
132
|
+
setOverflowIndex(Math.max(1, newOverflowIndex));
|
|
133
|
+
}, []);
|
|
134
|
+
|
|
135
|
+
useEffect(() => {
|
|
136
|
+
if (!ref.current) return;
|
|
137
|
+
|
|
138
|
+
const root = Array.from(ref.current.children).find(e => e.classList.contains("tabRoot")) as
|
|
139
|
+
| HTMLElement
|
|
140
|
+
| undefined;
|
|
141
|
+
if (!root) return;
|
|
142
|
+
|
|
143
|
+
const resizeObserver = new ResizeObserver(() => computeOverflow());
|
|
144
|
+
resizeObserver.observe(root);
|
|
145
|
+
return () => resizeObserver.disconnect();
|
|
146
|
+
}, [computeOverflow]);
|
|
147
|
+
|
|
148
|
+
// Recompute when items change (e.g., conditional tabs added/removed)
|
|
149
|
+
useEffect(() => {
|
|
150
|
+
// Reset cached widths when item count changes so stale entries are cleared
|
|
151
|
+
tabWidthsRef.current = [];
|
|
152
|
+
computeOverflow();
|
|
153
|
+
}, [inItems.length, computeOverflow]);
|
|
88
154
|
|
|
89
155
|
return (
|
|
90
156
|
<Stack
|
|
157
|
+
ref={stackRef}
|
|
91
158
|
flex={1}
|
|
92
159
|
direction="row"
|
|
93
160
|
alignItems="center"
|
|
@@ -131,6 +198,7 @@ export function Tabs(props: TabsProps) {
|
|
|
131
198
|
{(overflowIndex < items.length) && (
|
|
132
199
|
<Fragment>
|
|
133
200
|
<IconButton
|
|
201
|
+
ref={overflowButtonRef}
|
|
134
202
|
sx={{
|
|
135
203
|
ml: 1,
|
|
136
204
|
}}
|