Compare commits
47 Commits
8ddca02e54
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c9d6fa9e6 | ||
|
|
a0c18ff4e7 | ||
|
|
5d7911e644 | ||
|
|
3dac143f1a | ||
|
|
62d000728c | ||
|
|
93d80019c2 | ||
|
|
aeb9456204 | ||
|
|
5d5c18635d | ||
|
|
cd3a888a65 | ||
|
|
b96eb762ae | ||
|
|
c7b6af0b43 | ||
|
|
1f25cf2dc6 | ||
|
|
2757629128 | ||
|
|
d34e955021 | ||
|
|
775e1d9959 | ||
|
|
c735ba9c65 | ||
|
|
f49dd4b00f | ||
|
|
c3fd05b424 | ||
|
|
1277e355eb | ||
|
|
37461e539b | ||
|
|
3e90ec7f67 | ||
|
|
90141a539a | ||
|
|
64cfc56ccd | ||
|
|
1976cfb617 | ||
|
|
c34f86f762 | ||
|
|
8325402bc6 | ||
|
|
266f5212aa | ||
|
|
023a8889cd | ||
|
|
ed290c127b | ||
|
|
bc49139b1e | ||
|
|
6e23c34bd3 | ||
|
|
a7cb541e9d | ||
|
|
b0290852cc | ||
|
|
91ef0c2567 | ||
|
|
41079f3057 | ||
|
|
f997a954ee | ||
|
|
649239a930 | ||
|
|
eda3b193c6 | ||
|
|
14770f27ce | ||
|
|
9f8b46aa49 | ||
|
|
db5a64a69a | ||
|
|
744907aedc | ||
|
|
84a3611b4f | ||
|
|
21a30b95c3 | ||
|
|
2eafed8034 | ||
|
|
70789f9030 | ||
|
|
b89ae90645 |
@@ -16,7 +16,7 @@ This package is source-only: consumers import directly from `src/` via the
|
|||||||
| `dashgui/tiles` | `src/tiles/` | `Tile`, `TILE_SIZE`, `tileBaseSx` |
|
| `dashgui/tiles` | `src/tiles/` | `Tile`, `TILE_SIZE`, `tileBaseSx` |
|
||||||
| `dashgui/ElevatedPill` | `src/ElevatedPill.jsx` | `ElevatedPill`, `ToggleElevatedPill`, `ActionElevatedPill` |
|
| `dashgui/ElevatedPill` | `src/ElevatedPill.jsx` | `ElevatedPill`, `ToggleElevatedPill`, `ActionElevatedPill` |
|
||||||
| `dashgui/SingleLineTextEdit` | `src/SingleLineTextEdit.jsx` | Centralized single-line text field |
|
| `dashgui/SingleLineTextEdit` | `src/SingleLineTextEdit.jsx` | Centralized single-line text field |
|
||||||
| `dashgui/FillSpace` | `src/FillSpace.jsx` | `FillSpace` — viewport-height spacer between header/footer refs; optional repeating `measureElement` |
|
| `dashgui/FillSpace` | `src/FillSpace.jsx` | `FillSpace` — paginates `items` via `renderItem`, filling the viewport space between `aboveRef`/`belowRef`; wheel flips pages |
|
||||||
|
|
||||||
## Design rules
|
## Design rules
|
||||||
|
|
||||||
|
|||||||
199
package-lock.json
generated
199
package-lock.json
generated
@@ -16,7 +16,8 @@
|
|||||||
"@vitejs/plugin-react": "6.1.1",
|
"@vitejs/plugin-react": "6.1.1",
|
||||||
"react": "^19.3.0",
|
"react": "^19.3.0",
|
||||||
"react-dom": "^19.3.0",
|
"react-dom": "^19.3.0",
|
||||||
"vite": "^8.3.0"
|
"vite": "^8.3.2",
|
||||||
|
"vite-plugin-qrcode": "^0.4.1"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.14.0",
|
||||||
@@ -617,9 +618,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@oxc-project/types": {
|
"node_modules/@oxc-project/types": {
|
||||||
"version": "0.149.0",
|
"version": "0.154.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.149.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.154.0.tgz",
|
||||||
"integrity": "sha512-Efcc+iF0j3Bf67YjEqIqWXbX5XddXoK/Mw4K1/JuXwRCZ8N16VR7iT23nlCc9XrveFVh/E5Rqs2StT0V8v9LdA==",
|
"integrity": "sha512-KYOUGQFlLWR+s4QMV2Q9ko38TZm7EdO87Qm7rvZLqk/webse/cDjtyxKhg/47l0byCbs/k46LT8qc4PKjAikzA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
@@ -654,9 +655,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-android-arm-eabi": {
|
"node_modules/@rolldown/binding-android-arm-eabi": {
|
||||||
"version": "1.2.8",
|
"version": "1.2.14",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.14.tgz",
|
||||||
"integrity": "sha512-tN5aztYkKCte4i5SIrrz5yK/HMjEuCqCSCJa418jOV8tZ1cBY3YF2otxB1ktPxzsLA1BeTqwapK0bfjxNvHJVw==",
|
"integrity": "sha512-aWR+XKH9+rW7m5fWq/5360J3mHycrx/o6xZM2DQFdJnTMdyP7/WzxPjhtLbVWdKo2y612/4U1bWqb3HQhiF7vg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -671,9 +672,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-android-arm64": {
|
"node_modules/@rolldown/binding-android-arm64": {
|
||||||
"version": "1.2.8",
|
"version": "1.2.14",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.14.tgz",
|
||||||
"integrity": "sha512-dIYTWl9XprMUiQFoc55KUyk/oS8SKYH3zFl0LTR7RT0Xj4hgSVyuJcroH8JUu8RcpF8fTB6E0aOwCkZoYPcDSQ==",
|
"integrity": "sha512-QiVxHofl6A8lQU7WAtOUY4QMVZXohxwz7WJJoeh8NBEUyKfIkMRKhYyq8az/1RypNiIXzNTtiS6KwHz0erxYRQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -688,9 +689,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-darwin-arm64": {
|
"node_modules/@rolldown/binding-darwin-arm64": {
|
||||||
"version": "1.2.8",
|
"version": "1.2.14",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.14.tgz",
|
||||||
"integrity": "sha512-PCSDQGXD2IyTEFrcgPyBM8jJuGmrbCMuoIOXdbEGVemruKACXoLQJrb+A45Z0L5t1RQkdfJprAYPkikbh7dzdA==",
|
"integrity": "sha512-lTQOzHuBIooh3jIr5XINQkjPGEuGy+Dsy0meXDr6Sjty0b99kYgGh4uBC/8mJX1T0kC8d6S0aPP1Y+bHkh6aBw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -705,9 +706,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-darwin-x64": {
|
"node_modules/@rolldown/binding-darwin-x64": {
|
||||||
"version": "1.2.8",
|
"version": "1.2.14",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.14.tgz",
|
||||||
"integrity": "sha512-Uk7lRsGhPFHVX/sAUC6D5H9Ol30dFHd6iquokll2th3LpdJ3F5CzQB+7DHn0Ri2mG+U7k2zXiPHDrwZenXhwSA==",
|
"integrity": "sha512-hyMKEGatjmBYCs/Q4sbIcL/GqpFEqsX3wc+HYVQ/85A4rxc1o7rPm4DUI4GV4k/h0ITddwFSGpqQunI14GXjTA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -722,9 +723,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-freebsd-x64": {
|
"node_modules/@rolldown/binding-freebsd-x64": {
|
||||||
"version": "1.2.8",
|
"version": "1.2.14",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.14.tgz",
|
||||||
"integrity": "sha512-DjszaTEVogPqA5bYzsEeqDCQxbcp2fexQwKcRspYji2yzR68fCf+e4fx6kBSRDwX5/brZaHw/hWS9+A/+/w9sQ==",
|
"integrity": "sha512-u371GbXoVXrEgP20PwzoGNOdAOqAMhzo19JzzY+V4YmmnC+DTOoqdbMu+O+54emM59Mik/UdrtWgcuEDKWRPKA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -739,9 +740,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
||||||
"version": "1.2.8",
|
"version": "1.2.14",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.14.tgz",
|
||||||
"integrity": "sha512-zmwa7FTmdzB6aaEEuuls18H6Ap5JmJPSoPTuXixeJZV6tG40SyLkApQtz1g8ptZtiEKqj9OM0oNLPh1AgvE31Q==",
|
"integrity": "sha512-BqwKsdZQf6EqVWr8fv2MrDYzAvOZ7+NYFSJESRGs87fkseEhs0TU9TXgRJTkZoRRkxTizyEg1LKUXfU3/78uqg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -756,9 +757,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
||||||
"version": "1.2.8",
|
"version": "1.2.14",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.14.tgz",
|
||||||
"integrity": "sha512-KdYQDPHwJVnbFwdTGMgxsI9SqblBlz6STGM+w1We/d5B8OWWidYH0MwkU/uA1wM5fIpO2MkOVxXrNzzuZhw9ew==",
|
"integrity": "sha512-SD9kY8M009x/7s57Z1dgUVlP5+uYBgKQK0Tkr1WIWngrtUJmRCW1RJFMY7QDla2khXhmg2i5Io7MUjcmZNZ/KQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -776,9 +777,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
||||||
"version": "1.2.8",
|
"version": "1.2.14",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.14.tgz",
|
||||||
"integrity": "sha512-jFJTifHnNPY+yzOoNZQfSIysrVyXzEQPhPnOUjmD1bcQGHH6s7c8cViKWar8YplQImE5N9JRqMCLrM2CdxOrZA==",
|
"integrity": "sha512-BJ2CL19M6uuWesMgnqJzgxoI+N+eV7b0A9O9vLV42y+gitC41fNa8KElau1tHVzuLWX5GzEuxMPhAO0tQXQMkg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -796,9 +797,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
||||||
"version": "1.2.8",
|
"version": "1.2.14",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.14.tgz",
|
||||||
"integrity": "sha512-FhiOziBDWPBjbcmRzfLyIJnaP7AVMFXT7YCXPjXxj7wKU3vx24RjrCNN/zjvVa+N2vVoHJwCoUBvsrN/DG3zIA==",
|
"integrity": "sha512-4+Kl8daPV8NDHaD7mfCcc1pRM+Qg7ab8DyMm5FG5WoZgv4h2i3KFN1CybYBNbQqmuYm7ftJLvmMI1eHYJ22S/Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
@@ -816,9 +817,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
||||||
"version": "1.2.8",
|
"version": "1.2.14",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.14.tgz",
|
||||||
"integrity": "sha512-WnHfADMzOV2Y55wlx1hzzQnar/wDt/VdvWSD99r18Mz9ylNieIGOkRx3UV21h7m/eJvjySYJkO26VvGNFkwsIQ==",
|
"integrity": "sha512-V/h8ePq1fKDrmtFMz6xcCN3DKvuvMRrwwD1q0PkBeeo4DpK64biaKvqWGVxqCeYKqeRk7gE7/iKPxLCn08O97A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
@@ -836,9 +837,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
||||||
"version": "1.2.8",
|
"version": "1.2.14",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.14.tgz",
|
||||||
"integrity": "sha512-H9tRr5ibfXFVLxbPOseVewewFpl28zcEdjRDt2FTUZU7odxP0gEv1ki4/kGmcGOh78oRwZuuQllGLZ9zTJp84g==",
|
"integrity": "sha512-WxalXzgrLVpKC93xfpaJ3gCXUWzeCr+FRCbDVD2mUAy4L6Vo3lNVz0epUEUpxPWJkLNYUwM4OSIikv36OJ9mLg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -856,9 +857,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-x64-musl": {
|
"node_modules/@rolldown/binding-linux-x64-musl": {
|
||||||
"version": "1.2.8",
|
"version": "1.2.14",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.14.tgz",
|
||||||
"integrity": "sha512-UefiqfM3D6IVNlZ8tSGs9+Ejjud2T+oxO0IHADU45Y+lyEjD2dVFyZHbkfX0LUb5Zugo/oIv1eCO/KVYhgYJYA==",
|
"integrity": "sha512-3G3tnAmTpdk9jO3GQtyHXqeFD2SlecfTws/Y3M2mmj9nMBwwuLT4kur8sZKeu7ENUHSpVO3cIsm5A52LEWWwlg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -876,9 +877,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-openharmony-arm64": {
|
"node_modules/@rolldown/binding-openharmony-arm64": {
|
||||||
"version": "1.2.8",
|
"version": "1.2.14",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.14.tgz",
|
||||||
"integrity": "sha512-637Ke4kWSy6rp9cxQ9gMOXlxPgIw/c1beASV4M//3+9I4uwBVOOl74G+e3zyU3u19U7RkRl/HuewixZ/Z6+Rjg==",
|
"integrity": "sha512-qyr5XiNGxm2ytx17vvtcJ9nXn6v1gENETi503bJx3OGwZ1tSonj7BosxbMa/xeAfSl5iS9X6KjEWYXLu6v1L7Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -893,9 +894,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||||
"version": "1.2.8",
|
"version": "1.2.14",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.14.tgz",
|
||||||
"integrity": "sha512-xWBkPOF1Q9k/Gv1nQXnVdLxKu74jXppuOM4Z3mnypVUJJJwLsMl7hNJGRAUJoG8A5MgOI1ACKM+wBFxSJzKy4A==",
|
"integrity": "sha512-oA4o5o0S2yB199YyYXDzKUv1Z56Rc+mgOjwm7Z/vJ9Z8igiDPYvoOuDyo6g7AK/ZaF09nPhDKVnGBcYHO11GXA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -910,9 +911,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
||||||
"version": "1.2.8",
|
"version": "1.2.14",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.14.tgz",
|
||||||
"integrity": "sha512-uz2ZvfgXbxqNwijjjbxrnvALwpyODDcgc1T1N8N3rf/DXKQmaFwmB4LX4yyjggpwN2obdQLb2rgirX5ffCWYng==",
|
"integrity": "sha512-9mE94bf2GZUaR/fUSXCMpBoWkctu421Mi0ORpm4zqfbkjKep7RxzPFa5etcGs5h+2KApoWBOA/hAJa/7saGgEQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1595,9 +1596,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.19",
|
"version": "3.3.20",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz",
|
||||||
"integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==",
|
"integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -1722,9 +1723,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.28",
|
"version": "8.5.30",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.30.tgz",
|
||||||
"integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==",
|
"integrity": "sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -1742,9 +1743,9 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.18",
|
"nanoid": "^3.3.20",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
"source-map-js": "^1.2.1"
|
"source-map-js": "^1.2.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^10 || ^12 || >=14"
|
"node": "^10 || ^12 || >=14"
|
||||||
@@ -1849,13 +1850,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/rolldown": {
|
"node_modules/rolldown": {
|
||||||
"version": "1.2.8",
|
"version": "1.2.14",
|
||||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.14.tgz",
|
||||||
"integrity": "sha512-Z67nTmhZe7anqnM/EjI392w5i/ANUinjip7QYsOyN37oayduxt3ksdX0hf5OOamkAd53BiIHfbfSzfUmzKFQqQ==",
|
"integrity": "sha512-eTBTTqMR6uHKLgcUqpJP8ntzDI6V4LYA8negAMBEI7gkgVvOdrx6VsvS2Ym+83KhOPdLCHWJu0w7UQFJZhHKNw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@oxc-project/types": "=0.149.0",
|
"@oxc-project/types": "=0.154.0",
|
||||||
"@rolldown/pluginutils": "^1.0.0"
|
"@rolldown/pluginutils": "^1.0.0"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -1865,21 +1866,21 @@
|
|||||||
"node": "^20.19.0 || >=22.12.0"
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@rolldown/binding-android-arm-eabi": "1.2.8",
|
"@rolldown/binding-android-arm-eabi": "1.2.14",
|
||||||
"@rolldown/binding-android-arm64": "1.2.8",
|
"@rolldown/binding-android-arm64": "1.2.14",
|
||||||
"@rolldown/binding-darwin-arm64": "1.2.8",
|
"@rolldown/binding-darwin-arm64": "1.2.14",
|
||||||
"@rolldown/binding-darwin-x64": "1.2.8",
|
"@rolldown/binding-darwin-x64": "1.2.14",
|
||||||
"@rolldown/binding-freebsd-x64": "1.2.8",
|
"@rolldown/binding-freebsd-x64": "1.2.14",
|
||||||
"@rolldown/binding-linux-arm-gnueabihf": "1.2.8",
|
"@rolldown/binding-linux-arm-gnueabihf": "1.2.14",
|
||||||
"@rolldown/binding-linux-arm64-gnu": "1.2.8",
|
"@rolldown/binding-linux-arm64-gnu": "1.2.14",
|
||||||
"@rolldown/binding-linux-arm64-musl": "1.2.8",
|
"@rolldown/binding-linux-arm64-musl": "1.2.14",
|
||||||
"@rolldown/binding-linux-ppc64-gnu": "1.2.8",
|
"@rolldown/binding-linux-ppc64-gnu": "1.2.14",
|
||||||
"@rolldown/binding-linux-s390x-gnu": "1.2.8",
|
"@rolldown/binding-linux-s390x-gnu": "1.2.14",
|
||||||
"@rolldown/binding-linux-x64-gnu": "1.2.8",
|
"@rolldown/binding-linux-x64-gnu": "1.2.14",
|
||||||
"@rolldown/binding-linux-x64-musl": "1.2.8",
|
"@rolldown/binding-linux-x64-musl": "1.2.14",
|
||||||
"@rolldown/binding-openharmony-arm64": "1.2.8",
|
"@rolldown/binding-openharmony-arm64": "1.2.14",
|
||||||
"@rolldown/binding-win32-arm64-msvc": "1.2.8",
|
"@rolldown/binding-win32-arm64-msvc": "1.2.14",
|
||||||
"@rolldown/binding-win32-x64-msvc": "1.2.8"
|
"@rolldown/binding-win32-x64-msvc": "1.2.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/scheduler": {
|
"node_modules/scheduler": {
|
||||||
@@ -1900,9 +1901,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/source-map-js": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
|
||||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
"integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -1946,17 +1947,24 @@
|
|||||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/uqr": {
|
||||||
|
"version": "0.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/uqr/-/uqr-0.1.3.tgz",
|
||||||
|
"integrity": "sha512-0rjE8iEJe4YmT9TOhwsZtqCMRLc5DXZUI2UEYUUg63ikBkqqE5EYWaI0etFe/5KUcmcYwLih2RND1kq+hrUJXA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "8.3.0",
|
"version": "8.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz",
|
||||||
"integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==",
|
"integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lightningcss": "^1.33.0",
|
"lightningcss": "^1.33.0",
|
||||||
"picomatch": "^4.0.7",
|
"picomatch": "^4.0.7",
|
||||||
"postcss": "^8.5.28",
|
"postcss": "^8.5.29",
|
||||||
"rolldown": "~1.2.6",
|
"rolldown": "~1.2.12",
|
||||||
"tinyglobby": "^0.2.17"
|
"tinyglobby": "^0.2.17"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -2024,20 +2032,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/yaml": {
|
"node_modules/vite-plugin-qrcode": {
|
||||||
"version": "2.9.1",
|
"version": "0.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/vite-plugin-qrcode/-/vite-plugin-qrcode-0.4.1.tgz",
|
||||||
"integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==",
|
"integrity": "sha512-t3gdCbn/a2HHLK3M8bUrQ+4c+I0YQGJ/STxAL9eI6TBnfdeM/CJr3HctvOb7UYhEtWxHs5xZWpz7kYnRd5w+rA==",
|
||||||
"extraneous": true,
|
"dev": true,
|
||||||
"license": "ISC",
|
"license": "MIT",
|
||||||
"bin": {
|
"dependencies": {
|
||||||
"yaml": "bin.mjs"
|
"uqr": "^0.1.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"peerDependencies": {
|
||||||
"node": ">= 14.6"
|
"vite": ">=3"
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/eemeli"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
15
package.json
15
package.json
@@ -38,12 +38,13 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.14.0",
|
||||||
"@emotion/styled": "^11.14.1",
|
"@emotion/styled": "^11.14.1",
|
||||||
"@mui/icons-material": "^9.4.0",
|
"@mui/icons-material": "^9.5.0",
|
||||||
"@mui/material": "^9.4.0",
|
"@mui/material": "^9.5.0",
|
||||||
"@playwright/test": "^1.63.0",
|
"@playwright/test": "^1.64.1",
|
||||||
"@vitejs/plugin-react": "6.1.1",
|
"@vitejs/plugin-react": "6.1.2",
|
||||||
"react": "^19.3.0",
|
"react": "^19.3.1",
|
||||||
"react-dom": "^19.3.0",
|
"react-dom": "^19.3.1",
|
||||||
"vite": "^8.3.0"
|
"vite": "^8.3.4",
|
||||||
|
"vite-plugin-qrcode": "^0.4.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,75 +1,194 @@
|
|||||||
import { Component } from 'react';
|
import { Component } from 'react';
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
|
import List from '@mui/material/List';
|
||||||
|
import Pagination from '@mui/material/Pagination';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
import { alpha } from '@mui/material/styles';
|
import { alpha } from '@mui/material/styles';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Vertical spacer that fills the remaining viewport height between the content
|
* FillSpace — paginates `items`.
|
||||||
* above it and (optionally) a footer below it — useful for creating scrollable
|
|
||||||
* middle sections in apps with fixed headers/footers.
|
|
||||||
*
|
*
|
||||||
* @component FillSpace
|
* @component FillSpace
|
||||||
* @import dashgui/FillSpace
|
* @import dashgui/FillSpace
|
||||||
* @defaultExport
|
* @defaultExport
|
||||||
*
|
*
|
||||||
* @prop {React.Ref} [aboveRef] - Ref to the element immediately above the spacer. It is observed (not strictly required) so the spacer re-measures when that element's height changes.
|
* @prop {Array} items - Wheel flips pages.
|
||||||
* @prop {React.Ref} [belowRef] - Ref to the footer element below. Its height (plus top margin) is reserved so the footer stays visible without page scrolling.
|
* @prop {React.ComponentType|Function} renderItem - Row renderer (component or callback).
|
||||||
* @prop {ReactNode} [measureElement] - When given, this element is used as the measurement unit of the spacer: its height is read from a hidden copy and it is repeated top-to-bottom to fill only the leftover space below `children` (like a repeating ruler/row pattern). When omitted, only `children` are rendered.
|
* @prop {React.ReactNode} [header] - Rendered below pagination controls and above the list items.
|
||||||
* @prop {number} [measureGap=0] - Vertical gap (px) between repeated `measureElement` units.
|
* @prop {any} [headerItem] - Optional dummy/sample item passed to probe element for layout measurement.
|
||||||
* @prop {(count: number) => void} [onMeasurement] - Called whenever the number of `measureElement` units that would fit in the whole spacer height changes, as if there were no `children` (i.e. the total row count the space could hold). Fires once after mount with the initial count.
|
* @prop {number} [minPageSize=10] - Rows per page.
|
||||||
* @prop {number} [minHeight=0] - Minimum spacer height in px.
|
* @prop {number} [maxPageSize] - Maximum rows per page.
|
||||||
* @prop {number} [maxHeight] - Optional maximum spacer height in px.
|
* @prop {number} [totalCount] - Total items in current dataset (or filtered dataset when paginating).
|
||||||
* @prop {boolean} [debug=false] - When true, draws a dashed outline around the spacer and a translucent overlay over the whole viewport so the measured bounds are visible.
|
* @prop {number} [unfilteredTotal] - Total items in unfiltered dataset. When unfilteredTotal > pageSize, pagination space is reserved even if current filtered count <= pageSize, preventing layout shift.
|
||||||
* @prop {ReactNode} [children] - Real content rendered at the top of the spacer. The repeated `measureElement` units (if any) fill the remaining space below it.
|
* @prop {number} [unfilteredCount] - Alias for unfilteredTotal.
|
||||||
* @prop {object} [sx] - Extra styles merged into the Box.
|
* @prop {(page: number) => void} [onPageChange] - Called on page change.
|
||||||
*
|
* @prop {boolean} [disableWheel=false] - When true, disables mouse wheel page flipping and lets the outer page scroll.
|
||||||
* @behaviour Fills to the bottom of the initial viewport: height = viewport height − spacer's distance from the document top − reserved below height.
|
* @prop {boolean} [virtualScrollbar=false] - Shows a synchronized scrollbar alongside pagination controls.
|
||||||
* @behaviour The distance-from-top is read from the spacer's own position (document-relative), so every element above (including their margins and the parent's padding) is accounted for, and scrolling the page does not change the computed height.
|
* @prop {boolean} [reserveScrollbar] - Reserves raceway space even when items do not overflow (defaults to true when virtualScrollbar is true).
|
||||||
* @behaviour When `measureElement` is set, `children` are rendered on top and the units fill only `height − contentHeight`, so no units are shown once the content already fills the space.
|
* @prop {boolean} [debug=false] - Dashed outline for layout debugging.
|
||||||
* @behaviour `onMeasurement` fires with the count of units that would fit in the whole spacer height (as if `children` were absent) — not the number actually rendered below the content — whenever that count changes.
|
* @prop {object} [sx] - Extra styles.
|
||||||
* @behaviour Re-measures on window resize and whenever the observed refs or the content change size (ResizeObserver), throttled with requestAnimationFrame.
|
|
||||||
* @behaviour `debug` draws a dashed primary outline around the spacer plus a translucent full-viewport overlay.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/** True for class components (e.g. `renderItem={ArtikelMarke}`). Rendered as element, not called. */
|
||||||
|
function isRowComponent(renderItem) {
|
||||||
|
return (
|
||||||
|
typeof renderItem === 'function' &&
|
||||||
|
(renderItem.prototype?.isReactComponent || renderItem.isFillRowComponent === true)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Renders one row: component types become `<Row brand item index />`, callbacks are called. */
|
||||||
|
function renderRowElement(renderItem, item, index) {
|
||||||
|
if (isRowComponent(renderItem)) {
|
||||||
|
const Row = renderItem;
|
||||||
|
return <Row brand={item} item={item} index={index} />;
|
||||||
|
}
|
||||||
|
return renderItem(item, index);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Probe of the row height, rendered hidden. Class components
|
||||||
|
* (e.g. `renderItem={ArtikelMarke}`) tolerate empty props
|
||||||
|
* (`brand`/`item` undefined) and keep a fixed height (40px thumb /
|
||||||
|
* text field). When `headerItem` is supplied, it is passed to the row
|
||||||
|
* for precise measurement of data-driven layouts.
|
||||||
|
*/
|
||||||
|
function renderProbeElement(renderItem, headerItem) {
|
||||||
|
if (!isRowComponent(renderItem)) return null;
|
||||||
|
const Row = renderItem;
|
||||||
|
return <Row brand={headerItem} item={headerItem} index={0} disabled />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MIN_THUMB_HEIGHT = 48;
|
||||||
|
const RACEWAY_WIDTH = 16;
|
||||||
|
const RACEWAY_BOTTOM_MARGIN = 16;
|
||||||
|
|
||||||
class FillSpace extends Component {
|
class FillSpace extends Component {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
|
const minSize = Math.max(1, Number(props.minPageSize) || 10);
|
||||||
|
const maxSize = Number(props.maxPageSize) > 0 ? Number(props.maxPageSize) : Infinity;
|
||||||
this.state = {
|
this.state = {
|
||||||
height: 0,
|
|
||||||
unitHeight: 0,
|
unitHeight: 0,
|
||||||
contentHeight: 0,
|
viewportHeight: null,
|
||||||
totalCount: 0,
|
pageSize: Math.min(maxSize, minSize),
|
||||||
fillCount: 0,
|
currentPage: 1,
|
||||||
|
isDragging: false,
|
||||||
|
dragThumbTop: 0,
|
||||||
};
|
};
|
||||||
this.lastTotalCount = null;
|
|
||||||
this.ref = null;
|
this.ref = null;
|
||||||
this.contentRef = null;
|
this.trackRef = null;
|
||||||
this.unitRef = null;
|
this.thumbRef = null;
|
||||||
|
this.probeRef = null;
|
||||||
|
this.wheelAcc = 0;
|
||||||
this.observer = null;
|
this.observer = null;
|
||||||
this.raf = null;
|
this.raf = null;
|
||||||
|
this.dragStartY = 0;
|
||||||
|
this.dragStartThumbTop = 0;
|
||||||
|
const rawUnfiltered = props.unfilteredTotal ?? props.unfilteredCount;
|
||||||
|
const unfilteredTotal = rawUnfiltered != null ? Number(rawUnfiltered) : null;
|
||||||
|
const total =
|
||||||
|
props.totalCount != null
|
||||||
|
? Number(props.totalCount)
|
||||||
|
: Array.isArray(props.items)
|
||||||
|
? props.items.length
|
||||||
|
: 0;
|
||||||
|
this.maxSeenCount = unfilteredTotal != null ? unfilteredTotal : total;
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
requestAnimationFrame(() => {
|
this._probeMeasure();
|
||||||
this._measure();
|
this._viewportMeasure();
|
||||||
this._observe();
|
this._observe();
|
||||||
});
|
|
||||||
window.addEventListener('resize', this._onViewportChange);
|
window.addEventListener('resize', this._onViewportChange);
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidUpdate(prevProps) {
|
componentDidUpdate(prevProps) {
|
||||||
|
if (
|
||||||
|
prevProps.renderItem !== this.props.renderItem ||
|
||||||
|
prevProps.headerItem !== this.props.headerItem
|
||||||
|
) {
|
||||||
|
this._probeMeasure();
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
prevProps.minPageSize !== this.props.minPageSize ||
|
||||||
|
prevProps.maxPageSize !== this.props.maxPageSize
|
||||||
|
) {
|
||||||
|
const minSize = Math.max(1, Number(this.props.minPageSize) || 10);
|
||||||
|
const maxSize = Number(this.props.maxPageSize) > 0 ? Number(this.props.maxPageSize) : Infinity;
|
||||||
|
this.setState({ pageSize: Math.min(maxSize, minSize) });
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
prevProps.aboveRef !== this.props.aboveRef ||
|
prevProps.aboveRef !== this.props.aboveRef ||
|
||||||
prevProps.belowRef !== this.props.belowRef ||
|
prevProps.belowRef !== this.props.belowRef ||
|
||||||
prevProps.minHeight !== this.props.minHeight ||
|
prevProps.containerRef !== this.props.containerRef ||
|
||||||
prevProps.maxHeight !== this.props.maxHeight ||
|
prevProps.reserveBottom !== this.props.reserveBottom ||
|
||||||
prevProps.measureElement !== this.props.measureElement ||
|
prevProps.items !== this.props.items ||
|
||||||
prevProps.measureGap !== this.props.measureGap
|
prevProps.header !== this.props.header
|
||||||
) {
|
) {
|
||||||
this._observe();
|
this._observe();
|
||||||
this._measure();
|
this._viewportMeasure();
|
||||||
|
}
|
||||||
|
const rawUnfiltered = this.props.unfilteredTotal ?? this.props.unfilteredCount;
|
||||||
|
const unfilteredTotal = rawUnfiltered != null ? Number(rawUnfiltered) : null;
|
||||||
|
const total =
|
||||||
|
this.props.totalCount != null
|
||||||
|
? Number(this.props.totalCount)
|
||||||
|
: Array.isArray(this.props.items)
|
||||||
|
? this.props.items.length
|
||||||
|
: 0;
|
||||||
|
if (unfilteredTotal != null) {
|
||||||
|
this.maxSeenCount = unfilteredTotal;
|
||||||
|
} else {
|
||||||
|
this.maxSeenCount = Math.max(this.maxSeenCount || 0, total);
|
||||||
|
}
|
||||||
|
if (this.props.resetKey !== undefined && this.props.resetKey !== prevProps.resetKey) {
|
||||||
|
if (this.state.currentPage !== 1) {
|
||||||
|
this._goToPage(1);
|
||||||
|
} else {
|
||||||
|
this.props.onPageChange?.(1, {
|
||||||
|
pageCount: Math.max(1, Math.ceil(total / Math.max(1, this.state.pageSize))),
|
||||||
|
pageSize: this.state.pageSize,
|
||||||
|
total,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Clamp current page when the item list shrinks (e.g. brand deleted) or trigger load for current page if items are missing.
|
||||||
|
if (
|
||||||
|
Array.isArray(this.props.items) &&
|
||||||
|
this.props.items !== prevProps.items
|
||||||
|
) {
|
||||||
|
const pageCount = Math.max(
|
||||||
|
1,
|
||||||
|
Math.ceil(total / Math.max(1, this.state.pageSize)),
|
||||||
|
);
|
||||||
|
if (this.state.currentPage > pageCount) {
|
||||||
|
this.setState({ currentPage: pageCount });
|
||||||
|
this.props.onPageChange?.(pageCount, {
|
||||||
|
pageCount,
|
||||||
|
pageSize: this.state.pageSize,
|
||||||
|
total,
|
||||||
|
});
|
||||||
|
} else if (this.state.currentPage > 1) {
|
||||||
|
const offset = (this.state.currentPage - 1) * this.state.pageSize;
|
||||||
|
if (this.props.items.length > offset && this.props.items[offset] == null) {
|
||||||
|
this.props.onPageChange?.(this.state.currentPage, {
|
||||||
|
pageCount,
|
||||||
|
pageSize: this.state.pageSize,
|
||||||
|
total,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
componentWillUnmount() {
|
componentWillUnmount() {
|
||||||
|
if (this.state.isDragging) {
|
||||||
|
document.body.style.userSelect = '';
|
||||||
|
document.body.style.cursor = '';
|
||||||
|
window.removeEventListener('mousemove', this._onThumbDragMove);
|
||||||
|
window.removeEventListener('mouseup', this._onThumbDragEnd);
|
||||||
|
}
|
||||||
window.removeEventListener('resize', this._onViewportChange);
|
window.removeEventListener('resize', this._onViewportChange);
|
||||||
if (this.raf) {
|
if (this.raf) {
|
||||||
cancelAnimationFrame(this.raf);
|
cancelAnimationFrame(this.raf);
|
||||||
@@ -81,11 +200,98 @@ class FillSpace extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_onThumbMouseDown = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const { totalCount, items } = this.props;
|
||||||
|
const { viewportHeight, pageSize, currentPage } = this.state;
|
||||||
|
const total = totalCount != null ? Number(totalCount) : (items?.length || 0);
|
||||||
|
const pageCount = Math.max(1, Math.ceil(total / Math.max(1, pageSize)));
|
||||||
|
const racewayBottom = Math.max(0, Number(this.props.racewayMarginBottom ?? RACEWAY_BOTTOM_MARGIN) || 0);
|
||||||
|
const trackHeight = Math.max(0, (viewportHeight || 0) - racewayBottom);
|
||||||
|
const thumbHeight = Math.max(MIN_THUMB_HEIGHT, Math.min(trackHeight, total > 0 ? (pageSize / total) * trackHeight : trackHeight));
|
||||||
|
const maxThumbTravel = Math.max(0, trackHeight - thumbHeight);
|
||||||
|
const currentThumbTop = pageCount > 1 && maxThumbTravel > 0 ? ((currentPage - 1) / (pageCount - 1)) * maxThumbTravel : 0;
|
||||||
|
|
||||||
|
this.dragStartY = e.clientY;
|
||||||
|
this.dragStartThumbTop = currentThumbTop;
|
||||||
|
this.setState({ isDragging: true, dragThumbTop: currentThumbTop });
|
||||||
|
document.body.style.userSelect = 'none';
|
||||||
|
document.body.style.cursor = 'grabbing';
|
||||||
|
window.addEventListener('mousemove', this._onThumbDragMove);
|
||||||
|
window.addEventListener('mouseup', this._onThumbDragEnd);
|
||||||
|
};
|
||||||
|
|
||||||
|
_onThumbDragMove = (e) => {
|
||||||
|
if (!this.state.isDragging) return;
|
||||||
|
const { totalCount, items } = this.props;
|
||||||
|
const { viewportHeight, pageSize } = this.state;
|
||||||
|
const total = totalCount != null ? Number(totalCount) : (items?.length || 0);
|
||||||
|
const pageCount = Math.max(1, Math.ceil(total / Math.max(1, pageSize)));
|
||||||
|
if (pageCount <= 1) return;
|
||||||
|
|
||||||
|
const racewayBottom = Math.max(0, Number(this.props.racewayMarginBottom ?? RACEWAY_BOTTOM_MARGIN) || 0);
|
||||||
|
const trackHeight = Math.max(0, (viewportHeight || 0) - racewayBottom);
|
||||||
|
const thumbHeight = Math.max(MIN_THUMB_HEIGHT, Math.min(trackHeight, total > 0 ? (pageSize / total) * trackHeight : trackHeight));
|
||||||
|
const maxThumbTravel = Math.max(0, trackHeight - thumbHeight);
|
||||||
|
if (maxThumbTravel <= 0) return;
|
||||||
|
|
||||||
|
const deltaY = e.clientY - this.dragStartY;
|
||||||
|
const nextThumbTop = Math.max(0, Math.min(maxThumbTravel, this.dragStartThumbTop + deltaY));
|
||||||
|
const targetPage = Math.min(pageCount, Math.max(1, Math.round(1 + (nextThumbTop / maxThumbTravel) * (pageCount - 1))));
|
||||||
|
|
||||||
|
this.setState({ dragThumbTop: nextThumbTop });
|
||||||
|
if (targetPage !== this.state.currentPage) {
|
||||||
|
this._goToPage(targetPage);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
_onThumbDragEnd = () => {
|
||||||
|
document.body.style.userSelect = '';
|
||||||
|
document.body.style.cursor = '';
|
||||||
|
window.removeEventListener('mousemove', this._onThumbDragMove);
|
||||||
|
window.removeEventListener('mouseup', this._onThumbDragEnd);
|
||||||
|
this.setState({ isDragging: false });
|
||||||
|
};
|
||||||
|
|
||||||
|
_handleTrackMouseDown = (e) => {
|
||||||
|
if (e.target === this.thumbRef || this.thumbRef?.contains(e.target)) return;
|
||||||
|
if (!this.trackRef) return;
|
||||||
|
const trackRect = this.trackRef.getBoundingClientRect();
|
||||||
|
const clickY = e.clientY - trackRect.top;
|
||||||
|
const { viewportHeight, pageSize } = this.state;
|
||||||
|
const { items, totalCount } = this.props;
|
||||||
|
const total = totalCount != null ? Number(totalCount) : (items?.length || 0);
|
||||||
|
const pageCount = Math.max(1, Math.ceil(total / Math.max(1, pageSize)));
|
||||||
|
if (pageCount <= 1) return;
|
||||||
|
|
||||||
|
const racewayBottom = Math.max(0, Number(this.props.racewayMarginBottom ?? RACEWAY_BOTTOM_MARGIN) || 0);
|
||||||
|
const trackHeight = Math.max(0, (viewportHeight || 0) - racewayBottom);
|
||||||
|
if (trackHeight <= 0) return;
|
||||||
|
const thumbHeight = Math.max(MIN_THUMB_HEIGHT, Math.min(trackHeight, total > 0 ? (pageSize / total) * trackHeight : trackHeight));
|
||||||
|
const maxThumbTravel = Math.max(0, trackHeight - thumbHeight);
|
||||||
|
const targetThumbTop = Math.max(0, Math.min(maxThumbTravel, clickY - thumbHeight / 2));
|
||||||
|
const targetPage = maxThumbTravel > 0 ? Math.min(pageCount, Math.max(1, Math.round(1 + (targetThumbTop / maxThumbTravel) * (pageCount - 1)))) : 1;
|
||||||
|
this._goToPage(targetPage);
|
||||||
|
};
|
||||||
|
|
||||||
|
scrollToTop = () => {
|
||||||
|
this._goToPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
scrollToIndex = (index) => {
|
||||||
|
const { pageSize } = this.state;
|
||||||
|
if (pageSize > 0) {
|
||||||
|
const targetPage = Math.floor(index / pageSize) + 1;
|
||||||
|
this._goToPage(targetPage);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
_onViewportChange = () => {
|
_onViewportChange = () => {
|
||||||
if (this.raf) return;
|
if (this.raf) return;
|
||||||
this.raf = requestAnimationFrame(() => {
|
this.raf = requestAnimationFrame(() => {
|
||||||
this.raf = null;
|
this.raf = null;
|
||||||
this._measure();
|
this._viewportMeasure();
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -93,163 +299,294 @@ class FillSpace extends Component {
|
|||||||
if (this.observer) {
|
if (this.observer) {
|
||||||
this.observer.disconnect();
|
this.observer.disconnect();
|
||||||
}
|
}
|
||||||
|
this.observer = new ResizeObserver(() => this._onViewportChange());
|
||||||
this.observer = new ResizeObserver(() => this._measure());
|
const { aboveRef, belowRef, containerRef } = this.props;
|
||||||
|
const aboveList = Array.isArray(aboveRef) ? aboveRef : [aboveRef];
|
||||||
// Only observe the referenced elements + the rendered content — never the
|
const belowList = Array.isArray(belowRef) ? belowRef : [belowRef];
|
||||||
// spacer's own parent, since resizing the spacer would resize the parent
|
const containerTarget =
|
||||||
// and create a loop.
|
containerRef && 'current' in containerRef ? containerRef.current : containerRef;
|
||||||
const { aboveRef, belowRef } = this.props;
|
const targets = [
|
||||||
const targets = [aboveRef?.current, belowRef?.current, this.contentRef];
|
...aboveList.map((r) => (r && 'current' in r ? r.current : r)),
|
||||||
|
...belowList.map((r) => (r && 'current' in r ? r.current : r)),
|
||||||
|
containerTarget,
|
||||||
|
];
|
||||||
for (const el of targets) {
|
for (const el of targets) {
|
||||||
if (el && el.offsetParent !== null) {
|
if (el && typeof Element !== 'undefined' && el instanceof Element && el.offsetParent !== null) {
|
||||||
this.observer.observe(el);
|
this.observer.observe(el);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_measure() {
|
probeMeasure = () => {
|
||||||
if (!this.ref) return;
|
this._probeMeasure();
|
||||||
|
};
|
||||||
|
|
||||||
const {
|
viewportMeasure = () => {
|
||||||
belowRef,
|
this._viewportMeasure();
|
||||||
measureElement,
|
};
|
||||||
measureGap = 0,
|
|
||||||
minHeight = 0,
|
|
||||||
maxHeight,
|
|
||||||
onMeasurement,
|
|
||||||
} = this.props;
|
|
||||||
|
|
||||||
// Distance from the document top to the spacer top — includes every
|
measure = () => {
|
||||||
// element above the spacer plus their margins and the parent's padding.
|
this._probeMeasure();
|
||||||
// Document-relative (rect.top + scrollY), so scrolling the page does not
|
this._viewportMeasure();
|
||||||
// change the computed height (fixes "grows with scrolling").
|
};
|
||||||
const rect = this.ref.getBoundingClientRect();
|
|
||||||
const abovePx = rect.top + window.scrollY;
|
|
||||||
|
|
||||||
// Height to reserve for the footer below (its box + top margin).
|
/** Only measures the height of the `renderItem` probe. Nothing else. */
|
||||||
const belowEl = belowRef?.current;
|
_probeMeasure() {
|
||||||
let belowPx = 0;
|
const firstRow = this.ref?.querySelector('[data-fill-row]');
|
||||||
if (belowEl && belowEl.offsetParent !== null) {
|
const h = this.probeRef?.offsetHeight || firstRow?.offsetHeight;
|
||||||
belowPx = belowEl.offsetHeight;
|
if (h > 0 && h !== this.state.unitHeight) {
|
||||||
const marginTop = parseFloat(window.getComputedStyle(belowEl).marginTop);
|
this.setState({ unitHeight: h }, () => this._viewportMeasure());
|
||||||
belowPx += Number.isFinite(marginTop) ? marginTop : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
const available = Math.max(0, window.innerHeight - abovePx - belowPx);
|
|
||||||
const clamped = Math.min(
|
|
||||||
Math.max(available, minHeight),
|
|
||||||
maxHeight ?? Infinity,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Height of the real rendered children (the top part of the spacer).
|
|
||||||
let contentHeight = 0;
|
|
||||||
if (this.contentRef) {
|
|
||||||
contentHeight = this.contentRef.offsetHeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Measure the height of one measurement unit from the hidden copy so the
|
|
||||||
// repeats match the real rendered size.
|
|
||||||
let unitHeight = this.state.unitHeight;
|
|
||||||
if (measureElement != null && this.unitRef) {
|
|
||||||
const h = this.unitRef.offsetHeight;
|
|
||||||
if (h > 0) unitHeight = h;
|
|
||||||
}
|
|
||||||
|
|
||||||
// How many units would fit in the WHOLE spacer height, as if there were
|
|
||||||
// no children — this is what onMeasurement reports.
|
|
||||||
let totalCount = 0;
|
|
||||||
// How many units actually fit in the leftover space below the children —
|
|
||||||
// this is what gets rendered. Starts at 0: if the content already fills
|
|
||||||
// the space, none fit.
|
|
||||||
let fillCount = 0;
|
|
||||||
if (measureElement != null && unitHeight > 0) {
|
|
||||||
totalCount = Math.floor((clamped + measureGap) / (unitHeight + measureGap));
|
|
||||||
const fillHeight = Math.max(0, clamped - contentHeight);
|
|
||||||
fillCount = Math.floor((fillHeight + measureGap) / (unitHeight + measureGap));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fire onMeasurement with the TOTAL count whenever it changes — including
|
|
||||||
// the first measurement. lastTotalCount starts as null so the initial
|
|
||||||
// value (even 0) is always reported once.
|
|
||||||
if (
|
|
||||||
measureElement != null &&
|
|
||||||
typeof onMeasurement === 'function' &&
|
|
||||||
totalCount !== this.lastTotalCount
|
|
||||||
) {
|
|
||||||
this.lastTotalCount = totalCount;
|
|
||||||
onMeasurement(totalCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
Math.abs(clamped - this.state.height) > 0.5 ||
|
|
||||||
Math.abs(unitHeight - this.state.unitHeight) > 0.5 ||
|
|
||||||
Math.abs(contentHeight - this.state.contentHeight) > 0.5 ||
|
|
||||||
totalCount !== this.state.totalCount ||
|
|
||||||
fillCount !== this.state.fillCount
|
|
||||||
) {
|
|
||||||
this.setState({ height: clamped, unitHeight, contentHeight, totalCount, fillCount });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Viewport height minus above/below refs: free space for the list. */
|
||||||
|
_viewportMeasure() {
|
||||||
|
if (!this.ref) return;
|
||||||
|
const { belowRef, containerRef, reserveBottom = 0, minPageSize } = this.props;
|
||||||
|
const rect = this.ref.getBoundingClientRect();
|
||||||
|
let belowPx = Number(reserveBottom) || 0;
|
||||||
|
const belowList = Array.isArray(belowRef) ? belowRef : [belowRef];
|
||||||
|
for (const ref of belowList) {
|
||||||
|
const el = ref && 'current' in ref ? ref.current : ref;
|
||||||
|
if (el && el.offsetParent !== null) {
|
||||||
|
belowPx += el.offsetHeight;
|
||||||
|
const mt = parseFloat(window.getComputedStyle(el).marginTop);
|
||||||
|
if (Number.isFinite(mt)) belowPx += mt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const containerEl =
|
||||||
|
containerRef && 'current' in containerRef ? containerRef.current : containerRef;
|
||||||
|
let available;
|
||||||
|
if (containerEl && typeof containerEl.getBoundingClientRect === 'function') {
|
||||||
|
const cRect = containerEl.getBoundingClientRect();
|
||||||
|
const paddingBottom = parseFloat(window.getComputedStyle(containerEl).paddingBottom) || 0;
|
||||||
|
available = Math.max(0, cRect.bottom - rect.top - belowPx - paddingBottom);
|
||||||
|
} else {
|
||||||
|
available = Math.max(0, window.innerHeight - rect.top - belowPx);
|
||||||
|
}
|
||||||
|
const minSize = Math.max(1, Number(minPageSize) || 10);
|
||||||
|
const maxSize = Number(this.props.maxPageSize) > 0 ? Number(this.props.maxPageSize) : Infinity;
|
||||||
|
const { unitHeight } = this.state;
|
||||||
|
const calculated =
|
||||||
|
unitHeight > 0 ? Math.max(minSize, Math.floor(available / unitHeight)) : minSize;
|
||||||
|
const nextPageSize = Math.min(maxSize, calculated);
|
||||||
|
const nextViewportHeight =
|
||||||
|
Number.isFinite(maxSize) && unitHeight > 0
|
||||||
|
? Math.min(available, nextPageSize * unitHeight)
|
||||||
|
: available;
|
||||||
|
if (
|
||||||
|
nextViewportHeight !== this.state.viewportHeight ||
|
||||||
|
nextPageSize !== this.state.pageSize
|
||||||
|
) {
|
||||||
|
this.setState({ viewportHeight: nextViewportHeight, pageSize: nextPageSize });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_handleWheel = (event) => {
|
||||||
|
if (this.props.disableWheel) return;
|
||||||
|
const { items, totalCount } = this.props;
|
||||||
|
if (!Array.isArray(items)) return;
|
||||||
|
if (this.ref && event.target && !this.ref.contains(event.target)) return;
|
||||||
|
const { pageSize, currentPage } = this.state;
|
||||||
|
const total = totalCount != null ? Number(totalCount) : items.length;
|
||||||
|
const pageCount = Math.max(1, Math.ceil(total / Math.max(1, pageSize)));
|
||||||
|
const delta = event.deltaY || 0;
|
||||||
|
if (pageCount <= 1) return;
|
||||||
|
if (delta === 0) return;
|
||||||
|
if (Math.sign(delta) !== Math.sign(this.wheelAcc || 0)) this.wheelAcc = 0;
|
||||||
|
this.wheelAcc = (this.wheelAcc || 0) + delta;
|
||||||
|
const threshold = Math.max(50, 800 / pageCount);
|
||||||
|
if (Math.abs(this.wheelAcc) < threshold) return;
|
||||||
|
const dir = this.wheelAcc > 0 ? 1 : -1;
|
||||||
|
this.wheelAcc = 0;
|
||||||
|
const next = dir > 0
|
||||||
|
? Math.min(pageCount, currentPage + 1)
|
||||||
|
: Math.max(1, currentPage - 1);
|
||||||
|
if (next === currentPage) return;
|
||||||
|
this._goToPage(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
_goToPage = (page) => {
|
||||||
|
const { items, onPageChange, totalCount } = this.props;
|
||||||
|
const total =
|
||||||
|
totalCount != null
|
||||||
|
? Number(totalCount)
|
||||||
|
: Array.isArray(items)
|
||||||
|
? items.length
|
||||||
|
: 0;
|
||||||
|
const pageCount = Math.max(1, Math.ceil(total / Math.max(1, this.state.pageSize)));
|
||||||
|
const next = Math.min(Math.max(1, Number(page) || 1), pageCount);
|
||||||
|
if (next === this.state.currentPage) return;
|
||||||
|
this.setState({ currentPage: next });
|
||||||
|
onPageChange?.(next, { pageCount, pageSize: this.state.pageSize, total });
|
||||||
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const {
|
const {
|
||||||
|
// Discarded legacy layout props (kept here so they don't leak to the DOM).
|
||||||
aboveRef,
|
aboveRef,
|
||||||
belowRef,
|
belowRef,
|
||||||
onMeasurement,
|
containerRef,
|
||||||
minHeight = 0,
|
reserveBottom,
|
||||||
|
minHeight,
|
||||||
maxHeight,
|
maxHeight,
|
||||||
|
minPageSize,
|
||||||
|
maxPageSize,
|
||||||
|
totalCount,
|
||||||
|
unfilteredCount,
|
||||||
|
unfilteredTotal: propUnfilteredTotal,
|
||||||
|
resetKey,
|
||||||
|
listRef,
|
||||||
|
onPageChange,
|
||||||
|
onWheel,
|
||||||
|
disableWheel = false,
|
||||||
|
virtualScrollbar = false,
|
||||||
|
reserveScrollbar,
|
||||||
|
racewayMarginBottom,
|
||||||
|
racewayRight,
|
||||||
debug = false,
|
debug = false,
|
||||||
children,
|
items,
|
||||||
measureElement,
|
renderItem,
|
||||||
measureGap = 0,
|
header,
|
||||||
|
headerItem,
|
||||||
sx,
|
sx,
|
||||||
...rest
|
...rest
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
|
||||||
const { height, fillCount } = this.state;
|
// FillSpace owns current page, wheel-pagination and the pagination header.
|
||||||
|
// Caller feeds `items` plus one row element via `renderItem` (component
|
||||||
|
// type or callback). `items` may be undefined while data is loading.
|
||||||
|
const { pageSize, currentPage, viewportHeight } = this.state;
|
||||||
|
const list = Array.isArray(items) ? items : [];
|
||||||
|
const total = totalCount != null ? Number(totalCount) : list.length;
|
||||||
|
const rawUnfiltered = propUnfilteredTotal ?? unfilteredCount;
|
||||||
|
const unfilteredTotal = rawUnfiltered != null ? Number(rawUnfiltered) : null;
|
||||||
|
const fullTotal = unfilteredTotal != null ? Math.max(unfilteredTotal, total) : Math.max(this.maxSeenCount || 0, total);
|
||||||
|
const needsPaginationContainer = fullTotal > pageSize;
|
||||||
|
const showPaginationControls = total > pageSize;
|
||||||
|
const pageCount = Math.max(1, Math.ceil(total / Math.max(1, pageSize)));
|
||||||
|
const safePage = Math.min(Math.max(1, currentPage), pageCount);
|
||||||
|
const offset = (safePage - 1) * pageSize;
|
||||||
|
const pageItems = list.slice(offset, offset + pageSize);
|
||||||
|
|
||||||
// Repeated measurement units fill only the leftover space below the real
|
const canScroll =
|
||||||
// children. fillCount (computed in _measure) starts at 0: if the content
|
virtualScrollbar &&
|
||||||
// already fills the space, no units are rendered.
|
total > pageSize &&
|
||||||
let fill = null;
|
(viewportHeight || 0) > 0;
|
||||||
if (measureElement != null) {
|
|
||||||
const items = [];
|
const racewayBottom = Math.max(0, Number(racewayMarginBottom ?? RACEWAY_BOTTOM_MARGIN) || 0);
|
||||||
for (let i = 0; i < fillCount; i++) {
|
const racewayRightOffset = racewayRight != null ? racewayRight : 0;
|
||||||
items.push(
|
const scrollbarReserved = reserveScrollbar ?? Boolean(virtualScrollbar);
|
||||||
<Box key={i} sx={{ width: '100%' }}>
|
const scrollbarPadding = scrollbarReserved
|
||||||
{measureElement}
|
? `${Math.max(0, RACEWAY_WIDTH + racewayRightOffset + 8)}px`
|
||||||
</Box>,
|
: 0;
|
||||||
);
|
|
||||||
}
|
let thumbHeight = 0;
|
||||||
fill = (
|
let thumbTop = 0;
|
||||||
<Box
|
|
||||||
sx={{
|
if (canScroll) {
|
||||||
display: 'flex',
|
const trackHeight = Math.max(0, (viewportHeight || 0) - racewayBottom);
|
||||||
flexDirection: 'column',
|
thumbHeight = Math.max(MIN_THUMB_HEIGHT, Math.min(trackHeight, total > 0 ? (pageSize / total) * trackHeight : trackHeight));
|
||||||
gap: measureGap,
|
const maxThumbTravel = Math.max(0, trackHeight - thumbHeight);
|
||||||
width: '100%',
|
thumbTop = this.state.isDragging
|
||||||
overflow: 'hidden',
|
? this.state.dragThumbTop
|
||||||
}}
|
: (pageCount > 1 && maxThumbTravel > 0 ? ((safePage - 1) / (pageCount - 1)) * maxThumbTravel : 0);
|
||||||
>
|
|
||||||
{items}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hidden probe: measures `renderItem` (passes headerItem when provided).
|
||||||
|
const probe = renderProbeElement(renderItem, this.props.headerItem);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{needsPaginationContainer ? (
|
||||||
|
<Box
|
||||||
|
// Fixed single-line bar: the header height must not depend on the
|
||||||
|
// filtered count so the content never shifts when filtering.
|
||||||
|
sx={{ mb: 1, height: 44, overflow: 'hidden' }}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
flexWrap: 'nowrap',
|
||||||
|
gap: 1,
|
||||||
|
width: '100%',
|
||||||
|
height: 44,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
color="text.secondary"
|
||||||
|
noWrap
|
||||||
|
sx={{ flexShrink: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis' }}
|
||||||
|
>
|
||||||
|
{total === 0
|
||||||
|
? (unfilteredTotal != null && unfilteredTotal > 0
|
||||||
|
? `0 von ${total} (${unfilteredTotal} gesamt)`
|
||||||
|
: '0')
|
||||||
|
: `${offset + 1}–${Math.min(offset + pageSize, total)} von ${total}${
|
||||||
|
unfilteredTotal != null && unfilteredTotal > total
|
||||||
|
? ` (${unfilteredTotal} gesamt)`
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
|
</Typography>
|
||||||
|
<Pagination
|
||||||
|
count={pageCount}
|
||||||
|
page={safePage}
|
||||||
|
onChange={(_e, page) => this._goToPage(page)}
|
||||||
|
color="primary"
|
||||||
|
size="large"
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
sx={{
|
||||||
|
flexShrink: 0,
|
||||||
|
maxWidth: '100%',
|
||||||
|
overflowX: 'auto',
|
||||||
|
scrollbarWidth: 'none',
|
||||||
|
'&::-webkit-scrollbar': { display: 'none' },
|
||||||
|
'& .MuiPagination-ul': { flexWrap: 'nowrap' },
|
||||||
|
'& .MuiPaginationItem-root': {
|
||||||
|
minWidth: 44,
|
||||||
|
height: 44,
|
||||||
|
fontSize: '1rem',
|
||||||
|
},
|
||||||
|
visibility: showPaginationControls ? 'visible' : 'hidden',
|
||||||
|
pointerEvents: showPaginationControls ? 'auto' : 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
) : null}
|
||||||
|
{header != null ? (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: '100%',
|
||||||
|
pr: scrollbarPadding,
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{header}
|
||||||
|
</Box>
|
||||||
|
) : null}
|
||||||
<Box
|
<Box
|
||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
this.ref = el;
|
this.ref = el;
|
||||||
}}
|
}}
|
||||||
|
onWheel={(e) => {
|
||||||
|
if (!disableWheel) {
|
||||||
|
this._handleWheel(e);
|
||||||
|
}
|
||||||
|
onWheel?.(e);
|
||||||
|
}}
|
||||||
{...rest}
|
{...rest}
|
||||||
sx={[
|
sx={[
|
||||||
{
|
{
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
height,
|
height: viewportHeight ?? 'auto',
|
||||||
minHeight,
|
overflow: 'hidden',
|
||||||
...(maxHeight != null ? { maxHeight } : {}),
|
width: '100%',
|
||||||
|
// Keep wheel ticks from scrolling the page behind the list unless disableWheel is true.
|
||||||
|
overscrollBehavior: disableWheel ? 'auto' : 'contain',
|
||||||
},
|
},
|
||||||
debug
|
debug
|
||||||
? (theme) => ({
|
? (theme) => ({
|
||||||
@@ -259,21 +596,25 @@ class FillSpace extends Component {
|
|||||||
sx,
|
sx,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{children != null ? (
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: '100%',
|
||||||
|
pr: scrollbarPadding,
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<List ref={listRef} dense disablePadding sx={{ width: '100%' }}>
|
||||||
|
{pageItems.map((item, i) => (
|
||||||
|
<Box key={item?.id ?? item?.sku ?? offset + i} data-fill-row sx={{ width: '100%' }}>
|
||||||
|
{renderRowElement(renderItem, item, offset + i)}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
</Box>
|
||||||
|
{probe != null ? (
|
||||||
<Box
|
<Box
|
||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
this.contentRef = el;
|
this.probeRef = el;
|
||||||
}}
|
|
||||||
sx={{ width: '100%' }}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</Box>
|
|
||||||
) : null}
|
|
||||||
{fill}
|
|
||||||
{measureElement != null ? (
|
|
||||||
<Box
|
|
||||||
ref={(el) => {
|
|
||||||
this.unitRef = el;
|
|
||||||
}}
|
}}
|
||||||
aria-hidden
|
aria-hidden
|
||||||
sx={{
|
sx={{
|
||||||
@@ -285,7 +626,62 @@ class FillSpace extends Component {
|
|||||||
pointerEvents: 'none',
|
pointerEvents: 'none',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{measureElement}
|
<List dense disablePadding sx={{ width: '100%' }}>
|
||||||
|
<Box data-fill-row sx={{ width: '100%' }}>
|
||||||
|
{probe}
|
||||||
|
</Box>
|
||||||
|
</List>
|
||||||
|
</Box>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{canScroll ? (
|
||||||
|
<Box
|
||||||
|
ref={(el) => {
|
||||||
|
this.trackRef = el;
|
||||||
|
}}
|
||||||
|
onMouseDown={this._handleTrackMouseDown}
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
right: racewayRightOffset,
|
||||||
|
bottom: `${racewayBottom}px`,
|
||||||
|
width: RACEWAY_WIDTH,
|
||||||
|
zIndex: 5,
|
||||||
|
cursor: 'pointer',
|
||||||
|
borderRadius: 8,
|
||||||
|
bgcolor: (theme) => alpha(theme.palette.text.primary, 0.04),
|
||||||
|
transition: 'background-color 0.15s ease',
|
||||||
|
'&:hover': {
|
||||||
|
bgcolor: (theme) => alpha(theme.palette.text.primary, 0.09),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
ref={(el) => {
|
||||||
|
this.thumbRef = el;
|
||||||
|
}}
|
||||||
|
onMouseDown={this._onThumbMouseDown}
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: thumbTop,
|
||||||
|
height: thumbHeight,
|
||||||
|
left: 2,
|
||||||
|
right: 2,
|
||||||
|
borderRadius: 6,
|
||||||
|
bgcolor: (theme) =>
|
||||||
|
this.state.isDragging
|
||||||
|
? alpha(theme.palette.primary.main, 0.7)
|
||||||
|
: alpha(theme.palette.text.primary, 0.3),
|
||||||
|
cursor: this.state.isDragging ? 'grabbing' : 'grab',
|
||||||
|
transition: this.state.isDragging ? 'none' : 'background-color 0.15s ease',
|
||||||
|
'&:hover': {
|
||||||
|
bgcolor: (theme) =>
|
||||||
|
this.state.isDragging
|
||||||
|
? alpha(theme.palette.primary.main, 0.7)
|
||||||
|
: alpha(theme.palette.text.primary, 0.55),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
) : null}
|
) : null}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { Component } from 'react';
|
import { Component } from 'react';
|
||||||
import TextField from '@mui/material/TextField';
|
import TextField from '@mui/material/TextField';
|
||||||
|
import InputAdornment from '@mui/material/InputAdornment';
|
||||||
|
import IconButton from '@mui/material/IconButton';
|
||||||
|
import SearchIcon from '@mui/icons-material/Search';
|
||||||
|
import ClearIcon from '@mui/icons-material/Clear';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Centralized single-line text field with the same outlined look everywhere.
|
* Centralized single-line text field with the same outlined look everywhere.
|
||||||
@@ -13,7 +17,7 @@ import TextField from '@mui/material/TextField';
|
|||||||
* @prop {string} value - Controlled field value.
|
* @prop {string} value - Controlled field value.
|
||||||
* @prop {(nextValue: string) => void} onChange - Called with the new string value (not an event). Not called while `readOnly`.
|
* @prop {(nextValue: string) => void} onChange - Called with the new string value (not an event). Not called while `readOnly`.
|
||||||
* @prop {React.Ref} [inputRef] - Ref forwarded to the inner input (used by EditablePanelTile to focus on edit).
|
* @prop {React.Ref} [inputRef] - Ref forwarded to the inner input (used by EditablePanelTile to focus on edit).
|
||||||
* @prop {ReactNode} [label=Name] - Outlined field label.
|
* @prop {ReactNode} [label] - Outlined field label.
|
||||||
* @prop {boolean} [readOnly=false] - When true, the input is read-only: non-editable, not tabbable, spellcheck off, and onChange is suppressed.
|
* @prop {boolean} [readOnly=false] - When true, the input is read-only: non-editable, not tabbable, spellcheck off, and onChange is suppressed.
|
||||||
* @prop {boolean} [disabled=false] - Passed to TextField — disables the field.
|
* @prop {boolean} [disabled=false] - Passed to TextField — disables the field.
|
||||||
* @prop {boolean} [required=false] - Passed to TextField — marks the field required.
|
* @prop {boolean} [required=false] - Passed to TextField — marks the field required.
|
||||||
@@ -21,6 +25,8 @@ import TextField from '@mui/material/TextField';
|
|||||||
* @prop {boolean} [autoFocus=false] - TextField autoFocus prop.
|
* @prop {boolean} [autoFocus=false] - TextField autoFocus prop.
|
||||||
* @prop {string} [autoComplete=off] - TextField autoComplete prop.
|
* @prop {string} [autoComplete=off] - TextField autoComplete prop.
|
||||||
* @prop {'small' | 'medium'} [size] - TextField size prop.
|
* @prop {'small' | 'medium'} [size] - TextField size prop.
|
||||||
|
* @prop {boolean} [search=false] - When true, displays a search icon start adornment.
|
||||||
|
* @prop {boolean} [clear=false] - When true and value is non-empty, displays a clear button end adornment.
|
||||||
* @prop {object} [slotProps] - MUI slotProps, merged (input/htmlInput are augmented for readOnly/spellCheck/tabIndex).
|
* @prop {object} [slotProps] - MUI slotProps, merged (input/htmlInput are augmented for readOnly/spellCheck/tabIndex).
|
||||||
* @prop {object} [sx] - Extra styles passed to TextField.
|
* @prop {object} [sx] - Extra styles passed to TextField.
|
||||||
* @prop {any} [otherProps] - Any remaining TextField props (helperText, etc.) are spread onto TextField.
|
* @prop {any} [otherProps] - Any remaining TextField props (helperText, etc.) are spread onto TextField.
|
||||||
@@ -35,7 +41,7 @@ class SingleLineTextEdit extends Component {
|
|||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
inputRef,
|
inputRef,
|
||||||
label = 'Name',
|
label,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
readOnly = false,
|
readOnly = false,
|
||||||
autoFocus = false,
|
autoFocus = false,
|
||||||
@@ -43,11 +49,38 @@ class SingleLineTextEdit extends Component {
|
|||||||
size,
|
size,
|
||||||
fullWidth = true,
|
fullWidth = true,
|
||||||
required = false,
|
required = false,
|
||||||
|
search = false,
|
||||||
|
clear = false,
|
||||||
sx,
|
sx,
|
||||||
slotProps,
|
slotProps,
|
||||||
...rest
|
...rest
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
|
||||||
|
let startAdornment = slotProps?.input?.startAdornment;
|
||||||
|
if (search && startAdornment === undefined) {
|
||||||
|
startAdornment = (
|
||||||
|
<InputAdornment position="start">
|
||||||
|
<SearchIcon color="action" />
|
||||||
|
</InputAdornment>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let endAdornment = slotProps?.input?.endAdornment;
|
||||||
|
if (clear && endAdornment === undefined) {
|
||||||
|
endAdornment = (value && !readOnly && !disabled) ? (
|
||||||
|
<InputAdornment position="end">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => onChange?.('')}
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
aria-label="Eingabe Löschen"
|
||||||
|
>
|
||||||
|
<ClearIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</InputAdornment>
|
||||||
|
) : null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TextField
|
<TextField
|
||||||
inputRef={inputRef}
|
inputRef={inputRef}
|
||||||
@@ -59,7 +92,7 @@ class SingleLineTextEdit extends Component {
|
|||||||
value={value}
|
value={value}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
if (!readOnly) onChange(event.target.value);
|
if (!readOnly) onChange?.(event.target.value);
|
||||||
}}
|
}}
|
||||||
autoFocus={autoFocus}
|
autoFocus={autoFocus}
|
||||||
autoComplete={autoComplete}
|
autoComplete={autoComplete}
|
||||||
@@ -67,6 +100,8 @@ class SingleLineTextEdit extends Component {
|
|||||||
...slotProps,
|
...slotProps,
|
||||||
input: {
|
input: {
|
||||||
readOnly,
|
readOnly,
|
||||||
|
...(startAdornment !== undefined ? { startAdornment } : {}),
|
||||||
|
...(endAdornment !== undefined ? { endAdornment } : {}),
|
||||||
...slotProps?.input,
|
...slotProps?.input,
|
||||||
},
|
},
|
||||||
htmlInput: {
|
htmlInput: {
|
||||||
|
|||||||
@@ -1,14 +1,32 @@
|
|||||||
import { Component, createRef } from 'react';
|
import { Component, createRef } from 'react';
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
|
import ListItemButton from '@mui/material/ListItemButton';
|
||||||
|
import ListItemText from '@mui/material/ListItemText';
|
||||||
import Paper from '@mui/material/Paper';
|
import Paper from '@mui/material/Paper';
|
||||||
import Typography from '@mui/material/Typography';
|
import Typography from '@mui/material/Typography';
|
||||||
import FillSpace from '../../FillSpace';
|
import FillSpace from '../../FillSpace';
|
||||||
import { ComponentDocs } from '../ComponentDocs';
|
import { ComponentDocs } from '../ComponentDocs';
|
||||||
|
|
||||||
|
/** Row renderer: component types are rendered as elements by FillSpace. */
|
||||||
|
class Row extends Component {
|
||||||
|
render() {
|
||||||
|
const { item } = this.props;
|
||||||
|
return (
|
||||||
|
<ListItemButton divider disabled={!item}>
|
||||||
|
<ListItemText primary={item ? item.name : '\u00A0'} />
|
||||||
|
</ListItemButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const SAMPLE_ITEMS = Array.from({ length: 40 }, (_, i) => ({
|
||||||
|
id: i + 1,
|
||||||
|
name: `Eintrag ${i + 1}`,
|
||||||
|
}));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mock page exercising FillSpace: real content on top, and a repeating
|
* Mock page exercising FillSpace: header/footer refs, `items` + `renderItem`,
|
||||||
* `measureElement` filling only the leftover space down to the end of the
|
* wheel pagination and the built-in pagination header.
|
||||||
* viewport.
|
|
||||||
*/
|
*/
|
||||||
class FillSpaceMockPage extends Component {
|
class FillSpaceMockPage extends Component {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
@@ -16,27 +34,27 @@ class FillSpaceMockPage extends Component {
|
|||||||
this.ex1 = { header: createRef(), footer: createRef() };
|
this.ex1 = { header: createRef(), footer: createRef() };
|
||||||
this.ex2 = { header: createRef(), footer: createRef() };
|
this.ex2 = { header: createRef(), footer: createRef() };
|
||||||
this.ex3 = { header: createRef(), footer: createRef() };
|
this.ex3 = { header: createRef(), footer: createRef() };
|
||||||
this.state = { fitCount: null };
|
this.exVirtual = { header: createRef(), footer: createRef() };
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { fitCount } = this.state;
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||||
<Typography variant="body2" color="text.secondary">
|
<Typography variant="body2" color="text.secondary">
|
||||||
FillSpace renders real content on top and repeats the measurement
|
FillSpace paginates <code>items</code> via a <code>renderItem</code>{' '}
|
||||||
element until the end of the page.
|
row, measures the row height, fills the leftover viewport space and
|
||||||
|
flips pages on wheel scroll.
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
{/* Main example: content + repeating fill to end of page */}
|
{/* Main example: items + renderItem to end of page */}
|
||||||
<Paper variant="outlined" sx={{ p: 2 }}>
|
<Paper variant="outlined" sx={{ p: 2 }}>
|
||||||
<Typography variant="subtitle2" gutterBottom>
|
<Typography variant="subtitle2" gutterBottom>
|
||||||
Content + repeating element to end of page
|
Items + renderItem, filled to end of page
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
<Box
|
<Box
|
||||||
ref={this.ex1.header}
|
ref={this.ex1.header}
|
||||||
sx={{ bgcolor: '#e2e8f0', p: 1, borderRadius: 1 }}
|
sx={{ bgcolor: '#e2e8f0', p: 1, borderRadius: 1, mb: 1 }}
|
||||||
>
|
>
|
||||||
<Typography variant="caption">Header (measured)</Typography>
|
<Typography variant="caption">Header (measured)</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -44,65 +62,17 @@ class FillSpaceMockPage extends Component {
|
|||||||
<FillSpace
|
<FillSpace
|
||||||
aboveRef={this.ex1.header}
|
aboveRef={this.ex1.header}
|
||||||
belowRef={this.ex1.footer}
|
belowRef={this.ex1.footer}
|
||||||
measureElement={
|
items={SAMPLE_ITEMS}
|
||||||
<Box
|
renderItem={Row}
|
||||||
sx={{
|
reserveBottom={32}
|
||||||
bgcolor: '#dbeafe',
|
minPageSize={5}
|
||||||
p: 2,
|
/>
|
||||||
borderRadius: 1,
|
|
||||||
textAlign: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography variant="body2">Einheit</Typography>
|
|
||||||
</Box>
|
|
||||||
}
|
|
||||||
onMeasurement={(count) => this.setState({ fitCount: count })}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
bgcolor: '#fef3c7',
|
|
||||||
p: 2,
|
|
||||||
borderRadius: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography variant="body2" fontWeight={600}>
|
|
||||||
Echte Inhalte (children)
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="caption" color="text.secondary">
|
|
||||||
Diese Zeilen stehen oben, darunter wiederholt sich die
|
|
||||||
Einheit bis zum Seitenende.
|
|
||||||
</Typography>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
mt: 1,
|
|
||||||
px: 1.5,
|
|
||||||
py: 0.75,
|
|
||||||
borderRadius: 1,
|
|
||||||
bgcolor: 'background.paper',
|
|
||||||
border: '1px dashed',
|
|
||||||
borderColor: 'divider',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography variant="caption" component="span">
|
|
||||||
📏 onMeasurement (würden ohne Inhalt passen):
|
|
||||||
</Typography>
|
|
||||||
<Typography
|
|
||||||
variant="caption"
|
|
||||||
component="span"
|
|
||||||
fontWeight={700}
|
|
||||||
color="primary"
|
|
||||||
>
|
|
||||||
{fitCount != null ? `${fitCount} Einheiten` : '…'}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</FillSpace>
|
|
||||||
|
|
||||||
<Box
|
<Box
|
||||||
ref={this.ex1.footer}
|
ref={this.ex1.footer}
|
||||||
sx={{ bgcolor: '#e2e8f0', p: 1, borderRadius: 1 }}
|
sx={{ bgcolor: '#e2e8f0', p: 1, borderRadius: 1, mt: 1 }}
|
||||||
>
|
>
|
||||||
<Typography variant="caption">Footer (measured)</Typography>
|
<Typography variant="caption">Footer (reserved)</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Paper>
|
</Paper>
|
||||||
@@ -115,7 +85,7 @@ class FillSpaceMockPage extends Component {
|
|||||||
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
<Box
|
<Box
|
||||||
ref={this.ex2.header}
|
ref={this.ex2.header}
|
||||||
sx={{ bgcolor: '#e2e8f0', p: 1, borderRadius: 1 }}
|
sx={{ bgcolor: '#e2e8f0', p: 1, borderRadius: 1, mb: 1 }}
|
||||||
>
|
>
|
||||||
<Typography variant="caption">Header</Typography>
|
<Typography variant="caption">Header</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -123,24 +93,15 @@ class FillSpaceMockPage extends Component {
|
|||||||
<FillSpace
|
<FillSpace
|
||||||
aboveRef={this.ex2.header}
|
aboveRef={this.ex2.header}
|
||||||
belowRef={this.ex2.footer}
|
belowRef={this.ex2.footer}
|
||||||
|
items={SAMPLE_ITEMS.slice(0, 6)}
|
||||||
|
renderItem={Row}
|
||||||
|
minPageSize={4}
|
||||||
minHeight={200}
|
minHeight={200}
|
||||||
>
|
/>
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
bgcolor: '#fef3c7',
|
|
||||||
p: 2,
|
|
||||||
borderRadius: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography variant="body2">
|
|
||||||
Minimum height of 200px enforced.
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</FillSpace>
|
|
||||||
|
|
||||||
<Box
|
<Box
|
||||||
ref={this.ex2.footer}
|
ref={this.ex2.footer}
|
||||||
sx={{ bgcolor: '#e2e8f0', p: 1, borderRadius: 1 }}
|
sx={{ bgcolor: '#e2e8f0', p: 1, borderRadius: 1, mt: 1 }}
|
||||||
>
|
>
|
||||||
<Typography variant="caption">Footer</Typography>
|
<Typography variant="caption">Footer</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -155,7 +116,7 @@ class FillSpaceMockPage extends Component {
|
|||||||
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
<Box
|
<Box
|
||||||
ref={this.ex3.header}
|
ref={this.ex3.header}
|
||||||
sx={{ bgcolor: '#e2e8f0', p: 1, borderRadius: 1 }}
|
sx={{ bgcolor: '#e2e8f0', p: 1, borderRadius: 1, mb: 1 }}
|
||||||
>
|
>
|
||||||
<Typography variant="caption">Header</Typography>
|
<Typography variant="caption">Header</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -163,25 +124,46 @@ class FillSpaceMockPage extends Component {
|
|||||||
<FillSpace
|
<FillSpace
|
||||||
aboveRef={this.ex3.header}
|
aboveRef={this.ex3.header}
|
||||||
belowRef={this.ex3.footer}
|
belowRef={this.ex3.footer}
|
||||||
|
items={SAMPLE_ITEMS}
|
||||||
|
renderItem={Row}
|
||||||
|
minPageSize={5}
|
||||||
debug
|
debug
|
||||||
>
|
/>
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
bgcolor: '#dcfce7',
|
|
||||||
p: 2,
|
|
||||||
borderRadius: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography variant="body2">
|
|
||||||
Green background shows actual spacer bounds; purple overlay
|
|
||||||
shows full viewport coverage.
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</FillSpace>
|
|
||||||
|
|
||||||
<Box
|
<Box
|
||||||
ref={this.ex3.footer}
|
ref={this.ex3.footer}
|
||||||
sx={{ bgcolor: '#e2e8f0', p: 1, borderRadius: 1 }}
|
sx={{ bgcolor: '#e2e8f0', p: 1, borderRadius: 1, mt: 1 }}
|
||||||
|
>
|
||||||
|
<Typography variant="caption">Footer</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{/* Example 4: Virtual Scrollbar */}
|
||||||
|
<Paper variant="outlined" sx={{ p: 2 }}>
|
||||||
|
<Typography variant="subtitle2" gutterBottom>
|
||||||
|
Virtual Scrollbar (scrollbar synchronized with pagination)
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<Box
|
||||||
|
ref={this.exVirtual.header}
|
||||||
|
sx={{ bgcolor: '#e2e8f0', p: 1, borderRadius: 1, mb: 1 }}
|
||||||
|
>
|
||||||
|
<Typography variant="caption">Header</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<FillSpace
|
||||||
|
aboveRef={this.exVirtual.header}
|
||||||
|
belowRef={this.exVirtual.footer}
|
||||||
|
items={SAMPLE_ITEMS}
|
||||||
|
renderItem={Row}
|
||||||
|
minPageSize={5}
|
||||||
|
virtualScrollbar
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
ref={this.exVirtual.footer}
|
||||||
|
sx={{ bgcolor: '#e2e8f0', p: 1, borderRadius: 1, mt: 1 }}
|
||||||
>
|
>
|
||||||
<Typography variant="caption">Footer</Typography>
|
<Typography variant="caption">Footer</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { Component, createRef } from 'react';
|
|||||||
import Alert from '@mui/material/Alert';
|
import Alert from '@mui/material/Alert';
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import Chip from '@mui/material/Chip';
|
import Chip from '@mui/material/Chip';
|
||||||
|
import ListItemButton from '@mui/material/ListItemButton';
|
||||||
|
import ListItemText from '@mui/material/ListItemText';
|
||||||
import Typography from '@mui/material/Typography';
|
import Typography from '@mui/material/Typography';
|
||||||
import AddIcon from '@mui/icons-material/Add';
|
import AddIcon from '@mui/icons-material/Add';
|
||||||
import FolderIcon from '@mui/icons-material/FolderOutlined';
|
import FolderIcon from '@mui/icons-material/FolderOutlined';
|
||||||
@@ -307,8 +309,8 @@ class ScreenshotHarness extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deterministic FillSpace example for the screenshot harness. Bound with a
|
* Deterministic FillSpace example for the screenshot harness:
|
||||||
* `maxHeight` so the reference is stable regardless of the viewport height.
|
* `items` + a component `renderItem`, bounded by above/below refs.
|
||||||
*/
|
*/
|
||||||
class FillSpaceExample extends Component {
|
class FillSpaceExample extends Component {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
@@ -322,7 +324,7 @@ class FillSpaceExample extends Component {
|
|||||||
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
<Box
|
<Box
|
||||||
ref={this.headerRef}
|
ref={this.headerRef}
|
||||||
sx={{ bgcolor: '#e2e8f0', p: 1, borderRadius: 1 }}
|
sx={{ bgcolor: '#e2e8f0', p: 1, borderRadius: 1, mb: 1 }}
|
||||||
>
|
>
|
||||||
<Typography variant="caption">Header (gemessen)</Typography>
|
<Typography variant="caption">Header (gemessen)</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -330,26 +332,15 @@ class FillSpaceExample extends Component {
|
|||||||
<FillSpace
|
<FillSpace
|
||||||
aboveRef={this.headerRef}
|
aboveRef={this.headerRef}
|
||||||
belowRef={this.footerRef}
|
belowRef={this.footerRef}
|
||||||
|
items={FILL_SAMPLE_ITEMS}
|
||||||
|
renderItem={FillRow}
|
||||||
|
minPageSize={4}
|
||||||
minHeight={80}
|
minHeight={80}
|
||||||
maxHeight={220}
|
/>
|
||||||
>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
bgcolor: '#dbeafe',
|
|
||||||
p: 2,
|
|
||||||
borderRadius: 1,
|
|
||||||
height: '100%',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography variant="body2">
|
|
||||||
FillSpace füllt den Platz zwischen Header und Footer.
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</FillSpace>
|
|
||||||
|
|
||||||
<Box
|
<Box
|
||||||
ref={this.footerRef}
|
ref={this.footerRef}
|
||||||
sx={{ bgcolor: '#e2e8f0', p: 1, borderRadius: 1 }}
|
sx={{ bgcolor: '#e2e8f0', p: 1, borderRadius: 1, mt: 1 }}
|
||||||
>
|
>
|
||||||
<Typography variant="caption">Footer (gemessen)</Typography>
|
<Typography variant="caption">Footer (gemessen)</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -358,9 +349,36 @@ class FillSpaceExample extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Row renderer used by the FillSpace harness examples. */
|
||||||
|
class FillRow extends Component {
|
||||||
|
render() {
|
||||||
|
const { item } = this.props;
|
||||||
|
return (
|
||||||
|
<ListItemButton divider disabled={!item}>
|
||||||
|
<ListItemText
|
||||||
|
primary={
|
||||||
|
item ? (
|
||||||
|
`${item.name}`
|
||||||
|
) : (
|
||||||
|
<Typography variant="body2" sx={{ visibility: 'hidden' }}>
|
||||||
|
Platzhalter
|
||||||
|
</Typography>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ListItemButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const FILL_SAMPLE_ITEMS = Array.from({ length: 24 }, (_, i) => ({
|
||||||
|
id: i + 1,
|
||||||
|
name: `Eintrag ${i + 1}`,
|
||||||
|
}));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* FillSpace with a repeating measurement element plus real children on top,
|
* FillSpace with `items` + a component `renderItem`, for the screenshot
|
||||||
* for the screenshot harness. Bound with maxHeight so the reference is stable.
|
* harness. Bounded by above/below refs so the reference is stable.
|
||||||
*/
|
*/
|
||||||
class FillSpaceMeasureExample extends Component {
|
class FillSpaceMeasureExample extends Component {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
@@ -374,7 +392,7 @@ class FillSpaceMeasureExample extends Component {
|
|||||||
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
<Box
|
<Box
|
||||||
ref={this.headerRef}
|
ref={this.headerRef}
|
||||||
sx={{ bgcolor: '#e2e8f0', p: 1, borderRadius: 1 }}
|
sx={{ bgcolor: '#e2e8f0', p: 1, borderRadius: 1, mb: 1 }}
|
||||||
>
|
>
|
||||||
<Typography variant="caption">Header</Typography>
|
<Typography variant="caption">Header</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -382,36 +400,15 @@ class FillSpaceMeasureExample extends Component {
|
|||||||
<FillSpace
|
<FillSpace
|
||||||
aboveRef={this.headerRef}
|
aboveRef={this.headerRef}
|
||||||
belowRef={this.footerRef}
|
belowRef={this.footerRef}
|
||||||
measureElement={
|
items={FILL_SAMPLE_ITEMS}
|
||||||
<Box
|
renderItem={FillRow}
|
||||||
sx={{
|
minPageSize={4}
|
||||||
bgcolor: '#c7d2fe',
|
|
||||||
p: 1,
|
|
||||||
borderRadius: 1,
|
|
||||||
textAlign: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography variant="caption">Einheit</Typography>
|
|
||||||
</Box>
|
|
||||||
}
|
|
||||||
measureGap={8}
|
|
||||||
minHeight={60}
|
minHeight={60}
|
||||||
maxHeight={260}
|
/>
|
||||||
>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
bgcolor: '#fef3c7',
|
|
||||||
p: 1,
|
|
||||||
borderRadius: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography variant="body2">Echte Inhalte</Typography>
|
|
||||||
</Box>
|
|
||||||
</FillSpace>
|
|
||||||
|
|
||||||
<Box
|
<Box
|
||||||
ref={this.footerRef}
|
ref={this.footerRef}
|
||||||
sx={{ bgcolor: '#e2e8f0', p: 1, borderRadius: 1 }}
|
sx={{ bgcolor: '#e2e8f0', p: 1, borderRadius: 1, mt: 1 }}
|
||||||
>
|
>
|
||||||
<Typography variant="caption">Footer</Typography>
|
<Typography variant="caption">Footer</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -158,15 +158,14 @@ class RecordList extends Component {
|
|||||||
sx={{ cursor: onEdit ? 'pointer' : undefined }}
|
sx={{ cursor: onEdit ? 'pointer' : undefined }}
|
||||||
onClick={() => this.handleRowClick(row)}
|
onClick={() => this.handleRowClick(row)}
|
||||||
>
|
>
|
||||||
{columns.map((col) => (
|
|
||||||
<TableCell
|
<TableCell
|
||||||
key={col.key}
|
key={col.key}
|
||||||
align={col.align}
|
align={col.align}
|
||||||
padding={col.padding}
|
padding={col.padding}
|
||||||
|
sx={col.mono || col.key === 'number' || col.key === 'sku' ? { fontFamily: 'monospace' } : undefined}
|
||||||
>
|
>
|
||||||
{col.render ? col.render(row) : row[col.key]}
|
{col.render ? col.render(row) : row[col.key]}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
))}
|
|
||||||
{hasActions ? (
|
{hasActions ? (
|
||||||
<TableCell
|
<TableCell
|
||||||
padding="checkbox"
|
padding="checkbox"
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ function valuesEqual(a, b) {
|
|||||||
*
|
*
|
||||||
* @prop {any} [value] - The saved value shown in read mode and used as the baseline for dirty detection.
|
* @prop {any} [value] - The saved value shown in read mode and used as the baseline for dirty detection.
|
||||||
* @prop {ReactNode} [title] - Overline label shown above the body.
|
* @prop {ReactNode} [title] - Overline label shown above the body.
|
||||||
|
* @prop {function} [titleContent] - Optional title render function receiving `{ editing, draft, onChange }`.
|
||||||
* @prop {ReactNode} [display] - Node rendered while not editing.
|
* @prop {ReactNode} [display] - Node rendered while not editing.
|
||||||
* @prop {ReactNode | function} [edit] - Node rendered while editing, or a render function receiving `{ draft, onChange, inputRef }`.
|
* @prop {ReactNode | function} [edit] - Node rendered while editing, or a render function receiving `{ draft, onChange, inputRef }`.
|
||||||
* @prop {(draft) => Promise<void>} [onSave] - Async save handler. The tile exits edit mode only after this resolves; if it rejects, the error is surfaced via `onError` and shown inline.
|
* @prop {(draft) => Promise<void>} [onSave] - Async save handler. The tile exits edit mode only after this resolves; if it rejects, the error is surfaced via `onError` and shown inline.
|
||||||
@@ -142,15 +143,24 @@ class EditablePanelTileBase extends Component {
|
|||||||
};
|
};
|
||||||
|
|
||||||
renderTitle() {
|
renderTitle() {
|
||||||
const { title, onSave } = this.props;
|
const { title, titleContent, onSave } = this.props;
|
||||||
if (!title) return null;
|
const { editing, draft } = this.state;
|
||||||
|
const content =
|
||||||
|
typeof titleContent === 'function'
|
||||||
|
? titleContent({
|
||||||
|
editing,
|
||||||
|
draft,
|
||||||
|
onChange: this.handleDraftChange,
|
||||||
|
})
|
||||||
|
: title;
|
||||||
|
if (!content) return null;
|
||||||
return (
|
return (
|
||||||
<Typography
|
<Typography
|
||||||
variant="overline"
|
variant="h6"
|
||||||
color="text.secondary"
|
color="text.secondary"
|
||||||
sx={{ display: 'block', mb: 1, pr: onSave ? 9 : 0 }}
|
sx={{ mt: -0.5, display: 'block', pr: onSave ? 9 : 0 }}
|
||||||
>
|
>
|
||||||
{title}
|
{content}
|
||||||
</Typography>
|
</Typography>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -278,7 +288,7 @@ class EditablePanelTileBase extends Component {
|
|||||||
sx={{
|
sx={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
pr: actionPadding,
|
...(actionPadding ? { pr: actionPadding } : {}),
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: contentAlignItems,
|
alignItems: contentAlignItems,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ import SaveOutlinedIcon from '@mui/icons-material/SaveOutlined';
|
|||||||
import { injectMuiTheme } from './injectMuiTheme';
|
import { injectMuiTheme } from './injectMuiTheme';
|
||||||
import {
|
import {
|
||||||
GRID_COLUMNS,
|
GRID_COLUMNS,
|
||||||
|
PANEL_TILE_GRADIENT_DARK,
|
||||||
|
PANEL_TILE_GRADIENT_DARK_TINTED,
|
||||||
|
PANEL_TILE_GRADIENT_LIGHT,
|
||||||
|
PANEL_TILE_GRADIENT_TINTED,
|
||||||
TILE_ACTION_PADDING,
|
TILE_ACTION_PADDING,
|
||||||
panelTilePaperSx,
|
panelTilePaperSx,
|
||||||
tileActionIconSx,
|
tileActionIconSx,
|
||||||
@@ -16,7 +20,8 @@ import {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Read-only content panel with a title and an optional corner action (icon
|
* Read-only content panel with a title and an optional corner action (icon
|
||||||
* button with busy spinner). It is a styled MUI Paper; children fill the body.
|
* button with busy spinner). It is a styled MUI Paper with a modern minimal
|
||||||
|
* diagonal gradient surface; children fill the body.
|
||||||
*
|
*
|
||||||
* @component PanelTile
|
* @component PanelTile
|
||||||
* @import dashgui/panels
|
* @import dashgui/panels
|
||||||
@@ -113,7 +118,7 @@ class PanelTileBase extends Component {
|
|||||||
>
|
>
|
||||||
{this.renderAction()}
|
{this.renderAction()}
|
||||||
{this.renderTitle()}
|
{this.renderTitle()}
|
||||||
<Box sx={{ flex: 1, minWidth: 0, pr: actionPadding }}>{children}</Box>
|
<Box sx={{ flex: 1, minWidth: 0, ...(actionPadding ? { pr: actionPadding } : {}) }}>{children}</Box>
|
||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -134,7 +139,7 @@ export const PanelTile = injectMuiTheme(PanelTileBase);
|
|||||||
* @behaviour Uses a CSS grid with `gap: 2` (theme spacing).
|
* @behaviour Uses a CSS grid with `gap: 2` (theme spacing).
|
||||||
* @behaviour Children position themselves via the `wide` prop of each tile.
|
* @behaviour Children position themselves via the `wide` prop of each tile.
|
||||||
*/
|
*/
|
||||||
export function PanelTileGrid({ children, sx }) {
|
export function PanelTileGrid({ children, sx, icon, text, label, badge, ...rest }) {
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
@@ -147,10 +152,17 @@ export function PanelTileGrid({ children, sx }) {
|
|||||||
gap: 2,
|
gap: 2,
|
||||||
...sx,
|
...sx,
|
||||||
}}
|
}}
|
||||||
|
{...rest}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { GRID_COLUMNS };
|
export {
|
||||||
|
GRID_COLUMNS,
|
||||||
|
PANEL_TILE_GRADIENT_LIGHT,
|
||||||
|
PANEL_TILE_GRADIENT_TINTED,
|
||||||
|
PANEL_TILE_GRADIENT_DARK,
|
||||||
|
PANEL_TILE_GRADIENT_DARK_TINTED,
|
||||||
|
};
|
||||||
|
|||||||
191
src/panels/TabbedPanelTileGrid.jsx
Normal file
191
src/panels/TabbedPanelTileGrid.jsx
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
import React, { Component } from 'react';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Chip from '@mui/material/Chip';
|
||||||
|
import Tab from '@mui/material/Tab';
|
||||||
|
import Tabs from '@mui/material/Tabs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tabbed wrapper for multiple `PanelTileGrid` layouts.
|
||||||
|
*
|
||||||
|
* Each child `PanelTileGrid` defines its tab metadata via props:
|
||||||
|
* - `text` (or `label`): Tab label string
|
||||||
|
* - `icon`: Optional icon ReactNode
|
||||||
|
* - `value`: Optional unique tab value (defaults to child index)
|
||||||
|
* - `disabled`: Optional boolean
|
||||||
|
* - `badge`: Optional string/number shown as a pill chip next to the label
|
||||||
|
*
|
||||||
|
* Example:
|
||||||
|
* ```jsx
|
||||||
|
* <TabbedPanelTileGrid>
|
||||||
|
* <PanelTileGrid icon={<StyleIcon />} text="Auswahl-Merkmale">
|
||||||
|
* <PanelTile wide={2}>...</PanelTile>
|
||||||
|
* <PanelTile wide={2}>...</PanelTile>
|
||||||
|
* </PanelTileGrid>
|
||||||
|
* <PanelTileGrid icon={<TuneIcon />} text="Text-Merkmale">
|
||||||
|
* <PanelTile wide={2}>...</PanelTile>
|
||||||
|
* </PanelTileGrid>
|
||||||
|
* </TabbedPanelTileGrid>
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @component TabbedPanelTileGrid
|
||||||
|
* @import dashgui/panels
|
||||||
|
*
|
||||||
|
* @prop {ReactNode} children - One or more PanelTileGrid elements.
|
||||||
|
* @prop {any} [value] - Controlled active tab value/index.
|
||||||
|
* @prop {any} [defaultValue] - Initial active tab value/index for uncontrolled mode.
|
||||||
|
* @prop {any} [defaultTab] - Alias for defaultValue.
|
||||||
|
* @prop {(nextValue: any, event: React.SyntheticEvent) => void} [onChange] - Called on tab switch.
|
||||||
|
* @prop {object} [sx] - Extra styles merged into root container Box.
|
||||||
|
* @prop {object} [tabsWrapperSx] - Extra styles for the container around Tabs.
|
||||||
|
* @prop {object} [tabsSx] - Extra styles merged into MUI Tabs.
|
||||||
|
* @prop {object} [tabSx] - Extra styles merged into MUI Tab.
|
||||||
|
*/
|
||||||
|
export class TabbedPanelTileGrid extends Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.state = {
|
||||||
|
internalTab: props.defaultValue ?? props.defaultTab ?? 0,
|
||||||
|
};
|
||||||
|
this.handleTabChange = this.handleTabChange.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
getTabs() {
|
||||||
|
const children = React.Children.toArray(this.props.children).filter(Boolean);
|
||||||
|
return children.map((child, index) => {
|
||||||
|
const value = child.props.value !== undefined ? child.props.value : index;
|
||||||
|
const text = child.props.text ?? child.props.label ?? child.props.title ?? `Tab ${index + 1}`;
|
||||||
|
const icon = child.props.icon ?? null;
|
||||||
|
const disabled = Boolean(child.props.disabled);
|
||||||
|
const badge = child.props.badge;
|
||||||
|
const color = child.props.color;
|
||||||
|
return {
|
||||||
|
value,
|
||||||
|
text,
|
||||||
|
icon,
|
||||||
|
disabled,
|
||||||
|
badge,
|
||||||
|
color,
|
||||||
|
child,
|
||||||
|
index,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getActiveValue(tabs) {
|
||||||
|
if (this.props.value !== undefined) {
|
||||||
|
return this.props.value;
|
||||||
|
}
|
||||||
|
const { internalTab } = this.state;
|
||||||
|
if (tabs.some((t) => t.value === internalTab)) {
|
||||||
|
return internalTab;
|
||||||
|
}
|
||||||
|
const firstEnabled = tabs.find((t) => !t.disabled) ?? tabs[0];
|
||||||
|
return firstEnabled ? firstEnabled.value : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleTabChange(event, nextValue) {
|
||||||
|
if (this.props.value === undefined) {
|
||||||
|
this.setState({ internalTab: nextValue }, () => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.dispatchEvent(new Event('resize'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if (typeof window !== 'undefined') {
|
||||||
|
window.dispatchEvent(new Event('resize'));
|
||||||
|
}
|
||||||
|
this.props.onChange?.(nextValue, event);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const {
|
||||||
|
sx,
|
||||||
|
tabsWrapperSx,
|
||||||
|
tabsSx,
|
||||||
|
tabSx,
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
|
const tabs = this.getTabs();
|
||||||
|
if (tabs.length === 0) return null;
|
||||||
|
|
||||||
|
const activeValue = this.getActiveValue(tabs);
|
||||||
|
const activeTab = tabs.find((t) => t.value === activeValue) ?? tabs[0];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={sx}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
mb: 2,
|
||||||
|
borderBottom: 1,
|
||||||
|
borderColor: 'divider',
|
||||||
|
...tabsWrapperSx,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tabs
|
||||||
|
value={activeValue}
|
||||||
|
onChange={this.handleTabChange}
|
||||||
|
textColor="primary"
|
||||||
|
indicatorColor="primary"
|
||||||
|
sx={{
|
||||||
|
minHeight: 44,
|
||||||
|
'& .MuiTabs-flexContainer': {
|
||||||
|
gap: 1,
|
||||||
|
},
|
||||||
|
'& .MuiTab-root': {
|
||||||
|
minHeight: 44,
|
||||||
|
textTransform: 'none',
|
||||||
|
fontSize: '0.9375rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
gap: 1,
|
||||||
|
px: 2,
|
||||||
|
py: 1,
|
||||||
|
borderRadius: '8px 8px 0 0',
|
||||||
|
color: 'text.secondary',
|
||||||
|
'&:hover': {
|
||||||
|
bgcolor: 'action.hover',
|
||||||
|
color: 'text.primary',
|
||||||
|
},
|
||||||
|
'&.Mui-selected': {
|
||||||
|
color: 'text.primary',
|
||||||
|
fontWeight: 700,
|
||||||
|
},
|
||||||
|
'& .MuiSvgIcon-root': {
|
||||||
|
fontSize: 20,
|
||||||
|
},
|
||||||
|
...tabSx,
|
||||||
|
},
|
||||||
|
...tabsSx,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<Tab
|
||||||
|
key={String(tab.value)}
|
||||||
|
value={tab.value}
|
||||||
|
disabled={tab.disabled}
|
||||||
|
icon={tab.icon}
|
||||||
|
iconPosition="start"
|
||||||
|
label={
|
||||||
|
tab.badge != null ? (
|
||||||
|
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<span>{tab.text}</span>
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
label={tab.badge}
|
||||||
|
sx={{ height: 20, fontSize: '0.75rem', fontWeight: 600 }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
tab.text
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Tabs>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{activeTab ? React.cloneElement(activeTab.child, { key: String(activeTab.value) }) : null}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TabbedPanelTileGrid;
|
||||||
@@ -1,4 +1,12 @@
|
|||||||
export { PanelTile, PanelTileGrid, GRID_COLUMNS } from './PanelTile';
|
export { PanelTile, PanelTileGrid, GRID_COLUMNS } from './PanelTile';
|
||||||
|
export { TabbedPanelTileGrid } from './TabbedPanelTileGrid';
|
||||||
export { EditablePanelTile } from './EditablePanelTile';
|
export { EditablePanelTile } from './EditablePanelTile';
|
||||||
export { injectMuiTheme } from './injectMuiTheme';
|
export { injectMuiTheme } from './injectMuiTheme';
|
||||||
export { PENDING_EDIT_BORDER } from './panelTileLayout';
|
export {
|
||||||
|
PENDING_EDIT_BORDER,
|
||||||
|
PANEL_TILE_BORDER_RADIUS,
|
||||||
|
PANEL_TILE_GRADIENT_LIGHT,
|
||||||
|
PANEL_TILE_GRADIENT_TINTED,
|
||||||
|
PANEL_TILE_GRADIENT_DARK,
|
||||||
|
PANEL_TILE_GRADIENT_DARK_TINTED,
|
||||||
|
} from './panelTileLayout';
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export function responsiveColumnSpan(wide) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const PENDING_EDIT_RING_PX = 4;
|
export const PENDING_EDIT_RING_PX = 4;
|
||||||
export const TILE_ACTION_PADDING = 12;
|
export const TILE_ACTION_PADDING = 0;
|
||||||
|
|
||||||
export const tileActionSx = {
|
export const tileActionSx = {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
@@ -31,6 +31,15 @@ export const tileActionIconSx = {
|
|||||||
|
|
||||||
export const PANEL_TILE_BORDER_RADIUS = 4;
|
export const PANEL_TILE_BORDER_RADIUS = 4;
|
||||||
|
|
||||||
|
export const PANEL_TILE_GRADIENT_LIGHT =
|
||||||
|
'linear-gradient(135deg, #ffffff 0%, #f8fafc 50%, #f1f5f9 100%)';
|
||||||
|
export const PANEL_TILE_GRADIENT_TINTED =
|
||||||
|
'linear-gradient(135deg, rgba(255, 255, 255, 0.45) 0%, rgba(255, 255, 255, 0) 55%, rgba(0, 0, 0, 0.04) 100%)';
|
||||||
|
export const PANEL_TILE_GRADIENT_DARK =
|
||||||
|
'linear-gradient(135deg, rgba(255, 255, 255, 0.06) 0%, rgba(255, 255, 255, 0) 50%, rgba(0, 0, 0, 0.2) 100%)';
|
||||||
|
export const PANEL_TILE_GRADIENT_DARK_TINTED =
|
||||||
|
'linear-gradient(135deg, rgba(255, 255, 255, 0.08) 0%, rgba(255, 255, 255, 0) 50%, rgba(0, 0, 0, 0.2) 100%)';
|
||||||
|
|
||||||
function tileRingBoxShadow(ringColor, elevationShadow) {
|
function tileRingBoxShadow(ringColor, elevationShadow) {
|
||||||
const inset = `inset 0 0 0 ${PENDING_EDIT_RING_PX}px ${ringColor}`;
|
const inset = `inset 0 0 0 ${PENDING_EDIT_RING_PX}px ${ringColor}`;
|
||||||
return elevationShadow ? `${inset}, ${elevationShadow}` : inset;
|
return elevationShadow ? `${inset}, ${elevationShadow}` : inset;
|
||||||
@@ -52,6 +61,14 @@ export function panelTilePaperSx({
|
|||||||
? EDITING_TILE_BORDER
|
? EDITING_TILE_BORDER
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
const isDark = theme?.palette?.mode === 'dark';
|
||||||
|
const defaultGradient = isDark
|
||||||
|
? PANEL_TILE_GRADIENT_DARK
|
||||||
|
: PANEL_TILE_GRADIENT_LIGHT;
|
||||||
|
const coloredGradient = isDark
|
||||||
|
? PANEL_TILE_GRADIENT_DARK_TINTED
|
||||||
|
: PANEL_TILE_GRADIENT_TINTED;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
gridColumn: responsiveColumnSpan(wide),
|
gridColumn: responsiveColumnSpan(wide),
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
@@ -61,7 +78,14 @@ export function panelTilePaperSx({
|
|||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
borderRadius: PANEL_TILE_BORDER_RADIUS,
|
borderRadius: PANEL_TILE_BORDER_RADIUS,
|
||||||
...(color ? { bgcolor: color } : {}),
|
...(color
|
||||||
|
? {
|
||||||
|
bgcolor: color,
|
||||||
|
backgroundImage: coloredGradient,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
backgroundImage: defaultGradient,
|
||||||
|
}),
|
||||||
...(ringColor
|
...(ringColor
|
||||||
? {
|
? {
|
||||||
boxShadow: tileRingBoxShadow(ringColor, elevationShadow),
|
boxShadow: tileRingBoxShadow(ringColor, elevationShadow),
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
|
import { qrcode } from 'vite-plugin-qrcode';
|
||||||
import { componentDocsPlugin } from './vite-plugin-component-docs.js';
|
import { componentDocsPlugin } from './vite-plugin-component-docs.js';
|
||||||
|
|
||||||
// Library dev server — renders the mock pages under /dev so components can be
|
// Library dev server — renders the mock pages under /dev so components can be
|
||||||
// inspected in isolation, and doubles as a Vite config for consumers that want
|
// inspected in isolation, and doubles as a Vite config for consumers that want
|
||||||
// to preview this package standalone.
|
// to preview this package standalone.
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react(), componentDocsPlugin()],
|
plugins: [react(), componentDocsPlugin(), qrcode()],
|
||||||
server: {
|
server: {
|
||||||
|
host: true,
|
||||||
port: 5199,
|
port: 5199,
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
|
|||||||
Reference in New Issue
Block a user