Compare commits

...

30 Commits

Author SHA1 Message Date
seb
6c9d6fa9e6 u 2026-10-27 00:28:18 +01:00
seb
a0c18ff4e7 u 2026-10-23 22:40:31 +02:00
seb
5d7911e644 u 2026-10-21 11:54:48 +02:00
seb
3dac143f1a u 2026-10-18 15:35:56 +02:00
seb
62d000728c u 2026-10-16 22:39:50 +02:00
seb
93d80019c2 u 2026-10-16 12:22:49 +02:00
seb
aeb9456204 u 2026-10-15 21:52:35 +02:00
seb
5d5c18635d u 2026-10-15 21:04:50 +02:00
seb
cd3a888a65 u 2026-10-15 02:33:26 +02:00
seb
b96eb762ae u 2026-10-14 00:22:04 +02:00
seb
c7b6af0b43 u 2026-10-13 23:20:59 +02:00
seb
1f25cf2dc6 i 2026-10-12 20:03:39 +02:00
seb
2757629128 u 2026-10-12 14:10:14 +02:00
seb
d34e955021 u 2026-10-12 02:17:26 +02:00
seb
775e1d9959 u 2026-10-12 02:15:10 +02:00
seb
c735ba9c65 u 2026-10-12 02:13:39 +02:00
seb
f49dd4b00f u 2026-10-12 01:52:43 +02:00
seb
c3fd05b424 u 2026-10-12 01:51:08 +02:00
seb
1277e355eb u 2026-10-11 23:42:19 +02:00
seb
37461e539b u 2026-10-11 22:09:08 +02:00
seb
3e90ec7f67 u 2026-10-11 22:08:12 +02:00
seb
90141a539a u 2026-10-11 22:05:18 +02:00
seb
64cfc56ccd u 2026-10-11 19:46:22 +02:00
seb
1976cfb617 u 2026-10-11 17:27:36 +02:00
seb
c34f86f762 u 2026-10-11 13:44:39 +02:00
seb
8325402bc6 u 2026-10-11 12:49:16 +02:00
seb
266f5212aa u 2026-10-11 03:00:44 +02:00
seb
023a8889cd u 2026-10-10 22:58:48 +02:00
seb
ed290c127b u 2026-10-10 22:44:10 +02:00
seb
bc49139b1e u 2026-10-10 02:37:41 +02:00
13 changed files with 901 additions and 346 deletions

View File

@@ -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
View File

@@ -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"
} }
} }
} }

View File

@@ -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.2" "vite": "^8.3.4",
"vite-plugin-qrcode": "^0.4.1"
} }
} }

View File

@@ -14,8 +14,17 @@ import { alpha } from '@mui/material/styles';
* *
* @prop {Array} items - Wheel flips pages. * @prop {Array} items - Wheel flips pages.
* @prop {React.ComponentType|Function} renderItem - Row renderer (component or callback). * @prop {React.ComponentType|Function} renderItem - Row renderer (component or callback).
* @prop {React.ReactNode} [header] - Rendered below pagination controls and above the list items.
* @prop {any} [headerItem] - Optional dummy/sample item passed to probe element for layout measurement.
* @prop {number} [minPageSize=10] - Rows per page. * @prop {number} [minPageSize=10] - Rows per page.
* @prop {number} [maxPageSize] - Maximum rows per page.
* @prop {number} [totalCount] - Total items in current dataset (or filtered dataset when paginating).
* @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 {number} [unfilteredCount] - Alias for unfilteredTotal.
* @prop {(page: number) => void} [onPageChange] - Called on page change. * @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.
* @prop {boolean} [virtualScrollbar=false] - Shows a synchronized scrollbar alongside pagination controls.
* @prop {boolean} [reserveScrollbar] - Reserves raceway space even when items do not overflow (defaults to true when virtualScrollbar is true).
* @prop {boolean} [debug=false] - Dashed outline for layout debugging. * @prop {boolean} [debug=false] - Dashed outline for layout debugging.
* @prop {object} [sx] - Extra styles. * @prop {object} [sx] - Extra styles.
*/ */
@@ -38,34 +47,53 @@ function renderRowElement(renderItem, item, index) {
} }
/** /**
* Data-free probe of the row height, rendered hidden. Class components * Probe of the row height, rendered hidden. Class components
* (e.g. `renderItem={ArtikelMarke}`) tolerate empty props * (e.g. `renderItem={ArtikelMarke}`) tolerate empty props
* (`brand`/`item` undefined) and keep a fixed height (40px thumb / * (`brand`/`item` undefined) and keep a fixed height (40px thumb /
* text field), so the probe measures the row without depending on the * text field). When `headerItem` is supplied, it is passed to the row
* first data element. Plain callbacks are NOT probed — calling arbitrary * for precise measurement of data-driven layouts.
* user code with `undefined` could throw — they fall back to the first
* rendered data row.
*/ */
function renderProbeElement(renderItem) { function renderProbeElement(renderItem, headerItem) {
if (!isRowComponent(renderItem)) return null; if (!isRowComponent(renderItem)) return null;
const Row = renderItem; const Row = renderItem;
return <Row brand={undefined} item={undefined} index={0} disabled />; 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 = {
unitHeight: 0, unitHeight: 0,
viewportHeight: null, viewportHeight: null,
pageSize: Number(props.minPageSize) || 10, pageSize: Math.min(maxSize, minSize),
currentPage: 1, currentPage: 1,
isDragging: false,
dragThumbTop: 0,
}; };
this.ref = null; this.ref = null;
this.trackRef = null;
this.thumbRef = null;
this.probeRef = null; this.probeRef = null;
this.wheelAcc = 0; 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() {
@@ -76,39 +104,91 @@ class FillSpace extends Component {
} }
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
if (prevProps.renderItem !== this.props.renderItem) { if (
prevProps.renderItem !== this.props.renderItem ||
prevProps.headerItem !== this.props.headerItem
) {
this._probeMeasure(); this._probeMeasure();
} }
if (prevProps.minPageSize !== this.props.minPageSize) { if (
this.setState({ pageSize: Number(this.props.minPageSize) || 10 }); 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.containerRef !== this.props.containerRef || prevProps.containerRef !== this.props.containerRef ||
prevProps.reserveBottom !== this.props.reserveBottom || prevProps.reserveBottom !== this.props.reserveBottom ||
prevProps.items !== this.props.items prevProps.items !== this.props.items ||
prevProps.header !== this.props.header
) { ) {
this._observe(); this._observe();
this._viewportMeasure(); this._viewportMeasure();
} }
// Clamp current page when the item list shrinks (e.g. brand deleted). 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 ( if (
Array.isArray(this.props.items) && Array.isArray(this.props.items) &&
this.props.items !== prevProps.items this.props.items !== prevProps.items
) { ) {
const pageCount = Math.max( const pageCount = Math.max(
1, 1,
Math.ceil(this.props.items.length / Math.max(1, this.state.pageSize)), Math.ceil(total / Math.max(1, this.state.pageSize)),
); );
if (this.state.currentPage > pageCount) { if (this.state.currentPage > pageCount) {
this.setState({ currentPage: pageCount }); this.setState({ currentPage: pageCount });
this.props.onPageChange?.(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);
@@ -120,6 +200,93 @@ 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(() => {
@@ -144,7 +311,7 @@ class FillSpace extends Component {
containerTarget, 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);
} }
} }
@@ -165,7 +332,8 @@ class FillSpace extends Component {
/** Only measures the height of the `renderItem` probe. Nothing else. */ /** Only measures the height of the `renderItem` probe. Nothing else. */
_probeMeasure() { _probeMeasure() {
const h = this.probeRef?.offsetHeight; const firstRow = this.ref?.querySelector('[data-fill-row]');
const h = this.probeRef?.offsetHeight || firstRow?.offsetHeight;
if (h > 0 && h !== this.state.unitHeight) { if (h > 0 && h !== this.state.unitHeight) {
this.setState({ unitHeight: h }, () => this._viewportMeasure()); this.setState({ unitHeight: h }, () => this._viewportMeasure());
} }
@@ -197,22 +365,31 @@ class FillSpace extends Component {
available = Math.max(0, window.innerHeight - rect.top - belowPx); available = Math.max(0, window.innerHeight - rect.top - belowPx);
} }
const minSize = Math.max(1, Number(minPageSize) || 10); 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 { unitHeight } = this.state;
const nextPageSize = const calculated =
unitHeight > 0 ? Math.max(minSize, Math.floor(available / unitHeight)) : minSize; 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 ( if (
available !== this.state.viewportHeight || nextViewportHeight !== this.state.viewportHeight ||
nextPageSize !== this.state.pageSize nextPageSize !== this.state.pageSize
) { ) {
this.setState({ viewportHeight: available, pageSize: nextPageSize }); this.setState({ viewportHeight: nextViewportHeight, pageSize: nextPageSize });
} }
} }
_handleWheel = (event) => { _handleWheel = (event) => {
const { items } = this.props; if (this.props.disableWheel) return;
const { items, totalCount } = this.props;
if (!Array.isArray(items)) return; if (!Array.isArray(items)) return;
if (this.ref && event.target && !this.ref.contains(event.target)) return;
const { pageSize, currentPage } = this.state; const { pageSize, currentPage } = this.state;
const pageCount = Math.max(1, Math.ceil(items.length / Math.max(1, pageSize))); 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; const delta = event.deltaY || 0;
if (pageCount <= 1) return; if (pageCount <= 1) return;
if (delta === 0) return; if (delta === 0) return;
@@ -230,13 +407,18 @@ class FillSpace extends Component {
}; };
_goToPage = (page) => { _goToPage = (page) => {
const { items, onPageChange } = this.props; const { items, onPageChange, totalCount } = this.props;
const total = Array.isArray(items) ? items.length : 0; 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 pageCount = Math.max(1, Math.ceil(total / Math.max(1, this.state.pageSize)));
const next = Math.min(Math.max(1, Number(page) || 1), pageCount); const next = Math.min(Math.max(1, Number(page) || 1), pageCount);
if (next === this.state.currentPage) return; if (next === this.state.currentPage) return;
this.setState({ currentPage: next }); this.setState({ currentPage: next });
onPageChange?.(next); onPageChange?.(next, { pageCount, pageSize: this.state.pageSize, total });
}; };
render() { render() {
@@ -249,40 +431,80 @@ class FillSpace extends Component {
minHeight, minHeight,
maxHeight, maxHeight,
minPageSize, minPageSize,
maxPageSize,
totalCount,
unfilteredCount,
unfilteredTotal: propUnfilteredTotal,
resetKey,
listRef,
onPageChange, onPageChange,
onWheel, onWheel,
disableWheel = false,
virtualScrollbar = false,
reserveScrollbar,
racewayMarginBottom,
racewayRight,
debug = false, debug = false,
items, items,
renderItem, renderItem,
header,
headerItem,
sx, sx,
...rest ...rest
} = this.props; } = this.props;
// FillSpace owns current page, wheel-pagination and the pagination header. // FillSpace owns current page, wheel-pagination and the pagination header.
// Caller feeds `items` plus one row element via `renderItem` (component // Caller feeds `items` plus one row element via `renderItem` (component
// type or callback). // type or callback). `items` may be undefined while data is loading.
const { pageSize, currentPage, viewportHeight } = this.state; const { pageSize, currentPage, viewportHeight } = this.state;
const total = items.length; 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 pageCount = Math.max(1, Math.ceil(total / Math.max(1, pageSize)));
const safePage = Math.min(Math.max(1, currentPage), pageCount); const safePage = Math.min(Math.max(1, currentPage), pageCount);
const offset = (safePage - 1) * pageSize; const offset = (safePage - 1) * pageSize;
const pageItems = items.slice(offset, offset + pageSize); const pageItems = list.slice(offset, offset + pageSize);
const showPagination = total > pageSize;
// Hidden data-free probe: measures `renderItem` itself (no data). const canScroll =
const probe = renderProbeElement(renderItem); virtualScrollbar &&
total > pageSize &&
(viewportHeight || 0) > 0;
const racewayBottom = Math.max(0, Number(racewayMarginBottom ?? RACEWAY_BOTTOM_MARGIN) || 0);
const racewayRightOffset = racewayRight != null ? racewayRight : 0;
const scrollbarReserved = reserveScrollbar ?? Boolean(virtualScrollbar);
const scrollbarPadding = scrollbarReserved
? `${Math.max(0, RACEWAY_WIDTH + racewayRightOffset + 8)}px`
: 0;
let thumbHeight = 0;
let thumbTop = 0;
if (canScroll) {
const trackHeight = Math.max(0, (viewportHeight || 0) - racewayBottom);
thumbHeight = Math.max(MIN_THUMB_HEIGHT, Math.min(trackHeight, total > 0 ? (pageSize / total) * trackHeight : trackHeight));
const maxThumbTravel = Math.max(0, trackHeight - thumbHeight);
thumbTop = this.state.isDragging
? this.state.dragThumbTop
: (pageCount > 1 && maxThumbTravel > 0 ? ((safePage - 1) / (pageCount - 1)) * maxThumbTravel : 0);
}
// Hidden probe: measures `renderItem` (passes headerItem when provided).
const probe = renderProbeElement(renderItem, this.props.headerItem);
return ( return (
<> <>
{needsPaginationContainer ? (
<Box <Box
// Fixed single-line bar: the header height must not depend on the // Fixed single-line bar: the header height must not depend on the
// page count. Otherwise the first fit (e.g. "1-17") pushes the // filtered count so the content never shifts when filtering.
// spacer down, the available height shrinks, and a second pass
// flips to "1-15". One pass: constant 44px, real values always
// rendered (only visibility is gated), no placeholder numbers.
sx={{ mb: 1, height: 44, overflow: 'hidden' }} sx={{ mb: 1, height: 44, overflow: 'hidden' }}
> >
<Box <Box
aria-hidden={!showPagination || undefined}
sx={{ sx={{
display: 'flex', display: 'flex',
justifyContent: 'space-between', justifyContent: 'space-between',
@@ -291,8 +513,6 @@ class FillSpace extends Component {
gap: 1, gap: 1,
width: '100%', width: '100%',
height: 44, height: 44,
visibility: showPagination ? 'visible' : 'hidden',
pointerEvents: showPagination ? 'auto' : 'none',
}} }}
> >
<Typography <Typography
@@ -301,8 +521,15 @@ class FillSpace extends Component {
noWrap noWrap
sx={{ flexShrink: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis' }} sx={{ flexShrink: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis' }}
> >
{total === 0 ? '0' : `${offset + 1}–${Math.min(offset + pageSize, total)}`} von{' '} {total === 0
{total} ? (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> </Typography>
<Pagination <Pagination
count={pageCount} count={pageCount}
@@ -323,16 +550,32 @@ class FillSpace extends Component {
height: 44, height: 44,
fontSize: '1rem', fontSize: '1rem',
}, },
visibility: showPaginationControls ? 'visible' : 'hidden',
pointerEvents: showPaginationControls ? 'auto' : 'none',
}} }}
/> />
</Box> </Box>
</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) => { onWheel={(e) => {
if (!disableWheel) {
this._handleWheel(e); this._handleWheel(e);
}
onWheel?.(e); onWheel?.(e);
}} }}
{...rest} {...rest}
@@ -342,9 +585,8 @@ class FillSpace extends Component {
height: viewportHeight ?? 'auto', height: viewportHeight ?? 'auto',
overflow: 'hidden', overflow: 'hidden',
width: '100%', width: '100%',
// Keep wheel ticks from scrolling the page behind the list. // Keep wheel ticks from scrolling the page behind the list unless disableWheel is true.
// (No preventDefault needed — React wheel is passive.) overscrollBehavior: disableWheel ? 'auto' : 'contain',
overscrollBehavior: 'contain',
}, },
debug debug
? (theme) => ({ ? (theme) => ({
@@ -354,10 +596,16 @@ class FillSpace extends Component {
sx, sx,
]} ]}
> >
<Box sx={{ width: '100%' }}> <Box
<List dense disablePadding sx={{ width: '100%' }}> sx={{
width: '100%',
pr: scrollbarPadding,
boxSizing: 'border-box',
}}
>
<List ref={listRef} dense disablePadding sx={{ width: '100%' }}>
{pageItems.map((item, i) => ( {pageItems.map((item, i) => (
<Box key={item?.id ?? offset + i} data-fill-row sx={{ width: '100%' }}> <Box key={item?.id ?? item?.sku ?? offset + i} data-fill-row sx={{ width: '100%' }}>
{renderRowElement(renderItem, item, offset + i)} {renderRowElement(renderItem, item, offset + i)}
</Box> </Box>
))} ))}
@@ -385,6 +633,57 @@ class FillSpace extends Component {
</List> </List>
</Box> </Box>
) : null} ) : 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>
) : null}
</Box> </Box>
{debug ? ( {debug ? (
<Box <Box

View File

@@ -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: {

View File

@@ -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):&nbsp;
</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>

View File

@@ -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>

View File

@@ -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"

View File

@@ -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
@@ -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,
};

View 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;

View File

@@ -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';

View File

@@ -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),

View File

@@ -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: {