diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ecb34606e45c..ebb6c1eece87 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -898,6 +898,55 @@ importers: specifier: 30.4.2 version: 30.4.2 + projects/js-packages/grid: + dependencies: + '@dnd-kit/core': + specifier: ^6.3.1 + version: 6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@dnd-kit/sortable': + specifier: ^10.0.0 + version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) + '@dnd-kit/utilities': + specifier: ^3.2.2 + version: 3.2.2(react@18.3.1) + '@wordpress/compose': + specifier: 7.46.0 + version: 7.46.0(react@18.3.1) + '@wordpress/element': + specifier: 6.46.0 + version: 6.46.0 + '@wordpress/icons': + specifier: 13.1.0 + version: 13.1.0(react@18.3.1) + '@wordpress/ui': + specifier: 0.13.0 + version: 0.13.0(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + clsx: + specifier: ^2.1.1 + version: 2.1.1 + devDependencies: + '@types/react': + specifier: ^18.3.27 + version: 18.3.28 + '@types/react-dom': + specifier: ^18.3.7 + version: 18.3.7(@types/react@18.3.28) + '@typescript/native-preview': + specifier: 7.0.0-dev.20260225.1 + version: 7.0.0-dev.20260225.1 + jetpack-js-tools: + specifier: workspace:* + version: link:../../../tools/js-tools + react: + specifier: 18.3.1 + version: 18.3.1 + react-dom: + specifier: 18.3.1 + version: 18.3.1(react@18.3.1) + typescript: + specifier: 5.9.3 + version: 5.9.3 + projects/js-packages/i18n-check-webpack-plugin: dependencies: debug: @@ -1837,6 +1886,134 @@ importers: specifier: 5.2.4 version: 5.2.4(webpack-cli@6.0.1)(webpack@5.105.2) + projects/js-packages/widget-dashboard: + dependencies: + '@automattic/jetpack-grid': + specifier: workspace:* + version: link:../grid + '@automattic/jetpack-widget-primitives': + specifier: workspace:* + version: link:../widget-primitives + '@wordpress/api-fetch': + specifier: 7.46.0 + version: 7.46.0 + '@wordpress/commands': + specifier: 1.46.0 + version: 1.46.0(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@wordpress/components': + specifier: 33.1.0 + version: 33.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@wordpress/compose': + specifier: 7.46.0 + version: 7.46.0(react@18.3.1) + '@wordpress/core-data': + specifier: 7.46.0 + version: 7.46.0(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@wordpress/data': + specifier: 10.46.0 + version: 10.46.0(react@18.3.1) + '@wordpress/dataviews': + specifier: 14.3.0 + version: 14.3.0(@types/react@18.3.28)(react@18.3.1) + '@wordpress/element': + specifier: 6.46.0 + version: 6.46.0 + '@wordpress/i18n': + specifier: 6.19.0 + version: 6.19.0 + '@wordpress/icons': + specifier: 13.1.0 + version: 13.1.0(react@18.3.1) + '@wordpress/preferences': + specifier: 4.46.0 + version: 4.46.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@wordpress/primitives': + specifier: 4.46.0 + version: 4.46.0(react@18.3.1) + '@wordpress/private-apis': + specifier: 1.46.0 + version: 1.46.0 + '@wordpress/ui': + specifier: 0.13.0 + version: 0.13.0(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@wordpress/viewport': + specifier: 6.46.0 + version: 6.46.0(react@18.3.1) + clsx: + specifier: ^2.1.1 + version: 2.1.1 + fast-deep-equal: + specifier: ^3.1.3 + version: 3.1.3 + uuid: + specifier: ^14.0.0 + version: 14.0.0 + devDependencies: + '@types/react': + specifier: ^18.3.27 + version: 18.3.28 + '@types/react-dom': + specifier: ^18.3.7 + version: 18.3.7(@types/react@18.3.28) + '@typescript/native-preview': + specifier: 7.0.0-dev.20260225.1 + version: 7.0.0-dev.20260225.1 + jetpack-js-tools: + specifier: workspace:* + version: link:../../../tools/js-tools + react: + specifier: 18.3.1 + version: 18.3.1 + react-dom: + specifier: 18.3.1 + version: 18.3.1(react@18.3.1) + typescript: + specifier: 5.9.3 + version: 5.9.3 + + projects/js-packages/widget-primitives: + dependencies: + '@wordpress/core-data': + specifier: 7.46.0 + version: 7.46.0(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@wordpress/data': + specifier: 10.46.0 + version: 10.46.0(react@18.3.1) + '@wordpress/dataviews': + specifier: 14.3.0 + version: 14.3.0(@types/react@18.3.28)(react@18.3.1) + '@wordpress/element': + specifier: 6.46.0 + version: 6.46.0 + '@wordpress/i18n': + specifier: 6.19.0 + version: 6.19.0 + '@wordpress/ui': + specifier: 0.13.0 + version: 0.13.0(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + devDependencies: + '@types/react': + specifier: ^18.3.27 + version: 18.3.28 + '@types/react-dom': + specifier: ^18.3.7 + version: 18.3.7(@types/react@18.3.28) + '@typescript/native-preview': + specifier: 7.0.0-dev.20260225.1 + version: 7.0.0-dev.20260225.1 + jetpack-js-tools: + specifier: workspace:* + version: link:../../../tools/js-tools + react: + specifier: 18.3.1 + version: 18.3.1 + react-dom: + specifier: 18.3.1 + version: 18.3.1(react@18.3.1) + typescript: + specifier: 5.9.3 + version: 5.9.3 + projects/packages/account-protection: {} projects/packages/activity-log: @@ -3868,6 +4045,15 @@ importers: '@automattic/charts': specifier: workspace:* version: link:../../js-packages/charts + '@automattic/jetpack-grid': + specifier: workspace:* + version: link:../../js-packages/grid + '@automattic/jetpack-widget-dashboard': + specifier: workspace:* + version: link:../../js-packages/widget-dashboard + '@automattic/jetpack-widget-primitives': + specifier: workspace:* + version: link:../../js-packages/widget-primitives '@automattic/number-formatters': specifier: workspace:* version: link:../../js-packages/number-formatters @@ -3877,9 +4063,27 @@ importers: '@date-fns/tz': specifier: 1.4.1 version: 1.4.1 + '@jetpack-premium-analytics/data': + specifier: link:packages/data + version: link:packages/data + '@jetpack-premium-analytics/datetime': + specifier: link:packages/datetime + version: link:packages/datetime + '@jetpack-premium-analytics/formatters': + specifier: link:packages/formatters + version: link:packages/formatters + '@jetpack-premium-analytics/routing': + specifier: link:packages/routing + version: link:packages/routing + '@jetpack-premium-analytics/ui': + specifier: link:packages/ui + version: link:packages/ui '@tanstack/react-query': specifier: 5.90.8 version: 5.90.8(react@18.3.1) + '@wordpress/admin-ui': + specifier: 2.1.0 + version: 2.1.0(@date-fns/tz@1.4.1)(date-fns@4.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@wordpress/api-fetch': specifier: 7.48.0 version: 7.48.0 @@ -3901,6 +4105,9 @@ importers: '@wordpress/dataviews': specifier: 14.3.0 version: 14.3.0(@date-fns/tz@1.4.1)(react@18.3.1) + '@wordpress/element': + specifier: 6.46.0 + version: 6.46.0 '@wordpress/i18n': specifier: ^6.9.0 version: 6.21.0 @@ -3908,8 +4115,8 @@ importers: specifier: ^13.0.0 version: 13.1.0(react@18.3.1) '@wordpress/primitives': - specifier: 4.48.0 - version: 4.48.0(react@18.3.1) + specifier: 4.46.0 + version: 4.46.0(react@18.3.1) '@wordpress/private-apis': specifier: 1.48.0 version: 1.48.0 @@ -7878,6 +8085,28 @@ packages: resolution: {integrity: sha512-4B4OijXeVNOPZlYA2oEwWOTkzyltLao+xbotHQeqN++Rv27Y6s818+n2Qkp8q+Fxhn0t/5lA5X1Mxktud8eayQ==} engines: {node: '>=14.17.0'} + '@dnd-kit/accessibility@3.1.1': + resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} + peerDependencies: + react: '>=16.8.0' + + '@dnd-kit/core@6.3.1': + resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@dnd-kit/sortable@10.0.0': + resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==} + peerDependencies: + '@dnd-kit/core': ^6.3.0 + react: '>=16.8.0' + + '@dnd-kit/utilities@3.2.2': + resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==} + peerDependencies: + react: '>=16.8.0' + '@emnapi/core@1.10.0': resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} @@ -10945,6 +11174,10 @@ packages: peerDependencies: react: ^18.0.0 + '@wordpress/api-fetch@7.46.0': + resolution: {integrity: sha512-QOxuHSUXMzLat3Y90+0HNUDPSlBUK53r4mQ4m7f4/OKaWRRZU5jzvDBJyj52dEST7yJ1eZtuqUkEwK2T1MEBfQ==} + engines: {node: '>=18.12.0', npm: '>=8.19.2'} + '@wordpress/api-fetch@7.48.0': resolution: {integrity: sha512-WYoIikKQPdRqrbLB9b9diM80q4g80NqqMPwVYZY9c7vbhJvj5c0hkA5zAlwba/iRbwqDjpRiZMKp8XntYLzMWw==} engines: {node: '>=18.12.0', npm: '>=8.19.2'} @@ -11033,6 +11266,13 @@ packages: '@wordpress/theme': optional: true + '@wordpress/commands@1.46.0': + resolution: {integrity: sha512-Pzn9noMCkmFs+tRd5ghpkJy1iZtc0EfHU8XQTKoL2rtafs5Sxhsw08+85RNci/Uk7FZKhDTjKCgy7bxlyZ4EIQ==} + engines: {node: '>=18.12.0', npm: '>=8.19.2'} + peerDependencies: + react: ^18.0.0 + react-dom: ^18.0.0 + '@wordpress/commands@1.48.0': resolution: {integrity: sha512-wSo0Sj0Y7Z+yNfhp8QouB7rBSC9d3+Vb2/RKB/480+WlidFvCDgsFJPojNenqXdPlUTVcbgjsj0jLls8BbwHbA==} engines: {node: '>=18.12.0', npm: '>=8.19.2'} @@ -11066,6 +11306,13 @@ packages: peerDependencies: react: ^18.0.0 + '@wordpress/core-data@7.46.0': + resolution: {integrity: sha512-mfiqOrXcsv4rZJZFYjmUSc5goK1cKpuQ1lSoSBnuKMJNZAxTCVTwexIaj0XI5Qr/ngUjT5U1+w4I0Fzuv/qCMQ==} + engines: {node: '>=18.12.0', npm: '>=8.19.2'} + peerDependencies: + react: ^18.0.0 + react-dom: ^18.0.0 + '@wordpress/core-data@7.48.0': resolution: {integrity: sha512-coukurBp/mTSugI1PRKwunJsk9/sVilwdFv5h4yFWisVIMclZQ0GJg+MOLiK18fWEtqjPD2J1j7Aoz12QEx1Lw==} engines: {node: '>=18.12.0', npm: '>=8.19.2'} @@ -11079,6 +11326,12 @@ packages: peerDependencies: react: ^18.0.0 + '@wordpress/data@10.46.0': + resolution: {integrity: sha512-vxOO2IEn+29eue9Pq7Mzsq1SipMAg0Rp0Oztz9LsgWQIF9yyylGlP3yHnFjEmJ4MonGSjzvpArlc7jWwkzutKg==} + engines: {node: '>=18.12.0', npm: '>=8.19.2'} + peerDependencies: + react: ^18.0.0 + '@wordpress/data@10.48.0': resolution: {integrity: sha512-6SjfTBlXu5fuJWmmlHlwV2wcrcsWL+M5O227AoEvrPSLo96UuMj2kAx3cKLtP3xyOMDyd38koQSf6+SS522bTA==} engines: {node: '>=18.12.0', npm: '>=8.19.2'} @@ -11217,6 +11470,11 @@ packages: resolution: {integrity: sha512-KGxdaLC36wE10GybSfjYGcyWiy+KQCYheB6T8jhZhQ9mlf2Zwx6aJgfZm/L6BLwNN33Efx+sJY3nvMIxI5UwnA==} engines: {node: '>=18.12.0', npm: '>=8.19.2'} + '@wordpress/i18n@6.19.0': + resolution: {integrity: sha512-hRXd2E0SF9OQf22ZZWw7Ny/o+Q9u8jINiF1p0bF+rnSDKQUgoStihak6YiazWVRiIEYwctzotKXlt0HePJelXA==} + engines: {node: '>=18.12.0', npm: '>=8.19.2'} + hasBin: true + '@wordpress/i18n@6.21.0': resolution: {integrity: sha512-IXGGUJqN6b7QddU0dZB3HLJKu6uDQuhLsrrzYpUYTjDhfa43XEaikA9xHNgZhqzRtOVYqsNHVliWcISvJ/xjZQ==} engines: {node: '>=18.12.0', npm: '>=8.19.2'} @@ -11335,6 +11593,13 @@ packages: react: ^18.0.0 react-dom: ^18.0.0 + '@wordpress/preferences@4.46.0': + resolution: {integrity: sha512-vLvkOKmziv/D0ksC8wZ94bAeIAvXQm+X86Bte36kXXEvrru2+QGxCz4pHT+qOdkkALzS2cKXc7prqRCigRzJwg==} + engines: {node: '>=18.12.0', npm: '>=8.19.2'} + peerDependencies: + react: ^18.0.0 + react-dom: ^18.0.0 + '@wordpress/preferences@4.48.0': resolution: {integrity: sha512-ae8SOpc+NTFf5dB1bgN4RwMCzCQC/gX0d72SDxqtBeU1N52+sihunob9bhPLAEimKS/nMR/kU+YS9j9y5jyZ0A==} engines: {node: '>=18.12.0', npm: '>=8.19.2'} @@ -11348,6 +11613,12 @@ packages: peerDependencies: prettier: '>=3' + '@wordpress/primitives@4.46.0': + resolution: {integrity: sha512-x1IhEVa/aGDe6otGJ4VIqEioQGfIeK5B1VQm32+ycqinJRbtbw9F5bgx4ARIdnm5M1Lg63oV9Bhmg/XMyGSTZA==} + engines: {node: '>=18.12.0', npm: '>=8.19.2'} + peerDependencies: + react: ^18.0.0 + '@wordpress/primitives@4.48.0': resolution: {integrity: sha512-dfF7IZotIqb6LUiGs7oPwKbSF8RPoC0JDSIrtxvgwFA/yvbc/pDIp/Zs0O8GvxZNxu4JIVnKskOhoLq7lAeziQ==} engines: {node: '>=18.12.0', npm: '>=8.19.2'} @@ -11358,6 +11629,10 @@ packages: resolution: {integrity: sha512-NuGrfSSnBC794erb3xSEKrzWLGCNLa+ukob0pyVRtnebU7fPgrhx4NCBCXYK1vTcAta3NAkOVRfUZgcmLFYA6g==} engines: {node: '>=18.12.0', npm: '>=8.19.2'} + '@wordpress/private-apis@1.46.0': + resolution: {integrity: sha512-l8dsEuxq6CrtsI7Twfpn6CbPHmGBUQoGN4oLPJG1Bqsr1yXXLU/bEx9KAQN9emxRjXaELPsn7x7TVx0TUoKyJw==} + engines: {node: '>=18.12.0', npm: '>=8.19.2'} + '@wordpress/private-apis@1.48.0': resolution: {integrity: sha512-HHOSXLCAlBggfMozwWtX36wgsSt22g2tZwpka47Rjzr3hNY1BZ6SrrFJumiNxooy5PDKbRgcF092PAF82hdJXg==} engines: {node: '>=18.12.0', npm: '>=8.19.2'} @@ -11498,6 +11773,12 @@ packages: resolution: {integrity: sha512-NfhCvFyJnKQ7XnqLlFXbigwZzhnNQZPgS+mpXTkttq/d0/b62TgvjQd5XIu5wiEkWXye7rmZfdkRmG8fWmEb3Q==} engines: {node: '>=18.12.0', npm: '>=8.19.2'} + '@wordpress/viewport@6.46.0': + resolution: {integrity: sha512-n/kfg5x/lGCK3FkyxrMh+D3LOk5FDPpbCtq81wtvy8Xy+GwuU4g2quRhfYENoia13tp6HVX52fyugRIGZmM/sg==} + engines: {node: '>=18.12.0', npm: '>=8.19.2'} + peerDependencies: + react: ^18.0.0 + '@wordpress/viewport@6.48.0': resolution: {integrity: sha512-mP9BAg4xsFMiActGBjmADqcws+URFloJEfOFiCDe8y1BqWHdeNaUBC1cjXcgYj4hjmcij/lCBVdccKHg6BEAgg==} engines: {node: '>=18.12.0', npm: '>=8.19.2'} @@ -20233,6 +20514,31 @@ snapshots: '@discoveryjs/json-ext@0.6.3': {} + '@dnd-kit/accessibility@3.1.1(react@18.3.1)': + dependencies: + react: 18.3.1 + tslib: 2.8.1 + + '@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@dnd-kit/accessibility': 3.1.1(react@18.3.1) + '@dnd-kit/utilities': 3.2.2(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + tslib: 2.8.1 + + '@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)': + dependencies: + '@dnd-kit/core': 6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@dnd-kit/utilities': 3.2.2(react@18.3.1) + react: 18.3.1 + tslib: 2.8.1 + + '@dnd-kit/utilities@3.2.2(react@18.3.1)': + dependencies: + react: 18.3.1 + tslib: 2.8.1 + '@emnapi/core@1.10.0': dependencies: '@emnapi/wasi-threads': 1.2.1 @@ -23819,6 +24125,26 @@ snapshots: '@wordpress/dom-ready': 4.48.0 '@wordpress/i18n': 6.21.0 + '@wordpress/admin-ui@2.1.0(@date-fns/tz@1.4.1)(date-fns@4.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@wordpress/components': 33.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@wordpress/element': 6.46.0 + '@wordpress/i18n': 6.21.0 + '@wordpress/private-apis': 1.48.0 + '@wordpress/route': 0.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@wordpress/style-runtime': 0.2.0 + '@wordpress/ui': 0.13.0(@date-fns/tz@1.4.1)(date-fns@4.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + clsx: 2.1.1 + react: 18.3.1 + transitivePeerDependencies: + - '@date-fns/tz' + - '@emotion/is-prop-valid' + - '@types/react' + - date-fns + - react-dom + - stylelint + - supports-color + '@wordpress/admin-ui@2.1.0(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@wordpress/components': 33.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -23968,6 +24294,11 @@ snapshots: react: 18.3.1 uuid: 14.0.0 + '@wordpress/api-fetch@7.46.0': + dependencies: + '@wordpress/i18n': 6.21.0 + '@wordpress/url': 4.48.0 + '@wordpress/api-fetch@7.48.0': dependencies: '@wordpress/i18n': 6.21.0 @@ -24893,6 +25224,30 @@ snapshots: - browserslist - supports-color + '@wordpress/commands@1.46.0(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@wordpress/base-styles': 8.0.0 + '@wordpress/components': 33.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@wordpress/data': 10.48.0(react@18.3.1) + '@wordpress/element': 6.46.0 + '@wordpress/i18n': 6.21.0 + '@wordpress/icons': 13.1.0(react@18.3.1) + '@wordpress/keyboard-shortcuts': 5.48.0(react@18.3.1) + '@wordpress/preferences': 4.48.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@wordpress/private-apis': 1.48.0 + '@wordpress/warning': 3.48.0 + clsx: 2.1.1 + cmdk: 1.1.1(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + transitivePeerDependencies: + - '@date-fns/tz' + - '@emotion/is-prop-valid' + - '@types/react' + - '@types/react-dom' + - stylelint + - supports-color + '@wordpress/commands@1.48.0(@date-fns/tz@1.4.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@wordpress/base-styles': 9.1.0 @@ -25200,6 +25555,40 @@ snapshots: react: 18.3.1 use-memo-one: 1.1.3(react@18.3.1) + '@wordpress/core-data@7.46.0(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@wordpress/api-fetch': 7.48.0 + '@wordpress/block-editor': 15.21.0(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@wordpress/blocks': 15.21.0(react@18.3.1) + '@wordpress/compose': 7.46.0(react@18.3.1) + '@wordpress/data': 10.48.0(react@18.3.1) + '@wordpress/deprecated': 4.48.0 + '@wordpress/element': 6.46.0 + '@wordpress/html-entities': 4.48.0 + '@wordpress/i18n': 6.21.0 + '@wordpress/is-shallow-equal': 5.48.0 + '@wordpress/private-apis': 1.48.0 + '@wordpress/rich-text': 7.48.0(react@18.3.1) + '@wordpress/sync': 1.48.0 + '@wordpress/undo-manager': 1.48.0 + '@wordpress/url': 4.48.0 + '@wordpress/warning': 3.48.0 + change-case: 4.1.2 + equivalent-key-map: 0.2.2 + fast-deep-equal: 3.1.3 + memize: 2.1.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + uuid: 14.0.0 + transitivePeerDependencies: + - '@date-fns/tz' + - '@emotion/is-prop-valid' + - '@types/react' + - '@types/react-dom' + - date-fns + - stylelint + - supports-color + '@wordpress/core-data@7.48.0(@date-fns/tz@1.4.1)(date-fns@4.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@wordpress/api-fetch': 7.48.0 @@ -25445,6 +25834,24 @@ snapshots: '@wordpress/deprecated': 4.48.0 react: 18.3.1 + '@wordpress/data@10.46.0(react@18.3.1)': + dependencies: + '@wordpress/compose': 7.46.0(react@18.3.1) + '@wordpress/deprecated': 4.48.0 + '@wordpress/element': 6.46.0 + '@wordpress/is-shallow-equal': 5.48.0 + '@wordpress/priority-queue': 3.48.0 + '@wordpress/private-apis': 1.48.0 + '@wordpress/redux-routine': 5.48.0(redux@5.0.1) + deepmerge: 4.3.1 + equivalent-key-map: 0.2.2 + is-plain-object: 5.0.0 + is-promise: 4.0.0 + react: 18.3.1 + redux: 5.0.1 + rememo: 4.0.2 + use-memo-one: 1.1.3(react@18.3.1) + '@wordpress/data@10.48.0(react@18.3.1)': dependencies: '@wordpress/compose': 8.1.0(react@18.3.1) @@ -26633,6 +27040,14 @@ snapshots: '@wordpress/html-entities@4.48.0': {} + '@wordpress/i18n@6.19.0': + dependencies: + '@tannin/sprintf': 1.3.3 + '@wordpress/hooks': 4.48.0 + gettext-parser: 1.4.0 + memize: 2.1.1 + tannin: 1.2.0 + '@wordpress/i18n@6.21.0': dependencies: '@tannin/sprintf': 1.3.3 @@ -27570,6 +27985,25 @@ snapshots: - stylelint - supports-color + '@wordpress/preferences@4.46.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@wordpress/a11y': 4.48.0 + '@wordpress/base-styles': 8.0.0 + '@wordpress/components': 33.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@wordpress/compose': 7.46.0(react@18.3.1) + '@wordpress/data': 10.48.0(react@18.3.1) + '@wordpress/deprecated': 4.48.0 + '@wordpress/element': 6.46.0 + '@wordpress/i18n': 6.21.0 + '@wordpress/icons': 13.1.0(react@18.3.1) + '@wordpress/private-apis': 1.48.0 + clsx: 2.1.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + transitivePeerDependencies: + - '@emotion/is-prop-valid' + - supports-color + '@wordpress/preferences@4.48.0(@date-fns/tz@1.4.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@wordpress/a11y': 4.48.0 @@ -27616,6 +28050,12 @@ snapshots: dependencies: prettier: wp-prettier@3.0.3 + '@wordpress/primitives@4.46.0(react@18.3.1)': + dependencies: + '@wordpress/element': 6.46.0 + clsx: 2.1.1 + react: 18.3.1 + '@wordpress/primitives@4.48.0(react@18.3.1)': dependencies: '@wordpress/element': 8.0.0 @@ -27626,6 +28066,8 @@ snapshots: dependencies: requestidlecallback: 0.3.0 + '@wordpress/private-apis@1.46.0': {} + '@wordpress/private-apis@1.48.0': {} '@wordpress/react-i18n@4.48.0': @@ -28279,6 +28721,13 @@ snapshots: dependencies: remove-accents: 0.5.0 + '@wordpress/viewport@6.46.0(react@18.3.1)': + dependencies: + '@wordpress/compose': 7.46.0(react@18.3.1) + '@wordpress/data': 10.48.0(react@18.3.1) + '@wordpress/element': 6.46.0 + react: 18.3.1 + '@wordpress/viewport@6.48.0(react@18.3.1)': dependencies: '@wordpress/compose': 8.1.0(react@18.3.1) diff --git a/projects/js-packages/grid/.gitignore b/projects/js-packages/grid/.gitignore new file mode 100644 index 000000000000..7e5da87a90b0 --- /dev/null +++ b/projects/js-packages/grid/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +vendor/ diff --git a/projects/js-packages/grid/CHANGELOG.md b/projects/js-packages/grid/CHANGELOG.md new file mode 100644 index 000000000000..03a962f457f6 --- /dev/null +++ b/projects/js-packages/grid/CHANGELOG.md @@ -0,0 +1,6 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). diff --git a/projects/js-packages/grid/README.md b/projects/js-packages/grid/README.md new file mode 100644 index 000000000000..f7463dede7bb --- /dev/null +++ b/projects/js-packages/grid/README.md @@ -0,0 +1,28 @@ +# @automattic/jetpack-grid + +Internal, **private** port of WordPress core's `@wordpress/grid` (the drag-and-drop / resize +grid powering dashboard layouts). Consumed by `@automattic/jetpack-widget-dashboard` and the +Jetpack Premium Analytics dashboard route. + +Consumed as **source** (`exports` → `./src/index.ts`); the host build (`wp-build`) compiles the +TypeScript and CSS modules. This package ships no build output. + +## Provenance + +Ported verbatim from [`WordPress/gutenberg`](https://github.com/WordPress/gutenberg) `packages/grid/src` +@ commit `8a40c807e86` (branch `refactor/wp-build-name-as-module-id`). + +When core publishes `@wordpress/grid`, replace this package with the published dependency and +update the import name in consumers. Keep this README's commit reference updated when syncing. + +### Local deviations from core + +- `src/shared/drag-overlay-drop-animation.ts`: the default-cleanup call uses a `typeof` guard + instead of an optional call (`cleanupDefault?.()`). Jetpack type-checks with `tsgo` + (`@typescript/native-preview`), which rejects optional-calling a `void | CleanupFunction` + union; the guard is semantically identical and passes both `tsc` and `tsgo`. + +## Privacy + +`"private": true` in `package.json` + `composer.json` without `npmjs-autopublish`/`mirror-repo` +guarantees this package is never published to npm or mirrored to a standalone repo. diff --git a/projects/js-packages/grid/changelog/initial-version b/projects/js-packages/grid/changelog/initial-version new file mode 100644 index 000000000000..0dbf8692af7d --- /dev/null +++ b/projects/js-packages/grid/changelog/initial-version @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Initial version: private port of WordPress core's @wordpress/grid for dashboard layouts. diff --git a/projects/js-packages/grid/composer.json b/projects/js-packages/grid/composer.json new file mode 100644 index 000000000000..81ec81944246 --- /dev/null +++ b/projects/js-packages/grid/composer.json @@ -0,0 +1,21 @@ +{ + "name": "automattic/jetpack-js-grid", + "description": "Grid component with drag-and-drop reordering and resize for dashboard layouts. Internal port of @wordpress/grid until it is published.", + "type": "library", + "license": "GPL-2.0-or-later", + "require": {}, + "repositories": [ + { + "type": "path", + "url": "../../packages/*", + "options": { + "monorepo": true + } + } + ], + "minimum-stability": "dev", + "prefer-stable": true, + "extra": { + "textdomain": "jetpack-grid" + } +} diff --git a/projects/js-packages/grid/eslint.config.mjs b/projects/js-packages/grid/eslint.config.mjs new file mode 100644 index 000000000000..2ccd4474e681 --- /dev/null +++ b/projects/js-packages/grid/eslint.config.mjs @@ -0,0 +1,17 @@ +import { makeBaseConfig, defineConfig } from 'jetpack-js-tools/eslintrc/base.mjs'; + +// `src/` is vendored verbatim from WordPress core's `@wordpress/grid`. These rules +// conflict with core's house style; turning them off keeps the port faithful and +// avoids churn on every upstream re-sync. Drop this once core publishes the package. +export default defineConfig( makeBaseConfig( import.meta.url ), { + rules: { + 'react/jsx-no-bind': 'off', + 'import/order': 'off', + 'no-shadow': 'off', + 'jsdoc/check-indentation': 'off', + 'jsdoc/escape-inline-tags': 'off', + 'jsdoc/require-description': 'off', + 'jsdoc/require-param-description': 'off', + 'jsdoc/require-returns': 'off', + }, +} ); diff --git a/projects/js-packages/grid/package.json b/projects/js-packages/grid/package.json new file mode 100644 index 000000000000..d4e48ffd0e52 --- /dev/null +++ b/projects/js-packages/grid/package.json @@ -0,0 +1,49 @@ +{ + "name": "@automattic/jetpack-grid", + "version": "0.1.0-alpha", + "private": true, + "description": "Grid component with drag-and-drop reordering and resize for dashboard layouts. Internal port of @wordpress/grid until it is published.", + "license": "GPL-2.0-or-later", + "author": "Automattic", + "type": "module", + "sideEffects": [ + "**/*.module.css" + ], + "exports": { + ".": { + "jetpack:src": "./src/index.ts", + "types": "./src/index.ts", + "import": "./src/index.ts", + "default": "./src/index.ts" + } + }, + "main": "./src/index.ts", + "module": "./src/index.ts", + "types": "./src/index.ts", + "scripts": { + "typecheck": "tsgo --noEmit" + }, + "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + "@wordpress/compose": "7.46.0", + "@wordpress/element": "6.46.0", + "@wordpress/icons": "13.1.0", + "@wordpress/ui": "0.13.0", + "clsx": "^2.1.1" + }, + "devDependencies": { + "@types/react": "^18.3.27", + "@types/react-dom": "^18.3.7", + "@typescript/native-preview": "7.0.0-dev.20260225.1", + "jetpack-js-tools": "workspace:*", + "react": "18.3.1", + "react-dom": "18.3.1", + "typescript": "5.9.3" + }, + "peerDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0" + } +} diff --git a/projects/js-packages/grid/src/dashboard-grid/grid-item.module.css b/projects/js-packages/grid/src/dashboard-grid/grid-item.module.css new file mode 100644 index 000000000000..69ff0b5465fc --- /dev/null +++ b/projects/js-packages/grid/src/dashboard-grid/grid-item.module.css @@ -0,0 +1,99 @@ +.item { + position: relative; +} + +.item-content { + position: relative; + height: 100%; +} + +.is-resizing { + overflow: visible; + z-index: 1; +} + +.is-resizing .item-content { + position: relative; + z-index: 2; + overflow: visible; +} + +/* + * During drag, the original item acts as a placeholder in its grid + * cell while `` renders a clone that follows the cursor. + * Fading the placeholder and outlining it makes the destination visible + * without any scaling or translation on the original element. + * + * Placeholder chrome waits until `data-wp-grid-dragging` is set and the + * drag-preview enter animation (`--wpds-motion-duration-sm`) finishes + * so the dashed outline does not flash under the lifting clone. + */ +.is-dragging { + pointer-events: none; +} + +:global([data-wp-grid-dragging]) .is-dragging { + border-radius: var(--wp-grid-placeholder-radius, 0); +} + +@media not (prefers-reduced-motion: reduce) { + + :global([data-wp-grid-dragging]) .is-dragging { + opacity: 1; + outline-width: 0; + outline-style: var(--wp-grid-placeholder-outline-style, dashed); + outline-color: transparent; + animation: + wp-grid-item-placeholder-in 0ms linear + var(--wpds-motion-duration-sm) forwards; + } + + @keyframes wp-grid-item-placeholder-in { + + to { + opacity: var(--wp-grid-placeholder-opacity, 0.4); + outline-width: var(--wpds-border-width-sm); + outline-color: var(--wp-grid-placeholder-outline-color, var(--wpds-color-stroke-interactive-brand)); + } + } +} + +@media (prefers-reduced-motion: reduce) { + + :global([data-wp-grid-dragging]) .is-dragging { + opacity: var(--wp-grid-placeholder-opacity, 0.4); + outline: + var(--wpds-border-width-sm) + var(--wp-grid-placeholder-outline-style, dashed) + var(--wp-grid-placeholder-outline-color, var(--wpds-color-stroke-interactive-brand)); + } +} + +@media (forced-colors: active) { + + :global([data-wp-grid-dragging]) .is-dragging { + --wp-grid-placeholder-outline-color: Highlight; + } +} + +.preview-overlay { + position: absolute; + top: 0; + inset-inline-start: 0; + box-sizing: border-box; + pointer-events: none; + z-index: 0; + border: + var(--wpds-border-width-sm) + var(--wp-grid-resize-preview-outline-style, solid) + var(--wp-grid-placeholder-outline-color, var(--wpds-color-stroke-interactive-brand)); + background: transparent; + border-radius: var(--wp-grid-placeholder-radius, 0); +} + +@media (forced-colors: active) { + + .preview-overlay { + border-color: Highlight; + } +} diff --git a/projects/js-packages/grid/src/dashboard-grid/grid-item.tsx b/projects/js-packages/grid/src/dashboard-grid/grid-item.tsx new file mode 100644 index 000000000000..7db58e3a3313 --- /dev/null +++ b/projects/js-packages/grid/src/dashboard-grid/grid-item.tsx @@ -0,0 +1,212 @@ +/** + * External dependencies + */ +import { useSortable } from '@dnd-kit/sortable'; +import { useMergeRefs } from '@wordpress/compose'; +import { useState, useRef } from '@wordpress/element'; +import clsx from 'clsx'; + +/** + * WordPress dependencies + */ + +/** + * Internal dependencies + */ +import actionableAreaStyles from '../shared/actionable-area-slot.module.css'; +import { GRID_ITEM_DATA_KEY } from '../shared/grid-item-key'; +import ResizeHandle from '../shared/resize-handle'; +import { clampResizeDelta, type ResizeSnapSize } from '../shared/resize-snap'; +import styles from './grid-item.module.css'; +import type { GridItemProps } from './types'; +import type { ResizeDelta } from '../shared/types'; + +/** + * + * @param disabled + * @param interacting + */ +function getItemCursor( disabled: boolean, interacting: boolean ): React.CSSProperties[ 'cursor' ] { + if ( disabled ) { + return 'default'; + } + + if ( interacting ) { + return undefined; + } + + return 'grab'; +} + +/** + * + * @param root0 + * @param root0.item + * @param root0.maxColumns + * @param root0.disabled + * @param root0.verticalResizable + * @param root0.interacting + * @param root0.dragging + * @param root0.children + * @param root0.actionableArea + * @param root0.onResize + * @param root0.onResizeEnd + * @param root0.resizeSnapPreview + * @param root0.minResizeWidthPx + * @param root0.minResizeHeightPx + * @param root0.renderResizeHandle + */ +export function GridItem( { + item, + maxColumns, + disabled = false, + verticalResizable = true, + interacting = false, + dragging = false, + children, + actionableArea = null, + onResize, + onResizeEnd, + resizeSnapPreview = null, + minResizeWidthPx, + minResizeHeightPx, + renderResizeHandle, +}: GridItemProps ) { + const [ resizeDelta, setResizeDelta ] = useState< ResizeDelta | null >( null ); + const [ initialContentSize, setInitialContentSize ] = useState< { + width: number; + height: number; + } | null >( null ); + const itemRef = useRef< HTMLDivElement >( null ); + const contentRef = useRef< HTMLDivElement >( null ); + const { attributes, listeners, setNodeRef, setActivatorNodeRef, isDragging } = useSortable( { + id: item.key, + disabled, + } ); + const mergedRef = useMergeRefs( [ itemRef, setNodeRef ] ); + const contentMergedRef = useMergeRefs( [ contentRef ] ); + /* + * With `` handling the cursor-following clone, the + * sortable item stays put in its grid cell and acts as a + * placeholder. No `transform` is applied here — applying one + * would double-move the placeholder alongside the overlay. + */ + const style = { + gridColumnEnd: `span ${ + item.width === 'full' + ? maxColumns + : Math.min( typeof item.width === 'number' ? item.width : 1, maxColumns ) + }`, + gridRowEnd: `span ${ item.height || 1 }`, + }; + + const isResizing = resizeDelta !== null; + const itemClassName = clsx( + styles.item, + isDragging && styles[ 'is-dragging' ], + isResizing && styles[ 'is-resizing' ] + ); + + const handleResize = ( delta: ResizeDelta ) => { + const contentNode = contentRef.current; + let baselineSize = initialContentSize; + if ( contentNode && ! baselineSize ) { + const { width, height } = contentNode.getBoundingClientRect(); + baselineSize = { width, height }; + setInitialContentSize( baselineSize ); + } + let clamped: ResizeDelta = { + width: delta.width, + height: verticalResizable ? delta.height : 0, + }; + if ( baselineSize ) { + clamped = clampResizeDelta( clamped, baselineSize, { + width: minResizeWidthPx, + height: verticalResizable ? minResizeHeightPx : undefined, + } ); + } + setResizeDelta( clamped ); + onResize( item.key, clamped ); + }; + + const handleResizeEnd = () => { + setResizeDelta( null ); + setInitialContentSize( null ); + onResizeEnd(); + }; + + const continuousContentStyle: React.CSSProperties | undefined = + resizeDelta && initialContentSize + ? { + width: initialContentSize.width + resizeDelta.width, + height: verticalResizable ? initialContentSize.height + resizeDelta.height : undefined, + } + : undefined; + + const previewOverlay = resizeSnapPreview ? ( + + ) : null; + + return ( +
+ { actionableArea ? ( +
+
+ { actionableArea } +
+
+ ) : null } + +
+
+ { children } + { ! disabled && ( + + ) } +
+ { previewOverlay } +
+
+ ); +} + +/** + * + * @param root0 + * @param root0.snap + */ +function SnapPreviewOverlay( { snap }: { snap: ResizeSnapSize } ) { + return ( +
+ ); +} diff --git a/projects/js-packages/grid/src/dashboard-grid/grid.module.css b/projects/js-packages/grid/src/dashboard-grid/grid.module.css new file mode 100644 index 000000000000..990a2df40f47 --- /dev/null +++ b/projects/js-packages/grid/src/dashboard-grid/grid.module.css @@ -0,0 +1,142 @@ +.grid { + display: grid; + gap: var(--wp-grid-gap, var(--wpds-dimension-gap-xl)); + position: relative; +} + +/* + * Functional frame for the drag-preview clone inside ``. + * Always applied, including when the consumer passes a + * `renderDragPreview` wrapper. The outer frame has no `transform` so + * @dnd-kit’s drop translation matches the placeholder; scale lives on + * `__lift` (see dnd-kit #398). Owns elevation, radius, cursor, and + * pointer pass-through. Further chrome belongs to the consumer. Drag + * elevation animates `xs` → `md` so it reads above edit tiles at `xs`. + */ +.drag-preview-frame { + height: 100%; + border-radius: var(--wp-grid-drag-preview-radius, 0); + cursor: grabbing; + pointer-events: none; + box-shadow: var(--wpds-elevation-md); +} + +.drag-preview-frame__lift { + height: 100%; + transform: scale(var(--wp-grid-drag-preview-scale, 1.05)); + transform-origin: center; +} + +@media not (prefers-reduced-motion: reduce) { + + .drag-preview-frame { + animation: + wp-grid-drag-preview-shadow-enter + var(--wpds-motion-duration-sm) var(--wpds-motion-easing-balanced) both; + } + + .drag-preview-frame__lift { + animation: + wp-grid-drag-preview-scale-enter + var(--wpds-motion-duration-sm) var(--wpds-motion-easing-balanced) both; + } +} + +@media not (prefers-reduced-motion: reduce) { + + @keyframes wp-grid-drag-preview-shadow-enter { + + from { + box-shadow: var(--wpds-elevation-xs); + } + + to { + box-shadow: var(--wpds-elevation-md); + } + } + + @keyframes wp-grid-drag-preview-scale-enter { + + from { + transform: scale(1); + } + + to { + transform: scale(var(--wp-grid-drag-preview-scale, 1.05)); + } + } +} + +/* + * Applied by @dnd-kit `DragOverlay` drop side-effects while the + * default overlay transform runs; duration matches + * `createDashboardDragDropAnimation` (200ms / md token). Use keyframed + * exit (not transition) so scale does not snap when the enter animation + * is replaced. + */ +@media not (prefers-reduced-motion: reduce) { + + .drag-preview-frame.dragPreviewFrameExiting { + animation: + wp-grid-drag-preview-shadow-exit + var(--wpds-motion-duration-md) var(--wpds-motion-easing-balanced) + forwards; + } + + .drag-preview-frame.dragPreviewFrameExiting .drag-preview-frame__lift { + animation: + wp-grid-drag-preview-scale-exit + var(--wpds-motion-duration-md) var(--wpds-motion-easing-balanced) + forwards; + } + + @keyframes wp-grid-drag-preview-shadow-exit { + + from { + box-shadow: var(--wpds-elevation-md); + } + + to { + box-shadow: var(--wpds-elevation-xs); + } + } + + @keyframes wp-grid-drag-preview-scale-exit { + + from { + transform: scale(var(--wp-grid-drag-preview-scale, 1.05)); + } + + to { + transform: scale(1); + } + } +} + +.drag-preview-frame.dragPreviewFrameExiting { + box-shadow: var(--wpds-elevation-xs); +} + +.drag-preview-frame.dragPreviewFrameExiting .drag-preview-frame__lift { + transform: scale(1); +} + +@media (prefers-reduced-motion: reduce) { + + .drag-preview-frame { + box-shadow: var(--wpds-elevation-md); + } + + .drag-preview-frame__lift { + transform: none; + } + + .drag-preview-frame.dragPreviewFrameExiting { + box-shadow: var(--wpds-elevation-xs); + transition: none; + } + + .drag-preview-frame.dragPreviewFrameExiting .drag-preview-frame__lift { + transition: none; + } +} diff --git a/projects/js-packages/grid/src/dashboard-grid/index.tsx b/projects/js-packages/grid/src/dashboard-grid/index.tsx new file mode 100644 index 000000000000..fcbdbf36193a --- /dev/null +++ b/projects/js-packages/grid/src/dashboard-grid/index.tsx @@ -0,0 +1,624 @@ +/** + * External dependencies + */ +import { + DndContext, + DragOverlay, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, +} from '@dnd-kit/core'; +import { arrayMove, SortableContext, sortableKeyboardCoordinates } from '@dnd-kit/sortable'; +import { useResizeObserver, useEvent, useMergeRefs } from '@wordpress/compose'; +import { + forwardRef, + useMemo, + Children, + cloneElement, + isValidElement, + useLayoutEffect, + useRef, + useState, +} from '@wordpress/element'; +import clsx from 'clsx'; + +/** + * WordPress dependencies + */ + +/** + * Internal dependencies + */ +import { createDashboardDragDropAnimation } from '../shared/drag-overlay-drop-animation'; +import { GridOverlay } from '../shared/grid-overlay'; +import { ItemExitOverlay } from '../shared/item-exit-overlay'; +import layoutAnimationStyles from '../shared/layout-shift-animation.module.css'; +import { gridSpanToPixelSize } from '../shared/resize-snap'; +import { useItemExitAnimation } from '../shared/use-item-exit-animation'; +import { + getLayoutFingerprint, + useLayoutShiftAnimation, +} from '../shared/use-layout-shift-animation'; +import { GridItem } from './grid-item'; +import styles from './grid.module.css'; +import { resolveFillWidths } from './resolve-fill-widths'; +import type { DashboardGridLayoutItem, DashboardGridProps } from './types'; +import type { ResizeSnapSize } from '../shared/resize-snap'; +import type { ResizeDelta } from '../shared/types'; +import type { DragMoveEvent, DragStartEvent } from '@dnd-kit/core'; + +const dashboardDragDropAnimation = createDashboardDragDropAnimation( + styles[ 'drag-preview-frame' ], + styles.dragPreviewFrameExiting +); + +// Fallback gap in pixels for math that runs before the computed gap +// can be read from the DOM. Matches the `'xl'` step the surface +// resolves to in CSS (`--wpds-dimension-gap-xl`); the next layout +// effect overwrites this with the actual computed value. +const FALLBACK_GAP_PX = 24; + +// Default column cap when no explicit `columns` or `minColumnWidth` is +// supplied. Layered semantics: `columns` acts as a cap and +// `minColumnWidth` as a per-tile floor; if neither is set we still +// need a finite count to render against. +const DEFAULT_COLUMNS = 6; + +// Reorder is driven by `temporaryLayout` + CSS Grid, not by dnd-kit +// transforms. Hoist the no-op strategy outside the component so its +// reference is stable across renders — passing a fresh `() => null` +// to `` updates its context value and triggers all +// `useSortable` subscribers to re-render every frame. +const NO_SORT_STRATEGY = () => null; + +/** + * 2D packed dashboard grid with drag-to-reorder and resize handles. + * Supports fixed-column and responsive modes, `number | 'fill' | 'full'` + * widths, and multi-row tiles. + * + * Each child's `key` must match an entry in the `layout` array; + * children without a match render at the end of the grid without + * explicit placement and fall through CSS Grid's auto-flow. + * + * @example + * ```jsx + * const layout = [ + * { key: 'a', width: 2 }, + * { key: 'b', width: 'fill' }, + * { key: 'c', width: 'full' }, + * ]; + * + * + *
A
+ *
B
+ *
C
+ *
+ * ``` + * + * @param props - Component props. + * @param ref - Forwarded to the grid's root `
`. + */ +export const DashboardGrid = forwardRef< HTMLDivElement, DashboardGridProps >( + function DashboardGrid( props, ref ) { + const { + layout, + columns, + children, + className, + style, + rowHeight = 'auto', + minColumnWidth, + editMode = false, + onChangeLayout, + onPreviewLayout, + renderResizeHandle, + renderDragPreview, + renderGridOverlay, + ...divProps + } = props; + // Preview layout applied during drag/resize before committing. + const [ temporaryLayout, setTemporaryLayout ] = useState< + DashboardGridLayoutItem[] | undefined + >(); + // Drives `` content while a drag is in progress. + const [ activeId, setActiveId ] = useState< string | null >( null ); + // True while any tile is being resized. Combined with `activeId`, + // it drives the grid-wide `inert` flag on actionable areas so + // hovering over another tile's buttons can't steal the gesture. + const [ isResizing, setIsResizing ] = useState( false ); + // Snapped span in pixels for the resize-preview outline on the + // active tile. The tile content follows the cursor continuously; + // this preview shows the grid size that will commit on release. + const [ resizeSnapPreview, setResizeSnapPreview ] = useState< { + id: string; + snap: ResizeSnapSize; + } | null >( null ); + // Mirror of `temporaryLayout` read synchronously on drag end — + // the state update from `handleDragMove` may still be batched. + const latestLayoutRef = useRef< DashboardGridLayoutItem[] | undefined >(); + // Cursor center at the last applied reorder. Used to skip the + // cascade of re-measured `onDragMove` events after a layout + // change, when the cursor has not actually moved. + const lastReorderCursorRef = useRef< { + x: number; + y: number; + } | null >( null ); + // Width/height snapshot at the start of a resize session. The + // resize handle reports `delta` absolute from the gesture start, + // so the baseline must stay frozen — reading from the already + // mutated `activeLayout` would compound the delta each frame. + const resizeBaselineRef = useRef< { + width: number; + height: number; + } | null >( null ); + const captureLayoutSnapshotRef = useRef< () => void >( () => {} ); + const childrenCacheRef = useRef< Map< string, React.ReactElement > >( new Map() ); + const activeLayout = temporaryLayout ?? layout; + + const [ gridRoot, setGridRoot ] = useState< HTMLDivElement | null >( null ); + const [ containerWidth, setContainerWidth ] = useState( 0 ); + const [ containerHeight, setContainerHeight ] = useState( 0 ); + const [ gapPx, setGapPx ] = useState( FALLBACK_GAP_PX ); + const resizeObserverRef = useResizeObserver( ( [ { contentRect } ] ) => { + setContainerWidth( contentRect.width ); + setContainerHeight( contentRect.height ); + } ); + const mergedGridRef = useMergeRefs( [ setGridRoot, resizeObserverRef, ref ] ); + + // Measure before paint to avoid a single-column flash in + // responsive mode; `useResizeObserver` delivers async. The + // computed `column-gap` is read from the resolved CSS so the + // math tracks the design-system token under any density. + useLayoutEffect( () => { + if ( ! gridRoot ) { + return; + } + const { width, height } = gridRoot.getBoundingClientRect(); + if ( width > 0 ) { + setContainerWidth( width ); + } + if ( height > 0 ) { + setContainerHeight( height ); + } + const parsed = Number.parseFloat( window.getComputedStyle( gridRoot ).columnGap ); + if ( Number.isFinite( parsed ) && parsed > 0 ) { + setGapPx( parsed ); + } + }, [ gridRoot ] ); + const effectiveColumns = useMemo( () => { + if ( ! minColumnWidth ) { + return columns ?? DEFAULT_COLUMNS; + } + + const totalWidthPerColumn = minColumnWidth + gapPx; + const maxFit = Math.max( 1, Math.floor( ( containerWidth + gapPx ) / totalWidthPerColumn ) ); + return columns !== undefined ? Math.min( columns, maxFit ) : maxFit; + }, [ minColumnWidth, gapPx, containerWidth, columns ] ); + const columnWidth = ( containerWidth - ( effectiveColumns - 1 ) * gapPx ) / effectiveColumns; + const minResizeWidthPx = gridSpanToPixelSize( 1, 1, columnWidth, gapPx, null ).widthPx; + const rowHeightPx = typeof rowHeight === 'number' ? rowHeight : null; + const minResizeHeightPx = + rowHeightPx === null + ? undefined + : gridSpanToPixelSize( 1, 1, columnWidth, gapPx, rowHeightPx ).heightPx ?? undefined; + + const layoutMap = useMemo( () => { + const map = new Map< string, DashboardGridLayoutItem >(); + activeLayout.forEach( item => map.set( item.key, item ) ); + return map; + }, [ activeLayout ] ); + + // Stable-identity key set, preserved across renders whenever the + // *contents* of the key set are unchanged — even if the consumer + // passes a fresh `layout` array reference (common when `layout` + // is derived inline from state). Without this, downstream memos + // would invalidate on every parent re-render and the children + // walk skip during gestures wouldn't hold. + const layoutKeys = useMemo( () => new Set( layout.map( item => item.key ) ), [ layout ] ); + + // Sorted item keys, identity-stable when the resulting sequence is + // unchanged. Avoids producing a fresh `items` array on every parent + // re-render so `` doesn't update its context value + // and notify every `useSortable` subscriber unnecessarily. + const sortedItems = useMemo( + () => + activeLayout + .map( ( item, index ) => ( { item, index } ) ) + .sort( ( a, b ) => ( a.item.order ?? a.index ) - ( b.item.order ?? b.index ) ) + .map( ( { item } ) => item.key ), + [ activeLayout ] + ); + const items = sortedItems; + + // Resolve `width: 'fill'` items to concrete column spans. + const resolvedItemMap = useMemo( () => { + const fillWidths = resolveFillWidths( items, layoutMap, effectiveColumns ); + if ( fillWidths.size === 0 ) { + return layoutMap; + } + const map = new Map< string, DashboardGridLayoutItem >(); + for ( const [ key, item ] of layoutMap ) { + const fillW = fillWidths.get( key ); + map.set( key, fillW !== undefined ? { ...item, width: fillW } : item ); + } + return map; + }, [ items, layoutMap, effectiveColumns ] ); + + const [ childrenMap, actionableAreaMap, remaining, renderedByKey ] = useMemo( () => { + const childMap = new Map< string, React.ReactElement >(); + const actionableMap = new Map< string, React.ReactNode >(); + const rest: React.ReactNode[] = []; + const byKey = new Map< string, React.ReactElement >(); + + Children.forEach( children, child => { + if ( ! isValidElement( child ) ) { + rest.push( child ); + return; + } + + const key = child.key?.toString(); + if ( ! key ) { + rest.push( child ); + return; + } + + // Strip `actionableArea` so it does not leak to the DOM; + // the grid lifts it to a slot separately. + const { actionableArea } = child.props; + const stripped = + actionableArea !== undefined + ? cloneElement( child, { + actionableArea: undefined, + } ) + : child; + + byKey.set( key, stripped ); + + if ( layoutKeys.has( key ) ) { + if ( actionableArea !== undefined ) { + actionableMap.set( key, actionableArea ); + } + childMap.set( key, stripped ); + } else { + rest.push( child ); + } + } ); + + return [ childMap, actionableMap, rest, byKey ]; + }, [ children, layoutKeys ] ); + + // Persist the latest rendered children so a removed tile's content + // is still available for its exit overlay. Filled from an effect so a + // discarded render never writes to the cache. + useLayoutEffect( () => { + for ( const [ key, child ] of renderedByKey ) { + childrenCacheRef.current.set( key, child ); + } + }, [ renderedByKey ] ); + + const sensors = useSensors( + useSensor( PointerSensor ), + useSensor( KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + } ) + ); + + const handleDragStart = useEvent( ( event: DragStartEvent ) => { + setActiveId( String( event.active.id ) ); + lastReorderCursorRef.current = null; + } ); + + const handleDragCancel = useEvent( () => { + setActiveId( null ); + latestLayoutRef.current = undefined; + lastReorderCursorRef.current = null; + resizeBaselineRef.current = null; + setIsResizing( false ); + setResizeSnapPreview( null ); + setTemporaryLayout( undefined ); + } ); + + // Re-evaluate the insertion slot on every pointer move, not + // just when `over.id` changes — otherwise a "swap back" with + // the cursor still on the same tile would never fire. + const handleDragMove = useEvent( ( event: DragMoveEvent ) => { + const { active, over } = event; + if ( ! over || active.id === over.id ) { + return; + } + + const activeRect = active.rect.current.translated; + if ( ! activeRect ) { + return; + } + + const activeCenterX = activeRect.left + activeRect.width / 2; + const activeCenterY = activeRect.top + activeRect.height / 2; + + // Skip re-measured events after a layout change: require + // meaningful cursor movement between reorders. + const lastCursor = lastReorderCursorRef.current; + if ( lastCursor ) { + const dx = activeCenterX - lastCursor.x; + const dy = activeCenterY - lastCursor.y; + if ( dx * dx + dy * dy < 100 ) { + return; + } + } + + const overCenterX = over.rect.left + over.rect.width / 2; + const insertAfter = activeCenterX > overCenterX; + + const currentIndex = items.indexOf( String( active.id ) ); + const overIndex = items.indexOf( String( over.id ) ); + let newIndex: number; + if ( insertAfter ) { + newIndex = currentIndex > overIndex ? overIndex + 1 : overIndex; + } else { + newIndex = currentIndex > overIndex ? overIndex : overIndex - 1; + } + newIndex = Math.max( 0, Math.min( newIndex, items.length - 1 ) ); + + if ( newIndex === currentIndex ) { + return; + } + + const updatedItems = arrayMove( items, currentIndex, newIndex ); + const updatedLayout = activeLayout.map( item => ( { + ...item, + order: updatedItems.indexOf( item.key ), + } ) ); + + lastReorderCursorRef.current = { + x: activeCenterX, + y: activeCenterY, + }; + latestLayoutRef.current = updatedLayout; + captureLayoutSnapshotRef.current(); + setTemporaryLayout( updatedLayout ); + onPreviewLayout?.( updatedLayout ); + } ); + + // Commit the latest temporary layout and clear local state. + // Reads from the ref to bypass React's state batching. + const persistTemporaryLayout = useEvent( () => { + const latest = latestLayoutRef.current; + latestLayoutRef.current = undefined; + resizeBaselineRef.current = null; + setIsResizing( false ); + setResizeSnapPreview( null ); + if ( ! onChangeLayout || ! latest ) { + setTemporaryLayout( undefined ); + return; + } + + onChangeLayout( latest ); + setTemporaryLayout( undefined ); + } ); + + const handleResize = useEvent( ( id: string, delta: ResizeDelta ) => { + if ( ! editMode ) { + return; + } + + if ( ! isResizing ) { + setIsResizing( true ); + } + + const relativeDelta = { + width: Math.round( delta.width / ( columnWidth + gapPx ) ), + height: rowHeight === 'auto' ? 0 : Math.round( delta.height / ( rowHeight + gapPx ) ), + }; + + // Snapshot the baseline once at gesture start. The handle's + // `delta` is absolute from the gesture start, so summing it + // with the live (already mutated) `activeLayout` width would + // compound and oscillate — and stepping back through the + // zero-delta zone would never restore the original size. + if ( ! resizeBaselineRef.current ) { + const baseItem = activeLayout.find( item => item.key === id ); + const resolvedItem = resolvedItemMap.get( id ); + // `'fill'`/`'full'` resize from the rendered span + // and convert to a numeric width. + let baseWidth: number; + if ( baseItem?.width === 'full' ) { + baseWidth = effectiveColumns; + } else if ( baseItem?.width === 'fill' ) { + baseWidth = typeof resolvedItem?.width === 'number' ? resolvedItem.width : 1; + } else { + baseWidth = baseItem?.width ?? 1; + } + resizeBaselineRef.current = { + width: baseWidth, + height: baseItem?.height ?? 1, + }; + } + const baseline = resizeBaselineRef.current; + const newWidth = Math.max( + 1, + Math.min( baseline.width + relativeDelta.width, effectiveColumns ) + ); + const newHeight = Math.max( 1, baseline.height + relativeDelta.height ); + + setResizeSnapPreview( { + id, + snap: gridSpanToPixelSize( newWidth, newHeight, columnWidth, gapPx, rowHeightPx ), + } ); + + // Bail when the snapped size matches the layout already + // staged for commit. The tile still tracks the cursor + // continuously; only the preview outline and pending commit + // need updating when the snap target changes. + const pendingItem = latestLayoutRef.current?.find( item => item.key === id ); + const currentItem = pendingItem ?? activeLayout.find( item => item.key === id ); + if ( + currentItem && + currentItem.width === newWidth && + ( currentItem.height ?? 1 ) === newHeight + ) { + return; + } + + const updatedLayout = activeLayout.map( item => + item.key === id ? { ...item, width: newWidth, height: newHeight } : item + ); + + latestLayoutRef.current = updatedLayout; + captureLayoutSnapshotRef.current(); + setTemporaryLayout( updatedLayout ); + onPreviewLayout?.( updatedLayout ); + } ); + + // Drag-overlay clone composition: the surface always wraps with a + // thin functional frame (lift, cursor, pointer pass-through). When + // `renderDragPreview` is supplied, the consumer's wrapper sits + // inside the frame around the cloned children; otherwise the + // cloned children render directly so any persistent chrome on + // them carries through unchanged. + const activeClone = activeId ? childrenMap.get( activeId ) : null; + const DragPreview = renderDragPreview; + const dragOverlayContent = + activeId && activeClone ? ( +
+
+ { DragPreview ? ( + { activeClone } + ) : ( + activeClone + ) } +
+
+ ) : null; + + // Edit-mode background visual. Default paints row-marker tiles + // per column; a consumer can replace it via `renderGridOverlay` + // while reusing the resolved column count, row height, and row + // count. `'auto'` collapses to `undefined` for the overlay so + // row markers are omitted when the row height is content-driven. + // Rendered unconditionally so the overlay can cross-fade on + // edit-mode toggles; `isActive` drives the opacity transition + // inside the overlay. Memoized so drag/resize re-renders skip + // reconciliation while inputs are stable. + const Overlay = renderGridOverlay ?? GridOverlay; + const overlayRowHeight = typeof rowHeight === 'number' ? rowHeight : undefined; + const overlayRows = useMemo( () => { + if ( overlayRowHeight === undefined || containerHeight <= 0 ) { + return undefined; + } + const rowTile = overlayRowHeight + gapPx; + return Math.max( 1, Math.floor( ( containerHeight + gapPx ) / rowTile ) ); + }, [ overlayRowHeight, containerHeight, gapPx ] ); + const gridOverlay = useMemo( + () => ( + + ), + [ Overlay, editMode, effectiveColumns, overlayRowHeight, overlayRows ] + ); + + const layoutFingerprint = useMemo( + () => getLayoutFingerprint( [ ...resolvedItemMap.values() ] ), + [ resolvedItemMap ] + ); + const excludeLayoutAnimationKey = activeId ?? ( isResizing ? resizeSnapPreview?.id : null ); + const { captureLayoutSnapshot, getPositionsBeforeLastChange } = useLayoutShiftAnimation( { + container: gridRoot, + enabled: editMode, + layoutFingerprint, + excludeItemKey: excludeLayoutAnimationKey, + } ); + const { exitingItems, clearExitingItem } = useItemExitAnimation( { + container: gridRoot, + enabled: editMode, + layoutKeys, + getPositionsBeforeLastChange, + childrenCacheRef, + } ); + // Transform transitions on tiles for FLIP (drag, resize, removal). + const layoutAnimating = editMode; + useLayoutEffect( () => { + captureLayoutSnapshotRef.current = captureLayoutSnapshot; + }, [ captureLayoutSnapshot ] ); + + return ( + { + persistTemporaryLayout(); + lastReorderCursorRef.current = null; + setActiveId( null ); + } } + > + { /* No-op strategy: reorder comes from `temporaryLayout` + + CSS Grid, not dnd-kit transforms. */ } + +
+ { gridOverlay } + { items.map( id => ( + + { childrenMap.get( id ) } + + ) ) } + { remaining } + { exitingItems.map( ( { key, rect, child } ) => ( + clearExitingItem( key ) } + > + { child } + + ) ) } +
+
+ + { dragOverlayContent } + +
+ ); + } +); diff --git a/projects/js-packages/grid/src/dashboard-grid/resolve-fill-widths.ts b/projects/js-packages/grid/src/dashboard-grid/resolve-fill-widths.ts new file mode 100644 index 000000000000..cbab5d8a222d --- /dev/null +++ b/projects/js-packages/grid/src/dashboard-grid/resolve-fill-widths.ts @@ -0,0 +1,210 @@ +/** + * Internal dependencies + */ +import type { DashboardGridLayoutItem } from './types'; + +/** + * Resolves items with `width: 'fill'` by computing how many columns they + * should span. Simulates CSS Grid's row-sparse auto-flow placement so the + * resolved span matches the free run that CSS Grid will actually use. + * + * Two paths: + * - Fast path (no `height > 1` items): single-row column tracker, O(n). + * Each fixed item between two fills is visited at most once by a fill's + * look-ahead. + * - Multi-row path (any item with `height > 1`): per-column skyline that + * tracks shadow occupation of tall tiles. Placement scans rows in + * row-major order, so worst-case cost depends on both columns and rows + * scanned (rows bounded by the sum of item heights), not only on + * `maxColumns`. + * + * @param sortedKeys - Item keys in display order. + * @param layoutMap - Map of key to DashboardGridLayoutItem. + * @param maxColumns - Total columns in the grid. + * @return Map of fill item keys to their resolved column spans. + */ +export function resolveFillWidths( + sortedKeys: string[], + layoutMap: Map< string, DashboardGridLayoutItem >, + maxColumns: number +): Map< string, number > { + const resolved = new Map< string, number >(); + const n = sortedKeys.length; + + // Pre-extract items, clamp widths and heights, detect which path to take. + const items = new Array< DashboardGridLayoutItem | undefined >( n ); + const widths = new Array< number >( n ); + const heights = new Array< number >( n ); + let hasFill = false; + let hasMultiRow = false; + let totalRows = 0; + + for ( let i = 0; i < n; i++ ) { + const item = layoutMap.get( sortedKeys[ i ] ); + items[ i ] = item; + widths[ i ] = item && typeof item.width === 'number' ? Math.min( item.width, maxColumns ) : 1; + // Clamp to a positive integer so `0`, fractional, or negative + // values match the `|| 1` defaulting used in GridItem styles. + const h = Math.max( 1, Math.floor( item?.height ?? 1 ) ); + heights[ i ] = h; + if ( item?.width === 'fill' ) { + hasFill = true; + } + if ( h > 1 ) { + hasMultiRow = true; + } + totalRows += h; + } + + if ( ! hasFill ) { + return resolved; + } + + if ( ! hasMultiRow ) { + let currentCol = 0; + + for ( let i = 0; i < n; i++ ) { + const item = items[ i ]; + if ( ! item ) { + continue; + } + + if ( item.width === 'full' ) { + currentCol = 0; + continue; + } + + if ( item.width === 'fill' ) { + let reserved = 0; + for ( let j = i + 1; j < n; j++ ) { + const next = items[ j ]; + if ( ! next || next.width === 'full' || next.width === 'fill' ) { + break; + } + const nextW = widths[ j ]; + if ( currentCol + 1 + reserved + nextW <= maxColumns ) { + reserved += nextW; + } else { + break; + } + } + const fillCols = Math.max( 1, maxColumns - currentCol - reserved ); + resolved.set( item.key, fillCols ); + currentCol += fillCols; + } else { + const w = widths[ i ]; + if ( currentCol + w > maxColumns ) { + currentCol = 0; + } + currentCol += w; + } + + if ( currentCol >= maxColumns ) { + currentCol = 0; + } + } + + return resolved; + } + + // `rowOccupancy[ col ]` is the index of the next free row at that + // column; rows below it are taken by previously placed items. This + // captures the "shadow" of tall tiles into the rows they span. + const rowOccupancy = new Array< number >( maxColumns ).fill( 0 ); + let cursorRow = 0; + let cursorCol = 0; + + for ( let i = 0; i < n; i++ ) { + const item = items[ i ]; + if ( ! item ) { + continue; + } + + const h = heights[ i ]; + + if ( item.width === 'full' ) { + let r = cursorRow; + for ( let c = 0; c < maxColumns; c++ ) { + if ( rowOccupancy[ c ] > r ) { + r = rowOccupancy[ c ]; + } + } + for ( let c = 0; c < maxColumns; c++ ) { + rowOccupancy[ c ] = r + h; + } + cursorRow = r + h; + cursorCol = 0; + continue; + } + + if ( item.width === 'fill' ) { + let r = cursorRow; + let c = cursorCol; + scan: for ( ; r <= totalRows; r++ ) { + const start = r === cursorRow ? cursorCol : 0; + for ( c = start; c < maxColumns; c++ ) { + if ( rowOccupancy[ c ] <= r ) { + break scan; + } + } + } + const fillStartRow = r; + const fillStartCol = c; + let runLength = 0; + while ( + fillStartCol + runLength < maxColumns && + rowOccupancy[ fillStartCol + runLength ] <= fillStartRow + ) { + runLength++; + } + let reserved = 0; + for ( let j = i + 1; j < n; j++ ) { + const next = items[ j ]; + if ( ! next || next.width === 'full' || next.width === 'fill' ) { + break; + } + const nextW = widths[ j ]; + if ( 1 + reserved + nextW <= runLength ) { + reserved += nextW; + } else { + break; + } + } + const fillCols = Math.max( 1, runLength - reserved ); + resolved.set( item.key, fillCols ); + for ( let k = 0; k < fillCols; k++ ) { + rowOccupancy[ fillStartCol + k ] = fillStartRow + h; + } + cursorRow = fillStartRow; + cursorCol = fillStartCol + fillCols; + continue; + } + + const w = widths[ i ]; + let r = cursorRow; + let c = cursorCol; + place: for ( ; r <= totalRows; r++ ) { + c = r === cursorRow ? cursorCol : 0; + while ( c + w <= maxColumns ) { + let blocked = -1; + for ( let k = 0; k < w; k++ ) { + if ( rowOccupancy[ c + k ] > r ) { + blocked = c + k; + break; + } + } + if ( blocked === -1 ) { + break place; + } + c = blocked + 1; + } + } + for ( let k = 0; k < w; k++ ) { + rowOccupancy[ c + k ] = r + h; + } + cursorRow = r; + cursorCol = c + w; + } + + return resolved; +} diff --git a/projects/js-packages/grid/src/dashboard-grid/types.ts b/projects/js-packages/grid/src/dashboard-grid/types.ts new file mode 100644 index 000000000000..690577214250 --- /dev/null +++ b/projects/js-packages/grid/src/dashboard-grid/types.ts @@ -0,0 +1,272 @@ +/** + * Internal dependencies + */ +import type { ResizeSnapSize } from '../shared/resize-snap'; +import type { + DragPreviewRenderProps, + GridOverlayRenderProps, + ResizeDelta, + ResizeHandleRenderProps, +} from '../shared/types'; + +/** + * Dashboard grid layout item definition. + * + * `width` accepts either a numeric column span or a discriminated string: + * - `number` spans that many columns (clamped to the grid's column count). + * - `'fill'` spans the remaining columns in the current row. + * - `'full'` spans all columns (`grid-column: 1 / -1`). + */ +export type DashboardGridLayoutItem = { + /** + * Unique key that matches a child component key. + */ + key: string; + + /** + * Number of columns this item spans, or a string discriminator + * (`'fill'` or `'full'`). + */ + width?: number | 'fill' | 'full'; + + /** + * Number of rows this item spans. + * + * @default 1 + */ + height?: number; + + /** + * Display order for the item. Lower values render first. When + * omitted, the item falls back to its index in the `layout` array. + */ + order?: number; +}; + +/** + * Props for the internal `` wrapper. + */ +export type GridItemProps = { + /** + * The layout item containing grid positioning information. + */ + item: DashboardGridLayoutItem; + + /** + * The maximum number of columns in the grid. + */ + maxColumns: number; + + /** + * Whether drag and resize interactions are disabled. + * + * @default false + */ + disabled?: boolean; + + /** + * Whether the item can be resized vertically. Disabled when the + * grid uses `rowHeight: 'auto'`, where row height is driven by + * content rather than by the user. + * + * @default true + */ + verticalResizable?: boolean; + + /** + * Whether any tile in the grid is currently being dragged or + * resized. Drives the drag activator cursor. + * + * @default false + */ + interacting?: boolean; + + /** + * Whether a tile drag is in progress. Mutes each tile's + * `actionableArea` with `inert` so hovers on other tiles' controls + * do not steal the gesture. + * + * @default false + */ + dragging?: boolean; + + /** + * The content to be displayed within the grid item. + */ + children: React.ReactNode; + + /** + * Content rendered above the draggable area that stays interactive + * in edit mode, typically action buttons, menus, or links. While + * a tile drag is in progress, this content is set `inert` so hovers + * on other tiles can't steal the gesture. During resize, visibility + * is controlled by grid-level CSS hooks. + */ + actionableArea?: React.ReactNode; + + /** + * Callback fired while the item is being resized. Receives the + * item's `key` plus the cursor offset from the gesture start in + * pixels. The grid derives snapped spans from the delta and passes + * them back through `resizeSnapPreview`. + */ + onResize: ( id: string, delta: ResizeDelta ) => void; + + /** + * Snapped grid size in pixels for the resize-preview outline. The + * tile content resizes continuously with the cursor; this outline + * shows the span the layout will commit to on release. + */ + resizeSnapPreview?: ResizeSnapSize | null; + + /** + * Minimum tile width while resizing, in pixels (one column track). + */ + minResizeWidthPx: number; + + /** + * Minimum tile height while resizing, in pixels (one row track). + * Omitted when vertical resize is disabled. + */ + minResizeHeightPx?: number; + + /** + * Callback fired when the resize gesture ends. + */ + onResizeEnd: () => void; + + /** + * Component forwarded to `` to override the default + * corner triangle. See `DashboardGridProps.renderResizeHandle`. + */ + renderResizeHandle?: React.ComponentType< ResizeHandleRenderProps >; +}; + +/** + * Props for `DashboardGrid`. Extends the standard div props so consumers + * can pass `id`, `aria-*`, `data-*`, event handlers, etc., directly on + * the grid root. + * + * `columns` and `minColumnWidth` compose as a layered model: + * - `columns` alone: fixed N columns; each tile scales with the container. + * - `minColumnWidth` alone: column count derives from container width, + * floored by the per-tile minimum, down to 1 column. + * - Both together: `columns` caps the count, `minColumnWidth` enforces a + * per-tile width floor that can reduce the count below the cap on + * narrow containers ("up to N columns, but never narrower than W px"). + */ +export interface DashboardGridProps + extends Omit< React.ComponentPropsWithoutRef< 'div' >, 'children' | 'className' | 'style' > { + /** + * Array of layout items. + */ + layout: DashboardGridLayoutItem[]; + + /** + * Grid children. Each child must carry a `key` that matches an + * entry in `layout`; children without a match render at the end + * of the grid without explicit placement and fall through CSS + * Grid's auto-flow. + */ + children: React.ReactNode; + + /** + * Additional CSS class on the grid root. + */ + className?: string; + + /** + * Inline styles applied to the grid root. Merged underneath the + * grid's own layout styles, so the layout (`gridTemplateColumns`, + * `gridAutoRows`) always wins. The gap between tiles is owned by + * the design-system gap token and is not configurable per + * instance; override it via a theme or density change. + */ + style?: React.CSSProperties; + + /** + * Height of each row in pixels, or `'auto'` to let the tallest + * tile in the row size it. + * + * @default 'auto' + */ + rowHeight?: number | 'auto'; + + /** + * Whether the grid is in edit mode (allows dragging and + * repositioning items). + * + * @default false + */ + editMode?: boolean; + + /** + * Callback fired when the user commits a drag or resize. Receives + * the resulting layout. + */ + onChangeLayout?: ( newLayout: DashboardGridLayoutItem[] ) => void; + + /** + * Callback fired continuously during a drag or resize interaction + * with the in-progress layout. Useful for live feedback in the + * surface (e.g., displaying the current width/position). The final + * committed layout is still emitted via `onChangeLayout`. + */ + onPreviewLayout?: ( previewLayout: DashboardGridLayoutItem[] ) => void; + + /** + * Override the default corner-triangle resize handle with a custom + * component. The grid still owns the gesture (dnd-kit ``, + * throttled delta loop) and passes the wiring to the consumer: + * spread `listeners` and `attributes` and assign `ref` on the + * element that should receive the gesture. Use `disabled` and + * `verticalResizable` to adapt the visual to context. + */ + renderResizeHandle?: React.ComponentType< ResizeHandleRenderProps >; + + /** + * through) and mounts this component inside it; the consumer + * owns the visual chrome (shadow, radius, padding). + * + * When omitted, the cloned children render directly inside the + * functional frame so any chrome the consumer applied to the + * persistent tile carries through unchanged. + * + * Token-only adjustments (lift scale, placeholder opacity, + * outline color, placeholder radius) flow through CSS custom + * properties documented in the README. + */ + renderDragPreview?: React.ComponentType< DragPreviewRenderProps >; + + /** + * Override the default edit-mode overlay (row-marker tiles per + * column) with a custom component. The grid supplies the resolved + * column count, row height, and row count; the consumer is + * responsible for the visual. + * + * The overlay only renders when `editMode` is true. When omitted, + * the package's default visual is used. + */ + renderGridOverlay?: React.ComponentType< GridOverlayRenderProps >; + + /** + * Target column count (cap). When set alone, the grid renders this + * many columns and tiles scale with the container. + * + * Composes with `minColumnWidth`: if both are set, the effective + * column count is `min( columns, fitsAtMinWidth )`. When omitted + * but `minColumnWidth` is set, the count is uncapped and derives + * purely from the container width. When both are omitted, the + * grid renders six columns. + */ + columns?: number; + + /** + * Per-tile minimum width in pixels. The effective column count is + * derived from container width, floored by this value, down to 1. + * + * Composes with `columns`: when both are set, this acts as a floor + * that can reduce the count below `columns` on narrow containers. + */ + minColumnWidth?: number; +} diff --git a/projects/js-packages/grid/src/dashboard-lanes/index.tsx b/projects/js-packages/grid/src/dashboard-lanes/index.tsx new file mode 100644 index 000000000000..818de1a8b5f9 --- /dev/null +++ b/projects/js-packages/grid/src/dashboard-lanes/index.tsx @@ -0,0 +1,561 @@ +/** + * External dependencies + */ +import { + DndContext, + DragOverlay, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, +} from '@dnd-kit/core'; +import { arrayMove, SortableContext, sortableKeyboardCoordinates } from '@dnd-kit/sortable'; +import { useResizeObserver, useEvent, useMergeRefs } from '@wordpress/compose'; +import { + forwardRef, + useMemo, + Children, + cloneElement, + isValidElement, + useLayoutEffect, + useRef, + useState, +} from '@wordpress/element'; +import clsx from 'clsx'; + +/** + * WordPress dependencies + */ + +/** + * Internal dependencies + */ +import { createDashboardDragDropAnimation } from '../shared/drag-overlay-drop-animation'; +import { GridOverlay } from '../shared/grid-overlay'; +import { ItemExitOverlay } from '../shared/item-exit-overlay'; +import layoutAnimationStyles from '../shared/layout-shift-animation.module.css'; +import { gridSpanToPixelSize } from '../shared/resize-snap'; +import { useItemExitAnimation } from '../shared/use-item-exit-animation'; +import { + getLayoutFingerprint, + getPlacementFingerprint, + useLayoutShiftAnimation, +} from '../shared/use-layout-shift-animation'; +import { LanesItem } from './lanes-item'; +import styles from './lanes.module.css'; +import { useLanePlacement } from './use-lane-placement'; +import type { DashboardLanesLayoutItem, DashboardLanesProps } from './types'; +import type { ResizeSnapSize } from '../shared/resize-snap'; +import type { ResizeDelta } from '../shared/types'; +import type { DragMoveEvent, DragStartEvent } from '@dnd-kit/core'; + +const dashboardDragDropAnimation = createDashboardDragDropAnimation( + styles[ 'drag-preview-frame' ], + styles.dragPreviewFrameExiting +); + +// Fallback gap in pixels for math that runs before the computed gap +// can be read from the DOM. Matches the `'xl'` step the surface +// resolves to in CSS (`--wpds-dimension-gap-xl`); the next layout +// effect overwrites this with the actual computed value. +const FALLBACK_GAP_PX = 24; + +// Default lane cap when no explicit `columns` or `minColumnWidth` is +// supplied. Layered semantics: `columns` acts as a cap and +// `minColumnWidth` as a per-tile floor; if neither is set we still +// need a finite count to render against. +const DEFAULT_COLUMNS = 6; + +const NO_SORT_STRATEGY = () => null; + +/** + * Masonry-style surface aligned with `display: grid-lanes`. Items + * declare a column span; heights are driven by content; placement + * follows the source-ordered, shortest-lane algorithm with + * `flow-tolerance` tiebreaking. + * + * On browsers that support `display: grid-lanes` natively, the + * component emits the spec's CSS and lets the engine handle layout. + * Otherwise, `useLanePlacement` measures item heights and assigns + * explicit `grid-column-start` / `grid-row-start` values that + * approximate the same result inside CSS Grid. + * + * Each child's `key` must match an entry in the `layout` array; + * children without a match render at the end of the surface without + * explicit placement and fall through the lanes auto-flow. + * + * @example + * ```jsx + * + * A + * B + * C + * + * ``` + * + * @param props - Component props. + * @param ref - Forwarded to the surface's root `
`. + */ +export const DashboardLanes = forwardRef< HTMLDivElement, DashboardLanesProps >( + function DashboardLanes( props, ref ) { + const { + layout, + columns, + children, + className, + style, + flowTolerance = 16, + rowUnit = 4, + minColumnWidth, + editMode = false, + onChangeLayout, + onPreviewLayout, + renderResizeHandle, + renderDragPreview, + renderGridOverlay, + ...divProps + } = props; + + const [ temporaryLayout, setTemporaryLayout ] = useState< + DashboardLanesLayoutItem[] | undefined + >(); + const [ activeId, setActiveId ] = useState< string | null >( null ); + const [ isResizing, setIsResizing ] = useState( false ); + const [ resizeSnapPreview, setResizeSnapPreview ] = useState< { + id: string; + snap: ResizeSnapSize; + } | null >( null ); + const latestLayoutRef = useRef< DashboardLanesLayoutItem[] | undefined >(); + const lastReorderCursorRef = useRef< { + x: number; + y: number; + } | null >( null ); + const resizeBaselineRef = useRef< number | null >( null ); + const captureLayoutSnapshotRef = useRef< () => void >( () => {} ); + const childrenCacheRef = useRef< Map< string, React.ReactElement > >( new Map() ); + const activeLayout = temporaryLayout ?? layout; + + const [ container, setContainer ] = useState< HTMLDivElement | null >( null ); + const [ containerWidth, setContainerWidth ] = useState( 0 ); + const [ gapPx, setGapPx ] = useState( FALLBACK_GAP_PX ); + const resizeObserverRef = useResizeObserver( ( [ { contentRect } ] ) => { + setContainerWidth( contentRect.width ); + } ); + const mergedRootRef = useMergeRefs( [ setContainer, resizeObserverRef, ref ] ); + + // Measure synchronously before paint and snapshot the computed + // `column-gap` so the placement math tracks the design-system + // token under any density. + useLayoutEffect( () => { + if ( ! container ) { + return; + } + const { width } = container.getBoundingClientRect(); + if ( width > 0 ) { + setContainerWidth( width ); + } + const parsed = Number.parseFloat( window.getComputedStyle( container ).columnGap ); + if ( Number.isFinite( parsed ) && parsed > 0 ) { + setGapPx( parsed ); + } + }, [ container ] ); + const effectiveColumns = useMemo( () => { + if ( ! minColumnWidth ) { + return columns ?? DEFAULT_COLUMNS; + } + const totalWidthPerColumn = minColumnWidth + gapPx; + const maxFit = Math.max( 1, Math.floor( ( containerWidth + gapPx ) / totalWidthPerColumn ) ); + return columns !== undefined ? Math.min( columns, maxFit ) : maxFit; + }, [ minColumnWidth, gapPx, containerWidth, columns ] ); + const columnWidth = ( containerWidth - ( effectiveColumns - 1 ) * gapPx ) / effectiveColumns; + const minResizeWidthPx = gridSpanToPixelSize( 1, 1, columnWidth, gapPx, null ).widthPx; + + const layoutMap = useMemo( () => { + const map = new Map< string, DashboardLanesLayoutItem >(); + activeLayout.forEach( item => map.set( item.key, item ) ); + return map; + }, [ activeLayout ] ); + + // Stable-identity key set for the children walk (see grid.tsx). + const layoutKeys = useMemo( () => new Set( layout.map( item => item.key ) ), [ layout ] ); + + // Sorted item keys, identity-stable when the resulting sequence + // is unchanged (avoids invalidating SortableContext). + const sortedItems = useMemo( + () => + activeLayout + .map( ( item, index ) => ( { item, index } ) ) + .sort( ( a, b ) => ( a.item.order ?? a.index ) - ( b.item.order ?? b.index ) ) + .map( ( { item } ) => item.key ), + [ activeLayout ] + ); + const items = sortedItems; + + // Placement input for the hook: each item with its clamped span + // in source (sorted) order. `lane` forwards the optional explicit + // pin from the layout item; the algorithm clamps out-of-range + // values, so no surface-level guard is needed. + const placementItems = useMemo( () => { + return items.map( key => { + const item = layoutMap.get( key ); + const width = item?.width; + const span = + typeof width === 'number' ? Math.max( 1, Math.min( width, effectiveColumns ) ) : 1; + return { key, span, lane: item?.lane }; + } ); + }, [ items, layoutMap, effectiveColumns ] ); + + const { itemStyles } = useLanePlacement( container, { + items: placementItems, + lanes: effectiveColumns, + gap: gapPx, + flowTolerance, + rowUnit, + } ); + + const [ childrenMap, actionableAreaMap, remaining, renderedByKey ] = useMemo( () => { + const childMap = new Map< string, React.ReactElement >(); + const actionableMap = new Map< string, React.ReactNode >(); + const rest: React.ReactNode[] = []; + const byKey = new Map< string, React.ReactElement >(); + + Children.forEach( children, child => { + if ( ! isValidElement( child ) ) { + rest.push( child ); + return; + } + const key = child.key?.toString(); + if ( ! key ) { + rest.push( child ); + return; + } + + // Strip `actionableArea` so it does not leak to the DOM; + // the grid lifts it to a slot separately. + const { actionableArea } = child.props as { + actionableArea?: React.ReactNode; + }; + const stripped = + actionableArea !== undefined + ? cloneElement( + child as React.ReactElement< { + actionableArea?: React.ReactNode; + } >, + { actionableArea: undefined } + ) + : ( child as React.ReactElement ); + + byKey.set( key, stripped ); + + if ( layoutKeys.has( key ) ) { + if ( actionableArea !== undefined ) { + actionableMap.set( key, actionableArea ); + } + childMap.set( key, stripped ); + } else { + rest.push( child ); + } + } ); + + return [ childMap, actionableMap, rest, byKey ]; + }, [ children, layoutKeys ] ); + + // Persist the latest rendered children so a removed tile's content + // is still available for its exit overlay. Filled from an effect so a + // discarded render never writes to the cache. + useLayoutEffect( () => { + for ( const [ key, child ] of renderedByKey ) { + childrenCacheRef.current.set( key, child ); + } + }, [ renderedByKey ] ); + + const sensors = useSensors( + useSensor( PointerSensor ), + useSensor( KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + } ) + ); + + const handleDragStart = useEvent( ( event: DragStartEvent ) => { + setActiveId( String( event.active.id ) ); + lastReorderCursorRef.current = null; + } ); + + const handleDragCancel = useEvent( () => { + setActiveId( null ); + latestLayoutRef.current = undefined; + lastReorderCursorRef.current = null; + resizeBaselineRef.current = null; + setIsResizing( false ); + setResizeSnapPreview( null ); + setTemporaryLayout( undefined ); + } ); + + const handleDragMove = useEvent( ( event: DragMoveEvent ) => { + const { active, over } = event; + if ( ! over || active.id === over.id ) { + return; + } + const activeRect = active.rect.current.translated; + if ( ! activeRect ) { + return; + } + const activeCenterX = activeRect.left + activeRect.width / 2; + const activeCenterY = activeRect.top + activeRect.height / 2; + + const lastCursor = lastReorderCursorRef.current; + if ( lastCursor ) { + const dx = activeCenterX - lastCursor.x; + const dy = activeCenterY - lastCursor.y; + if ( dx * dx + dy * dy < 100 ) { + return; + } + } + + const overCenterX = over.rect.left + over.rect.width / 2; + const insertAfter = activeCenterX > overCenterX; + + const currentIndex = items.indexOf( String( active.id ) ); + const overIndex = items.indexOf( String( over.id ) ); + let newIndex: number; + if ( insertAfter ) { + newIndex = currentIndex > overIndex ? overIndex + 1 : overIndex; + } else { + newIndex = currentIndex > overIndex ? overIndex : overIndex - 1; + } + newIndex = Math.max( 0, Math.min( newIndex, items.length - 1 ) ); + + if ( newIndex === currentIndex ) { + return; + } + + const updatedItems = arrayMove( items, currentIndex, newIndex ); + // Build a key→index lookup so the .map below is O(n) + // instead of O(n²) from per-item `indexOf` calls. + const orderByKey = new Map< string, number >(); + updatedItems.forEach( ( key, index ) => { + orderByKey.set( key, index ); + } ); + const updatedLayout = activeLayout.map( item => ( { + ...item, + order: orderByKey.get( item.key ) ?? 0, + } ) ); + + lastReorderCursorRef.current = { + x: activeCenterX, + y: activeCenterY, + }; + latestLayoutRef.current = updatedLayout; + captureLayoutSnapshotRef.current(); + setTemporaryLayout( updatedLayout ); + onPreviewLayout?.( updatedLayout ); + } ); + + const persistTemporaryLayout = useEvent( () => { + const latest = latestLayoutRef.current; + latestLayoutRef.current = undefined; + resizeBaselineRef.current = null; + setIsResizing( false ); + setResizeSnapPreview( null ); + if ( ! onChangeLayout || ! latest ) { + setTemporaryLayout( undefined ); + return; + } + + onChangeLayout( latest ); + setTemporaryLayout( undefined ); + } ); + + const handleResize = useEvent( ( id: string, delta: ResizeDelta ) => { + if ( ! editMode ) { + return; + } + if ( ! isResizing ) { + setIsResizing( true ); + } + + const relativeDelta = Math.round( delta.width / ( columnWidth + gapPx ) ); + + if ( resizeBaselineRef.current === null ) { + const baseItem = layoutMap.get( id ); + const baseWidth = typeof baseItem?.width === 'number' ? baseItem.width : 1; + resizeBaselineRef.current = baseWidth; + } + const baseline = resizeBaselineRef.current; + const newWidth = Math.max( 1, Math.min( baseline + relativeDelta, effectiveColumns ) ); + + setResizeSnapPreview( { + id, + snap: gridSpanToPixelSize( newWidth, 1, columnWidth, gapPx, null ), + } ); + + const pendingItem = latestLayoutRef.current?.find( item => item.key === id ); + const currentItem = pendingItem ?? layoutMap.get( id ); + if ( currentItem && currentItem.width === newWidth ) { + return; + } + + const updatedLayout = activeLayout.map( item => + item.key === id ? { ...item, width: newWidth } : item + ); + + latestLayoutRef.current = updatedLayout; + captureLayoutSnapshotRef.current(); + setTemporaryLayout( updatedLayout ); + onPreviewLayout?.( updatedLayout ); + } ); + + const interacting = activeId !== null || isResizing; + + // Drag-overlay clone composition: the surface always wraps with a + // thin functional frame (lift, cursor, pointer pass-through). When + // `renderDragPreview` is supplied, the consumer's wrapper sits + // inside the frame around the cloned children; otherwise the + // cloned children render directly so any persistent chrome on + // them carries through unchanged. + const activeClone = activeId ? childrenMap.get( activeId ) : null; + const DragPreview = renderDragPreview; + const dragOverlayContent = + activeId && activeClone ? ( +
+
+ { DragPreview ? ( + { activeClone } + ) : ( + activeClone + ) } +
+
+ ) : null; + + // Edit-mode background visual. Lanes are content-driven + // vertically, so the overlay only mirrors columns; the default + // can be replaced wholesale via `renderGridOverlay`. Rendered + // unconditionally so the overlay can cross-fade on edit-mode + // toggles; `isActive` drives the opacity transition inside the + // overlay. Memoized so drag/resize re-renders skip + // reconciliation while inputs are stable. + const Overlay = renderGridOverlay ?? GridOverlay; + const gridOverlay = useMemo( + () => , + [ Overlay, editMode, effectiveColumns ] + ); + + const layoutFingerprint = useMemo( () => { + const layoutSig = getLayoutFingerprint( activeLayout ); + const placementSig = getPlacementFingerprint( itemStyles ); + return `${ layoutSig }\0${ placementSig }`; + }, [ activeLayout, itemStyles ] ); + const excludeLayoutAnimationKey = activeId ?? ( isResizing ? resizeSnapPreview?.id : null ); + const { captureLayoutSnapshot, getPositionsBeforeLastChange } = useLayoutShiftAnimation( { + container, + enabled: editMode, + layoutFingerprint, + excludeItemKey: excludeLayoutAnimationKey, + } ); + const { exitingItems, clearExitingItem } = useItemExitAnimation( { + container, + enabled: editMode, + layoutKeys, + getPositionsBeforeLastChange, + childrenCacheRef, + } ); + const layoutAnimating = editMode; + useLayoutEffect( () => { + captureLayoutSnapshotRef.current = captureLayoutSnapshot; + }, [ captureLayoutSnapshot ] ); + + return ( + { + persistTemporaryLayout(); + lastReorderCursorRef.current = null; + setActiveId( null ); + } } + > + +
+ { gridOverlay } + { items.map( id => { + const child = childrenMap.get( id ); + if ( ! child ) { + return null; + } + return ( + + { child } + + ); + } ) } + { remaining } + { exitingItems.map( ( { key, rect, child } ) => ( + clearExitingItem( key ) } + > + { child } + + ) ) } +
+
+ + { dragOverlayContent } + +
+ ); + } +); diff --git a/projects/js-packages/grid/src/dashboard-lanes/lane-placement.ts b/projects/js-packages/grid/src/dashboard-lanes/lane-placement.ts new file mode 100644 index 000000000000..dffff293bf0a --- /dev/null +++ b/projects/js-packages/grid/src/dashboard-lanes/lane-placement.ts @@ -0,0 +1,260 @@ +/** + * Lane placement algorithm for `display: grid-lanes` polyfill. + * + * Implements the source-ordered, shortest-lane placement described in + * https://webkit.org/blog/17660/introducing-css-grid-lanes/. + * + * The skyline + tolerance core is adapted from the CSS Grid Lanes + * Polyfill by Simon Willison (MIT, + * https://tools.simonwillison.net/grid-lanes-polyfill.js). The rest + * of this module is a pure function suitable for unit testing in + * isolation from any DOM. + * + * Concepts: + * - "Lane" is the cross-axis track: a column in waterfall mode, a row + * in brick mode. The algorithm is axis-agnostic; the renderer maps + * the chosen lane index plus offset to `grid-column-start` / + * `grid-row-start` (or vice versa). + * - Items are placed in source order. Explicit-lane items are placed + * first so auto-placed items can flow around them. + * - `flowTolerance` is a length: when two candidate lanes differ in + * baseline by no more than this, the earlier lane wins (preserves + * reading order). + */ + +/** + * A single item to place. Heights are pre-measured by the caller. + */ +export type LanePlacementItem = { + /** + * Stable identity. Returned in the result map. + */ + key: string; + + /** + * Number of contiguous lanes this item occupies. Clamped to + * `[ 1, lanes ]` by the algorithm. + */ + span: number; + + /** + * Measured cross-axis size (typically pixels). The algorithm only + * adds and compares heights; the unit is whatever the caller uses, + * as long as `gap` and `flowTolerance` use the same one. + */ + height: number; + + /** + * Explicit 0-indexed starting lane. When set, the item bypasses + * the skyline lookup and is placed at this lane regardless of + * source order. Out-of-range values are clamped. + */ + lane?: number; +}; + +/** + * Algorithm input. + */ +export type LanePlacementInput = { + /** + * Items in source order. + */ + items: ReadonlyArray< LanePlacementItem >; + + /** + * Total number of lanes. Clamped to `>= 1`. + */ + lanes: number; + + /** + * Gap between items in the same lane. Same unit as `height`. + */ + gap: number; + + /** + * Tolerance for source-order tiebreaking. When two candidate + * lanes have baselines within this amount, the earlier lane wins. + * Defaults to `0` if a negative value is passed. + */ + flowTolerance: number; +}; + +/** + * Resolved position for a single item. + */ +export type LanePlacement = { + /** + * Mirrors the input key. + */ + key: string; + + /** + * 0-indexed starting lane. The renderer adds 1 for + * `grid-column-start`. + */ + lane: number; + + /** + * Cross-axis offset from the container's start edge, in the same + * unit as the input heights. Use as the item's start position + * (e.g. `top`, or `grid-row-start` after dividing by a row unit). + */ + top: number; + + /** + * Effective span after clamping. Useful for the renderer when the + * input span exceeded the lane count. + */ + span: number; +}; + +/** + * Algorithm output. + */ +export type LanePlacementResult = { + /** + * Per-key placement. Insertion-ordered: the first iteration yields + * the explicit items in source order, then the auto items in + * source order. + */ + placements: Map< string, LanePlacement >; + + /** + * Sum of the tallest lane after all items are placed. The renderer + * applies this as the container's intrinsic height. + */ + totalHeight: number; +}; + +/** + * + * @param span + * @param lanes + */ +function clampSpan( span: number, lanes: number ): number { + if ( ! Number.isFinite( span ) ) { + return 1; + } + return Math.max( 1, Math.min( Math.floor( span ), lanes ) ); +} + +/** + * + * @param lane + * @param span + * @param lanes + */ +function clampLane( lane: number, span: number, lanes: number ): number { + if ( ! Number.isFinite( lane ) ) { + return 0; + } + return Math.max( 0, Math.min( Math.floor( lane ), lanes - span ) ); +} + +/** + * + * @param laneBottoms + * @param startLane + * @param span + */ +function maxBaselineAcross( + laneBottoms: ReadonlyArray< number >, + startLane: number, + span: number +): number { + let maxBaseline = 0; + for ( let i = startLane; i < startLane + span; i++ ) { + if ( laneBottoms[ i ] > maxBaseline ) { + maxBaseline = laneBottoms[ i ]; + } + } + return maxBaseline; +} + +/** + * Places all items into a fixed lane count using the grid-lanes + * algorithm: explicit items first, then auto items chosen by the + * shortest-lane skyline with a tolerance for source order. + * + * Pure: no DOM access, no mutation of inputs. Safe to call from a + * worker or during SSR. + * + * @param input - Items, lane count, gap, and tolerance. + * @return Per-key placements plus the resulting total height. + */ +export function computeLanePlacements( input: LanePlacementInput ): LanePlacementResult { + const lanes = Math.max( 1, Math.floor( input.lanes ) ); + const gap = Math.max( 0, input.gap ); + const tolerance = Math.max( 0, input.flowTolerance ); + + const laneBottoms = new Array< number >( lanes ).fill( 0 ); + const placements = new Map< string, LanePlacement >(); + + const explicitItems: LanePlacementItem[] = []; + const autoItems: LanePlacementItem[] = []; + for ( const item of input.items ) { + if ( item.lane !== undefined ) { + explicitItems.push( item ); + } else { + autoItems.push( item ); + } + } + + for ( const item of explicitItems ) { + const span = clampSpan( item.span, lanes ); + const lane = clampLane( item.lane as number, span, lanes ); + const baseline = maxBaselineAcross( laneBottoms, lane, span ); + const top = baseline === 0 ? 0 : baseline + gap; + const height = Math.max( 0, item.height ); + + placements.set( item.key, { key: item.key, lane, top, span } ); + + const newBottom = top + height; + for ( let i = lane; i < lane + span; i++ ) { + laneBottoms[ i ] = newBottom; + } + } + + for ( const item of autoItems ) { + const span = clampSpan( item.span, lanes ); + let bestLane = 0; + let bestBaseline = Infinity; + + for ( let candidate = 0; candidate <= lanes - span; candidate++ ) { + const baseline = maxBaselineAcross( laneBottoms, candidate, span ); + + // Only take a lane that is strictly shorter beyond + // tolerance. Within-tolerance ties keep the earlier lane + // because candidates iterate in lane order, so the first + // acceptable baseline wins. + if ( bestBaseline - baseline > tolerance ) { + bestBaseline = baseline; + bestLane = candidate; + } + } + + const top = bestBaseline === 0 ? 0 : bestBaseline + gap; + const height = Math.max( 0, item.height ); + + placements.set( item.key, { + key: item.key, + lane: bestLane, + top, + span, + } ); + + const newBottom = top + height; + for ( let i = bestLane; i < bestLane + span; i++ ) { + laneBottoms[ i ] = newBottom; + } + } + + let totalHeight = 0; + for ( const bottom of laneBottoms ) { + if ( bottom > totalHeight ) { + totalHeight = bottom; + } + } + + return { placements, totalHeight }; +} diff --git a/projects/js-packages/grid/src/dashboard-lanes/lanes-item.module.css b/projects/js-packages/grid/src/dashboard-lanes/lanes-item.module.css new file mode 100644 index 000000000000..a04e25f93cec --- /dev/null +++ b/projects/js-packages/grid/src/dashboard-lanes/lanes-item.module.css @@ -0,0 +1,98 @@ +.item { + position: relative; +} + +.item-content { + position: relative; + height: 100%; +} + +.is-resizing { + overflow: visible; + z-index: 1; +} + +.is-resizing .item-content { + position: relative; + z-index: 2; + overflow: visible; +} + +/* + * During drag, the original item acts as a placeholder in its lane + * while `` renders a clone that follows the cursor. + * Fading the placeholder and outlining it makes the destination visible + * without any scaling or translation on the original element. + * + * Placeholder chrome waits until `data-wp-grid-dragging` is set and the + * drag-preview enter animation (`--wpds-motion-duration-sm`) finishes + * so the dashed outline does not flash under the lifting clone. + */ +.is-dragging { + pointer-events: none; +} + +:global([data-wp-grid-dragging]) .is-dragging { + border-radius: var(--wp-grid-placeholder-radius, 0); +} + +@media not (prefers-reduced-motion: reduce) { + + :global([data-wp-grid-dragging]) .is-dragging { + opacity: 1; + outline-width: 0; + outline-style: var(--wp-grid-placeholder-outline-style, dashed); + outline-color: transparent; + animation: + wp-grid-item-placeholder-in 0ms linear + var(--wpds-motion-duration-sm) forwards; + } + + @keyframes wp-grid-item-placeholder-in { + + to { + opacity: var(--wp-grid-placeholder-opacity, 0.4); + outline-width: var(--wpds-border-width-sm); + outline-color: var(--wp-grid-placeholder-outline-color, var(--wpds-color-stroke-interactive-brand)); + } + } +} + +@media (prefers-reduced-motion: reduce) { + + :global([data-wp-grid-dragging]) .is-dragging { + opacity: var(--wp-grid-placeholder-opacity, 0.4); + outline: + var(--wpds-border-width-sm) + var(--wp-grid-placeholder-outline-style, dashed) + var(--wp-grid-placeholder-outline-color, var(--wpds-color-stroke-interactive-brand)); + } +} + +@media (forced-colors: active) { + + :global([data-wp-grid-dragging]) .is-dragging { + --wp-grid-placeholder-outline-color: Highlight; + } +} + +.preview-overlay { + position: absolute; + top: 0; + inset-inline-start: 0; + box-sizing: border-box; + pointer-events: none; + z-index: 0; + border: + var(--wpds-border-width-sm) + var(--wp-grid-resize-preview-outline-style, solid) + var(--wp-grid-placeholder-outline-color, var(--wpds-color-stroke-interactive-brand)); + background: transparent; +} + +@media (forced-colors: active) { + + .preview-overlay { + border-color: Highlight; + } +} diff --git a/projects/js-packages/grid/src/dashboard-lanes/lanes-item.tsx b/projects/js-packages/grid/src/dashboard-lanes/lanes-item.tsx new file mode 100644 index 000000000000..cfa0d26b780a --- /dev/null +++ b/projects/js-packages/grid/src/dashboard-lanes/lanes-item.tsx @@ -0,0 +1,241 @@ +/** + * External dependencies + */ +import { useSortable } from '@dnd-kit/sortable'; +import { useMergeRefs } from '@wordpress/compose'; +import { useState, useRef } from '@wordpress/element'; +import clsx from 'clsx'; + +/** + * WordPress dependencies + */ + +/** + * Internal dependencies + */ +import actionableAreaStyles from '../shared/actionable-area-slot.module.css'; +import { GRID_ITEM_DATA_KEY } from '../shared/grid-item-key'; +import ResizeHandle from '../shared/resize-handle'; +import { clampResizeDelta, type ResizeSnapSize } from '../shared/resize-snap'; +import styles from './lanes-item.module.css'; +import type { ResizeDelta, ResizeHandleRenderProps } from '../shared/types'; + +/** + * + * @param disabled + * @param interacting + */ +function getItemCursor( disabled: boolean, interacting: boolean ): React.CSSProperties[ 'cursor' ] { + if ( disabled ) { + return 'default'; + } + if ( interacting ) { + return undefined; + } + return 'grab'; +} + +/** + * Props for the internal `` wrapper. + */ +export type LanesItemProps = { + /** + * Item key. Forwarded to dnd-kit and emitted as the + * `data-wp-grid-item-key` attribute the hook reads to map measured DOM + * nodes back to logical items. + */ + itemKey: string; + + /** + * Inline placement style produced by `useLanePlacement`. On native + * (`display: grid-lanes`), only `gridColumn: span N`. While + * polyfilling, also `gridColumnStart` / `gridRowStart` / + * `gridRowEnd: span N`. + */ + placementStyle: React.CSSProperties; + + /** + * Whether drag and resize interactions are disabled. + */ + disabled?: boolean; + + /** + * Whether any tile in the surface is currently being dragged or + * resized. Drives the drag activator cursor. + */ + interacting?: boolean; + + /** + * Whether a tile drag is in progress. Mutes each tile's + * `actionableArea` with `inert` so hovers on other tiles' controls + * do not steal the gesture. + * + * @default false + */ + dragging?: boolean; + + children: React.ReactNode; + + actionableArea?: React.ReactNode; + + onResize: ( id: string, delta: ResizeDelta ) => void; + + /** + * Snapped column span in pixels for the resize-preview outline. + */ + resizeSnapPreview?: ResizeSnapSize | null; + + /** + * Minimum tile width while resizing, in pixels (one column track). + */ + minResizeWidthPx: number; + + onResizeEnd: () => void; + + renderResizeHandle?: React.ComponentType< ResizeHandleRenderProps >; +}; + +/** + * + * @param root0 + * @param root0.itemKey + * @param root0.placementStyle + * @param root0.disabled + * @param root0.interacting + * @param root0.children + * @param root0.actionableArea + * @param root0.onResize + * @param root0.onResizeEnd + * @param root0.resizeSnapPreview + * @param root0.minResizeWidthPx + * @param root0.renderResizeHandle + * @param root0.dragging + */ +export function LanesItem( { + itemKey, + placementStyle, + disabled = false, + interacting = false, + children, + actionableArea = null, + onResize, + onResizeEnd, + resizeSnapPreview = null, + minResizeWidthPx, + renderResizeHandle, + dragging = false, +}: LanesItemProps ) { + const [ resizeDelta, setResizeDelta ] = useState< ResizeDelta | null >( null ); + const [ initialContentSize, setInitialContentSize ] = useState< { + width: number; + height: number; + } | null >( null ); + const itemRef = useRef< HTMLDivElement >( null ); + const contentRef = useRef< HTMLDivElement >( null ); + + const { attributes, listeners, setNodeRef, setActivatorNodeRef, isDragging } = useSortable( { + id: itemKey, + disabled, + } ); + const mergedRef = useMergeRefs( [ itemRef, setNodeRef ] ); + const contentMergedRef = useMergeRefs( [ contentRef ] ); + + const style: React.CSSProperties = { + ...placementStyle, + alignSelf: 'start', + }; + + const isResizing = resizeDelta !== null; + const itemClassName = clsx( + styles.item, + isDragging && styles[ 'is-dragging' ], + isResizing && styles[ 'is-resizing' ] + ); + + const handleResize = ( delta: ResizeDelta ) => { + const contentNode = contentRef.current; + let baselineSize = initialContentSize; + if ( contentNode && ! baselineSize ) { + const { width, height } = contentNode.getBoundingClientRect(); + baselineSize = { width, height }; + setInitialContentSize( baselineSize ); + } + let clamped: ResizeDelta = { width: delta.width, height: 0 }; + if ( baselineSize ) { + clamped = clampResizeDelta( clamped, baselineSize, { + width: minResizeWidthPx, + } ); + } + setResizeDelta( clamped ); + onResize( itemKey, clamped ); + }; + + const handleResizeEnd = () => { + setResizeDelta( null ); + setInitialContentSize( null ); + onResizeEnd(); + }; + + const continuousContentStyle: React.CSSProperties | undefined = + resizeDelta && initialContentSize + ? { + width: initialContentSize.width + resizeDelta.width, + } + : undefined; + + const previewOverlay = resizeSnapPreview ? ( +
+ ) : null; + + return ( +
+ { actionableArea ? ( +
+
+ { actionableArea } +
+
+ ) : null } + +
+
+ { children } + { ! disabled && ( + + ) } +
+ { previewOverlay } +
+
+ ); +} diff --git a/projects/js-packages/grid/src/dashboard-lanes/lanes.module.css b/projects/js-packages/grid/src/dashboard-lanes/lanes.module.css new file mode 100644 index 000000000000..e6a61911c293 --- /dev/null +++ b/projects/js-packages/grid/src/dashboard-lanes/lanes.module.css @@ -0,0 +1,161 @@ +.lanes { + display: grid-lanes; + column-gap: var(--wp-grid-gap, var(--wpds-dimension-gap-xl)); + row-gap: var(--wp-grid-gap, var(--wpds-dimension-gap-xl)); + position: relative; +} + +/* + * Polyfill fallback. The hook computes per-item `grid-column-start` + * and `grid-row-start`/`grid-row-end: span N` against this row unit. + * The skyline already builds inter-item vertical spacing into each + * tile's `top`, so `row-gap` must be zero here to avoid compounding + * on top of the algorithm's spacing. `grid-auto-flow: dense` is a + * safety net; the algorithm drives placement. + */ +@supports not (display: grid-lanes) { + + .lanes { + display: grid; + grid-auto-rows: var(--wp-grid-lane-row-unit, 4px); + grid-auto-flow: dense; + row-gap: 0; + } +} + +/* + * Functional frame for the drag-preview clone inside ``. + * Always applied, including when the consumer passes a + * `renderDragPreview` wrapper. The outer frame has no `transform` so + * @dnd-kit’s drop translation matches the placeholder; scale lives on + * `__lift` (see dnd-kit #398). Owns elevation, radius, cursor, and + * pointer pass-through. Further chrome belongs to the consumer. Drag + * elevation animates `xs` → `md` so it reads above edit tiles at `xs`. + */ +.drag-preview-frame { + height: 100%; + border-radius: var(--wp-grid-drag-preview-radius, 0); + cursor: grabbing; + pointer-events: none; + box-shadow: var(--wpds-elevation-md); +} + +.drag-preview-frame__lift { + height: 100%; + transform: scale(var(--wp-grid-drag-preview-scale, 1.05)); + transform-origin: center; +} + +@media not (prefers-reduced-motion: reduce) { + + .drag-preview-frame { + animation: + wp-grid-drag-preview-shadow-enter + var(--wpds-motion-duration-sm) var(--wpds-motion-easing-balanced) both; + } + + .drag-preview-frame__lift { + animation: + wp-grid-drag-preview-scale-enter + var(--wpds-motion-duration-sm) var(--wpds-motion-easing-balanced) both; + } +} + +@media not (prefers-reduced-motion: reduce) { + + @keyframes wp-grid-drag-preview-shadow-enter { + + from { + box-shadow: var(--wpds-elevation-xs); + } + + to { + box-shadow: var(--wpds-elevation-md); + } + } + + @keyframes wp-grid-drag-preview-scale-enter { + + from { + transform: scale(1); + } + + to { + transform: scale(var(--wp-grid-drag-preview-scale, 1.05)); + } + } +} + +/* + * Applied by @dnd-kit `DragOverlay` drop side-effects while the + * default overlay transform runs; duration matches + * `createDashboardDragDropAnimation` (200ms / md token). Use keyframed + * exit (not transition) so scale does not snap when the enter animation + * is replaced. + */ +@media not (prefers-reduced-motion: reduce) { + + .drag-preview-frame.dragPreviewFrameExiting { + animation: + wp-grid-drag-preview-shadow-exit + var(--wpds-motion-duration-md) var(--wpds-motion-easing-balanced) + forwards; + } + + .drag-preview-frame.dragPreviewFrameExiting .drag-preview-frame__lift { + animation: + wp-grid-drag-preview-scale-exit + var(--wpds-motion-duration-md) var(--wpds-motion-easing-balanced) + forwards; + } + + @keyframes wp-grid-drag-preview-shadow-exit { + + from { + box-shadow: var(--wpds-elevation-md); + } + + to { + box-shadow: var(--wpds-elevation-xs); + } + } + + @keyframes wp-grid-drag-preview-scale-exit { + + from { + transform: scale(var(--wp-grid-drag-preview-scale, 1.05)); + } + + to { + transform: scale(1); + } + } +} + +.drag-preview-frame.dragPreviewFrameExiting { + box-shadow: var(--wpds-elevation-xs); +} + +.drag-preview-frame.dragPreviewFrameExiting .drag-preview-frame__lift { + transform: scale(1); +} + +@media (prefers-reduced-motion: reduce) { + + .drag-preview-frame { + box-shadow: var(--wpds-elevation-md); + } + + .drag-preview-frame__lift { + transform: none; + } + + .drag-preview-frame.dragPreviewFrameExiting { + box-shadow: var(--wpds-elevation-xs); + transition: none; + } + + .drag-preview-frame.dragPreviewFrameExiting .drag-preview-frame__lift { + transition: none; + } +} diff --git a/projects/js-packages/grid/src/dashboard-lanes/types.ts b/projects/js-packages/grid/src/dashboard-lanes/types.ts new file mode 100644 index 000000000000..646d39510875 --- /dev/null +++ b/projects/js-packages/grid/src/dashboard-lanes/types.ts @@ -0,0 +1,180 @@ +/** + * Internal dependencies + */ +import type { + DragPreviewRenderProps, + GridOverlayRenderProps, + ResizeHandleRenderProps, +} from '../shared/types'; + +/** + * Lanes layout item definition. + * + * Mirrors the public surface of `display: grid-lanes`: column span, + * an optional pinned lane, and an optional source order. Heights are + * content-driven; there is no `height` field. There is no `'fill'` + * (lanes pack their items by skyline; nothing is "left over"). + * `'full'` is expressed by setting `width` to the lane count. + */ +export type DashboardLanesLayoutItem = { + /** + * Unique key that matches a child component key. + */ + key: string; + + /** + * Number of lanes this item spans (`grid-column: span N`). Clamped + * to the surface's lane count. + * + * @default 1 + */ + width?: number; + + /** + * Pin the item to a specific 0-indexed lane. Pinned items are + * placed before auto items, so the auto flow runs around them. + * Out-of-range values (negative, or beyond `columns - width`) are + * clamped to the available range. + */ + lane?: number; + + /** + * Display order. Lower values render first. When omitted, the + * item falls back to its index in the `layout` array. + */ + order?: number; +}; + +/** + * Props for `DashboardLanes`. + * + * `columns` and `minColumnWidth` compose as a layered model: + * - `columns` alone: fixed N lanes; tiles scale with the container. + * - `minColumnWidth` alone: lane count derives from container width, + * floored by the per-tile minimum, down to 1. + * - Both together: `columns` caps the count, `minColumnWidth` enforces + * a per-tile width floor that can reduce the count below the cap. + */ +export interface DashboardLanesProps + extends Omit< React.ComponentPropsWithoutRef< 'div' >, 'children' | 'className' | 'style' > { + /** + * Array of layout items. + */ + layout: DashboardLanesLayoutItem[]; + + /** + * Surface children. Each child must carry a `key` matching an + * entry in `layout`; children without a match render at the end + * of the surface without explicit placement and fall through the + * lanes auto-flow. + */ + children: React.ReactNode; + + /** + * Additional CSS class on the surface root. + */ + className?: string; + + /** + * Inline styles on the surface root. Merged underneath the + * surface's own layout styles, so `display` and + * `gridTemplateColumns` always win. The gap between tiles is + * owned by the design-system gap token and is not configurable + * per instance; override it via a theme or density change. + */ + style?: React.CSSProperties; + + /** + * `flow-tolerance` value in pixels. When two candidate lanes + * differ in baseline by no more than this, the earlier lane wins + * to preserve source order. Larger values keep tiles closer to + * reading order at the cost of bigger empty regions. + * + * @default 16 + */ + flowTolerance?: number; + + /** + * Snap unit for the polyfill's `grid-row-start` / `grid-row-end: + * span N` math. Smaller values produce sharper placement at the + * cost of a larger implicit row count. Ignored on browsers with + * native `display: grid-lanes` support. + * + * @default 4 + */ + rowUnit?: number; + + /** + * Whether the surface is in edit mode (drag-to-reorder, resize). + * + * @default false + */ + editMode?: boolean; + + /** + * Fired when the user commits a drag or resize. + */ + onChangeLayout?: ( newLayout: DashboardLanesLayoutItem[] ) => void; + + /** + * Fired continuously during a gesture with the in-progress + * layout. The committed result still emits via `onChangeLayout`. + */ + onPreviewLayout?: ( previewLayout: DashboardLanesLayoutItem[] ) => void; + + /** + * Override the default corner resize handle. See `DashboardGrid` + * for the full contract; on lanes the handle is horizontal-only + * because heights are content-driven. + */ + renderResizeHandle?: React.ComponentType< ResizeHandleRenderProps >; + + /** + * Custom wrapper for the dragged-clone visual mounted inside + * ``. The surface always wraps the clone with a thin + * functional frame (lift scale, grabbing cursor, pointer pass- + * through) and mounts this component inside it; the consumer + * owns the visual chrome (shadow, radius, padding). + * + * When omitted, the cloned children render directly inside the + * functional frame so any chrome the consumer applied to the + * persistent tile carries through unchanged. + * + * Token-only adjustments (lift scale, placeholder opacity, + * outline color, placeholder radius) flow through CSS custom + * properties documented in the README. + */ + renderDragPreview?: React.ComponentType< DragPreviewRenderProps >; + + /** + * Override the default edit-mode overlay (empty column tracks) with + * a custom component. Lanes are content-driven vertically, so no + * `rowHeight` or `rows` is supplied and the default visual paints + * columns only. + * + * The overlay only renders when `editMode` is true. When omitted, + * the package's default visual is used. + */ + renderGridOverlay?: React.ComponentType< GridOverlayRenderProps >; + + /** + * Target lane count (cap). When set alone, the surface renders + * this many lanes and tiles scale with the container. + * + * Composes with `minColumnWidth`: if both are set, the effective + * lane count is `min( columns, fitsAtMinWidth )`. When omitted but + * `minColumnWidth` is set, the count is uncapped and derives purely + * from container width. When both are omitted, the surface + * renders six lanes. + */ + columns?: number; + + /** + * Per-tile minimum width in pixels. The effective lane count is + * derived from container width, floored by this value, down to 1. + * + * Composes with `columns`: when both are set, this acts as a floor + * that can reduce the count below `columns` on narrow containers. + */ + minColumnWidth?: number; +} diff --git a/projects/js-packages/grid/src/dashboard-lanes/use-lane-placement.ts b/projects/js-packages/grid/src/dashboard-lanes/use-lane-placement.ts new file mode 100644 index 000000000000..bcd8f935012b --- /dev/null +++ b/projects/js-packages/grid/src/dashboard-lanes/use-lane-placement.ts @@ -0,0 +1,296 @@ +/** + * WordPress dependencies + */ +import { useState, useLayoutEffect, useMemo } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { GRID_ITEM_DATA_KEY } from '../shared/grid-item-key'; +import { computeLanePlacements } from './lane-placement'; + +const DEFAULT_ROW_UNIT = 4; + +/** + * + */ +function supportsGridLanes(): boolean { + if ( typeof CSS === 'undefined' || ! CSS.supports ) { + return false; + } + return CSS.supports( 'display', 'grid-lanes' ); +} + +/** + * + * @param span + */ +function clampSpan( span: number | undefined ): number { + if ( typeof span !== 'number' || ! Number.isFinite( span ) ) { + return 1; + } + return Math.max( 1, Math.floor( span ) ); +} + +/** + * Logical item passed to the hook. The renderer is responsible for + * mounting a DOM node with `data-wp-grid-item-key={ item.key }` for each + * entry; the hook will measure that node and produce inline styles. + */ +export type LaneItemInput = { + key: string; + span?: number; + lane?: number; +}; + +export type UseLanePlacementInput = { + items: ReadonlyArray< LaneItemInput >; + lanes: number; + gap: number; + flowTolerance: number; + /** + * Snap unit for `grid-row-start` / `grid-row-end: span N` math. + * Smaller values produce sharper placement at the cost of more + * implicit rows. Defaults to 4 (px). + */ + rowUnit?: number; +}; + +export type UseLanePlacementResult = { + /** + * Inline styles to apply to each item, keyed by item key. On + * native (`display: grid-lanes`), entries carry only + * `gridColumn: span N`; the browser handles row placement. While + * polyfilling, entries also carry explicit `grid-column-start` / + * `grid-row-*` values. + */ + itemStyles: Map< string, React.CSSProperties >; + + /** + * `false` when the host browser supports `display: grid-lanes` + * natively. The hook avoids mounting any observers in that case. + */ + isPolyfilled: boolean; +}; + +/** + * Hook that measures item heights and resolves their placement when + * `display: grid-lanes` is unavailable, falling through to a no-op + * pass when the host browser supports the feature natively. + * + * Usage from the renderer: + * + * ```tsx + * const [ container, setContainer ] = useState< HTMLDivElement | null >( null ); + * const { itemStyles } = useLanePlacement( container, { + * items: layout, + * lanes: columns, + * gap: gapPx, + * flowTolerance: 16, + * } ); + * + * return ( + *
+ * { items.map( ( item ) => ( + *
+ * { ... } + *
+ * ) ) } + *
+ * ); + * ``` + * + * @param container - HTMLElement (or null pre-mount) hosting the items. + * @param input - Logical items, lane count, gap, and tuning. + * @return Per-item styles plus the `isPolyfilled` flag. + */ +export function useLanePlacement( + container: HTMLElement | null, + input: UseLanePlacementInput +): UseLanePlacementResult { + // Detect once at mount. SSR returns `true` (CSS undefined); the + // client-first render returns the real value. Either path produces + // the same DOM until the polyfill effect runs (both emit + // span-only styles), so there is no hydration mismatch. + const [ isPolyfilled ] = useState( () => ! supportsGridLanes() ); + + const [ itemStyles, setItemStyles ] = useState< Map< string, React.CSSProperties > >( + () => new Map() + ); + + // Native pass-through: items only need their column span; the + // browser handles row placement. Memoized so a stable items + // array yields a stable Map identity. + const nativeStyles = useMemo( () => { + const map = new Map< string, React.CSSProperties >(); + for ( const item of input.items ) { + map.set( item.key, { + gridColumn: `span ${ clampSpan( item.span ) }`, + } ); + } + return map; + }, [ input.items ] ); + + // Stable signature of items for deps. Keys, spans, and explicit + // lanes are the only fields that influence observer wiring or + // placement, so we hash exactly those. + const itemsSignature = useMemo( () => { + return input.items + .map( item => `${ item.key }/${ item.span ?? 1 }/${ item.lane ?? '' }` ) + .join( '\0' ); + }, [ input.items ] ); + + // Stable array identity while placement-relevant fields match + // `itemsSignature`, so the layout effect is not torn down on every + // parent re-render that passes a fresh `items` reference. + // eslint-disable-next-line react-hooks/exhaustive-deps -- `itemsSignature` encodes keys/spans/lanes; `input.items` reference often changes without placement changes. + const itemsForPlacement = useMemo( () => input.items, [ itemsSignature ] ); + + const { lanes, gap, flowTolerance, rowUnit } = input; + + useLayoutEffect( () => { + if ( ! isPolyfilled || ! container ) { + return; + } + if ( typeof ResizeObserver === 'undefined' ) { + return; + } + + const heights = new Map< string, number >(); + const observed = new Set< Element >(); + let cancelled = false; + let rafId: number | null = null; + + const recompute = () => { + if ( rafId !== null || cancelled ) { + return; + } + // One layout per frame even when ResizeObserver and + // MutationObserver fire in the same tick. + rafId = requestAnimationFrame( () => { + rafId = null; + if ( cancelled ) { + return; + } + const itemsWithHeight = itemsForPlacement.map( item => ( { + key: item.key, + span: clampSpan( item.span ), + lane: item.lane, + height: heights.get( item.key ) ?? 0, + } ) ); + const result = computeLanePlacements( { + items: itemsWithHeight, + lanes, + gap, + flowTolerance, + } ); + const effectiveRowUnit = Math.max( 1, rowUnit ?? DEFAULT_ROW_UNIT ); + const next = new Map< string, React.CSSProperties >(); + for ( const item of itemsForPlacement ) { + const placement = result.placements.get( item.key ); + if ( ! placement ) { + continue; + } + const height = heights.get( item.key ) ?? 0; + const rowStart = Math.floor( placement.top / effectiveRowUnit ) + 1; + const rowSpan = Math.max( 1, Math.ceil( height / effectiveRowUnit ) ); + next.set( item.key, { + gridColumnStart: placement.lane + 1, + gridColumnEnd: `span ${ placement.span }`, + gridRowStart: rowStart, + gridRowEnd: `span ${ rowSpan }`, + } ); + } + setItemStyles( next ); + } ); + }; + + const resizeObserver = new ResizeObserver( entries => { + let changed = false; + for ( const entry of entries ) { + const key = ( entry.target as HTMLElement ).getAttribute( GRID_ITEM_DATA_KEY ); + if ( ! key ) { + continue; + } + const newHeight = entry.contentRect.height; + if ( heights.get( key ) !== newHeight ) { + heights.set( key, newHeight ); + changed = true; + } + } + if ( changed ) { + recompute(); + } + } ); + + const refreshObserved = () => { + const current = container.querySelectorAll( `[${ GRID_ITEM_DATA_KEY }]` ); + for ( const element of current ) { + if ( ! observed.has( element ) ) { + observed.add( element ); + resizeObserver.observe( element ); + const key = element.getAttribute( GRID_ITEM_DATA_KEY ); + if ( key ) { + const rect = ( element as HTMLElement ).getBoundingClientRect(); + heights.set( key, rect.height ); + } + } + } + for ( const element of observed ) { + if ( ! container.contains( element ) ) { + resizeObserver.unobserve( element ); + observed.delete( element ); + } + } + }; + + // Children may mount, unmount, or change `data-wp-grid-item-key` + // after the container exists (drag reorders, additions). The + // mutation observer keeps the observed set in sync. + const mutationObserver = + typeof MutationObserver !== 'undefined' + ? new MutationObserver( () => { + refreshObserved(); + recompute(); + } ) + : null; + if ( mutationObserver ) { + mutationObserver.observe( container, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: [ GRID_ITEM_DATA_KEY ], + } ); + } + + refreshObserved(); + recompute(); + + return () => { + cancelled = true; + if ( rafId !== null ) { + cancelAnimationFrame( rafId ); + } + resizeObserver.disconnect(); + mutationObserver?.disconnect(); + }; + }, [ container, isPolyfilled, lanes, gap, flowTolerance, rowUnit, itemsForPlacement ] ); + + if ( ! isPolyfilled ) { + return { itemStyles: nativeStyles, isPolyfilled: false }; + } + if ( itemStyles.size === 0 ) { + // Pre-measurement frame: emit native-shape styles so items + // appear in their default span rather than collapsing to 1 + // column at the top-left. + return { + itemStyles: nativeStyles as Map< string, React.CSSProperties >, + isPolyfilled: true, + }; + } + return { itemStyles, isPolyfilled: true }; +} diff --git a/projects/js-packages/grid/src/index.ts b/projects/js-packages/grid/src/index.ts new file mode 100644 index 000000000000..39f4f3cb6dcc --- /dev/null +++ b/projects/js-packages/grid/src/index.ts @@ -0,0 +1,11 @@ +export { DashboardGrid } from './dashboard-grid'; +export { DashboardLanes } from './dashboard-lanes'; + +export type { DashboardGridLayoutItem, DashboardGridProps } from './dashboard-grid/types'; +export type { DashboardLanesLayoutItem, DashboardLanesProps } from './dashboard-lanes/types'; +export type { + DragPreviewRenderProps, + GridOverlayRenderProps, + ResizeDelta, + ResizeHandleRenderProps, +} from './shared/types'; diff --git a/projects/js-packages/grid/src/shared/actionable-area-slot.module.css b/projects/js-packages/grid/src/shared/actionable-area-slot.module.css new file mode 100644 index 000000000000..dfc85b41559b --- /dev/null +++ b/projects/js-packages/grid/src/shared/actionable-area-slot.module.css @@ -0,0 +1,17 @@ +.actionable-area-slot { + opacity: 1; +} + +@media (prefers-reduced-motion: no-preference) { + + .actionable-area-slot { + transition: + opacity var(--wpds-motion-duration-md) + var(--wpds-motion-easing-subtle); + } +} + +:global([data-wp-grid-resizing]) .actionable-area-slot { + opacity: 0; + pointer-events: none; +} diff --git a/projects/js-packages/grid/src/shared/drag-overlay-drop-animation.ts b/projects/js-packages/grid/src/shared/drag-overlay-drop-animation.ts new file mode 100644 index 000000000000..85e20b0073b3 --- /dev/null +++ b/projects/js-packages/grid/src/shared/drag-overlay-drop-animation.ts @@ -0,0 +1,61 @@ +/** + * External dependencies + */ +import { defaultDropAnimation, defaultDropAnimationSideEffects } from '@dnd-kit/core'; +import type { DropAnimation } from '@dnd-kit/core'; + +/** Matches `--wpds-motion-duration-md` on the drag preview frame exit. */ +export const DROP_ANIMATION_DURATION_MS = 200; + +/** Matches `--wpds-motion-easing-balanced` on the drag preview frame exit. */ +const DROP_ANIMATION_EASING = 'cubic-bezier(0.4, 0, 0.2, 1)'; + +/** + * Composes @dnd-kit/core’s default overlay drop translation with preview + * exit keyframes (via side effects). When the pointer never moves, @dnd-kit + * skips the drop animation and these side effects do not run. + * + * @param dragPreviewFrameClassName - Hashed class for `.drag-preview-frame`. + * @param exitingFrameClassName - Hashed class for the exit state. + */ +export function createDashboardDragDropAnimation( + dragPreviewFrameClassName: string, + exitingFrameClassName: string +): DropAnimation { + return { + ...defaultDropAnimation, + duration: DROP_ANIMATION_DURATION_MS, + easing: DROP_ANIMATION_EASING, + sideEffects( args ) { + const cleanupDefault = defaultDropAnimationSideEffects( { + styles: { + active: { + opacity: '0', + }, + }, + } )( args ); + + const frame = args.dragOverlay.node.getElementsByClassName( + dragPreviewFrameClassName + )[ 0 ] as HTMLElement | undefined; + + if ( frame ) { + frame.getAnimations().forEach( animation => animation.cancel() ); + const lift = frame.firstElementChild; + if ( lift instanceof HTMLElement ) { + lift.getAnimations().forEach( animation => animation.cancel() ); + } + frame.classList.add( exitingFrameClassName ); + } + + return () => { + if ( typeof cleanupDefault === 'function' ) { + cleanupDefault(); + } + if ( frame ) { + frame.classList.remove( exitingFrameClassName ); + } + }; + }, + }; +} diff --git a/projects/js-packages/grid/src/shared/grid-item-key.ts b/projects/js-packages/grid/src/shared/grid-item-key.ts new file mode 100644 index 000000000000..33f3569a9e3a --- /dev/null +++ b/projects/js-packages/grid/src/shared/grid-item-key.ts @@ -0,0 +1,5 @@ +/** + * Data attribute grid tiles declare so layout-shift animation can map + * measured DOM nodes back to logical item keys. + */ +export const GRID_ITEM_DATA_KEY = 'data-wp-grid-item-key'; diff --git a/projects/js-packages/grid/src/shared/grid-overlay.module.css b/projects/js-packages/grid/src/shared/grid-overlay.module.css new file mode 100644 index 000000000000..d3b84a65af00 --- /dev/null +++ b/projects/js-packages/grid/src/shared/grid-overlay.module.css @@ -0,0 +1,85 @@ +.overlay { + position: absolute; + inset: 0; + display: grid; + gap: var(--wp-grid-gap, var(--wpds-dimension-gap-xl)); + pointer-events: none; + opacity: 0; + visibility: hidden; +} + +.overlay.is-active { + opacity: 1; + visibility: visible; +} + +/* + * Alpha wave: row-marker tiles (or column tracks on lanes) reveal in a + * diagonal sweep from the top-left using staggered delays. + */ +@media not (prefers-reduced-motion) { + + .overlay.is-active .row, + .overlay.is-active:not(.has-rows) .column { + opacity: 0; + transform: scale(0.64); + animation: + grid-overlay-tile-in + var(--wpds-motion-duration-md) var(--wpds-motion-easing-subtle) + both; + animation-delay: calc(var(--wp-grid-overlay-wave-delay-step, 28ms) * (var(--wp-grid-overlay-column-index, 0) + var(--wp-grid-overlay-row-index, 0))); + } + + @keyframes grid-overlay-tile-in { + + to { + opacity: 1; + transform: scale(1); + } + } + + .overlay:not(.is-active) { + transition: + opacity var(--wpds-motion-duration-sm) + var(--wpds-motion-easing-subtle), + visibility 0s linear var(--wpds-motion-duration-sm); + } + + .overlay:not(.is-active) .row, + .overlay:not(.is-active) .column { + animation: none; + transform: none; + } +} + +@media (prefers-reduced-motion: reduce) { + + .overlay { + transition: + opacity var(--wpds-motion-duration-sm) + var(--wpds-motion-easing-subtle), + visibility 0s linear var(--wpds-motion-duration-sm); + } + + .overlay.is-active { + transition: + opacity var(--wpds-motion-duration-sm) + var(--wpds-motion-easing-subtle), + visibility 0s linear 0s; + } +} + +.column { + display: flex; + flex-direction: column; + gap: var(--wp-grid-gap, var(--wpds-dimension-gap-xl)); + min-width: 0; +} + +.row { + flex: 0 0 var(--wp-grid-overlay-row-height); + height: var(--wp-grid-overlay-row-height); + box-sizing: border-box; + border-radius: var(--wpds-border-radius-lg); + background-color: var(--wp-grid-overlay-tile-bg, var(--wpds-color-bg-surface-neutral-weak)); +} diff --git a/projects/js-packages/grid/src/shared/grid-overlay.tsx b/projects/js-packages/grid/src/shared/grid-overlay.tsx new file mode 100644 index 000000000000..755ba828c81d --- /dev/null +++ b/projects/js-packages/grid/src/shared/grid-overlay.tsx @@ -0,0 +1,102 @@ +/** + * External dependencies + */ +import { useEffect, useState } from '@wordpress/element'; +import clsx from 'clsx'; + +/** + * WordPress dependencies + */ + +/** + * Internal dependencies + */ +import styles from './grid-overlay.module.css'; +import type { GridOverlayRenderProps } from './types'; + +/** + * Default edit-mode overlay. Renders one column per track; when + * `rowHeight` and `rows` are supplied, each column holds that many + * row-marker tiles sized to the uniform row height. + * + * Used by both `DashboardGrid` and `DashboardLanes`. Replaced wholesale + * by passing a `renderGridOverlay` to either surface; themed in place + * via the CSS custom properties documented in the package README. + * + * Reveals with a diagonal alpha wave from the top-left corner when + * `isActive` becomes true (motion design tokens for duration and + * easing). Fades out on deactivate; while inactive, `visibility: + * hidden` releases paint cost. + * + * The overlay inherits its gap from the same design-system gap token + * the surfaces use, so columns and row markers stay pixel-aligned + * without the surface having to forward a `spacing` value. + * + * @param props - Render props supplied by the surface. + * @param props.columns - Number of column tracks to mirror. + * @param props.rowHeight - Row height in pixels for surfaces with uniform + * rows. Omitted on lane surfaces or auto-sized + * grids; in that case row markers are skipped. + * @param props.rows - Number of row tracks to mirror in each column. + * Omitted when row height is unknown. + * @param props.isActive - When `false`, the overlay fades out and stops + * consuming paint cost. + */ +export function GridOverlay( { columns, rowHeight, rows, isActive }: GridOverlayRenderProps ) { + const showRows = typeof rowHeight === 'number' && typeof rows === 'number' && rows > 0; + // Bump the key when edit mode activates so CSS animations restart on + // each enter (the overlay stays mounted across toggles). + const [ waveKey, setWaveKey ] = useState( 0 ); + useEffect( () => { + if ( isActive ) { + setWaveKey( key => key + 1 ); + } + }, [ isActive ] ); + const style: React.CSSProperties = { + gridTemplateColumns: `repeat(${ columns }, minmax(0, 1fr))`, + ...( showRows + ? ( { + '--wp-grid-overlay-row-height': `${ rowHeight }px`, + } as React.CSSProperties ) + : {} ), + }; + + return ( +
+ { Array.from( { length: columns }, ( _column, columnIndex ) => ( +
+ { showRows && + Array.from( { length: rows }, ( _row, rowIndex ) => ( +
+ ) ) } +
+ ) ) } +
+ ); +} diff --git a/projects/js-packages/grid/src/shared/item-exit-animation.module.css b/projects/js-packages/grid/src/shared/item-exit-animation.module.css new file mode 100644 index 000000000000..5bf0fbe9ff5e --- /dev/null +++ b/projects/js-packages/grid/src/shared/item-exit-animation.module.css @@ -0,0 +1,53 @@ +/* + * Absolutely positioned clone of a removed tile. Siblings reflow via + * FLIP while this overlay scales down and fades out. + */ +.exit-overlay { + position: absolute; + pointer-events: none; + z-index: 2; + overflow: hidden; + transform-origin: center center; + opacity: 1; + transform: scale(1); +} + +@media not (prefers-reduced-motion: reduce) { + + .exit-overlay { + animation: + wp-grid-item-exit-opacity var(--wpds-motion-duration-md) + var(--wpds-motion-easing-subtle) forwards, + wp-grid-item-exit-scale var(--wpds-motion-duration-md) + var(--wpds-motion-easing-balanced) forwards; + } +} + +@keyframes wp-grid-item-exit-opacity { + + from { + opacity: 1; + } + + to { + opacity: 0; + } +} + +@keyframes wp-grid-item-exit-scale { + + from { + transform: scale(1); + } + + to { + transform: scale(0.88); + } +} + +@media (prefers-reduced-motion: reduce) { + + .exit-overlay { + display: none; + } +} diff --git a/projects/js-packages/grid/src/shared/item-exit-overlay.tsx b/projects/js-packages/grid/src/shared/item-exit-overlay.tsx new file mode 100644 index 000000000000..989466956131 --- /dev/null +++ b/projects/js-packages/grid/src/shared/item-exit-overlay.tsx @@ -0,0 +1,54 @@ +/** + * Internal dependencies + */ +import { GRID_ITEM_DATA_KEY } from './grid-item-key'; +import exitStyles from './item-exit-animation.module.css'; +import type { RectSnapshot } from './use-layout-shift-animation'; + +export type ItemExitOverlayRect = Pick< RectSnapshot, 'left' | 'top' | 'width' | 'height' >; + +export type ItemExitOverlayProps = { + itemKey: string; + rect: ItemExitOverlayRect; + children: React.ReactNode; + onAnimationEnd: () => void; +}; + +/** + * Ghost tile shown at the removed item's last position while siblings + * reflow. Not a sortable grid cell — only visual exit feedback. + * + * @param root0 - Component props. + * @param root0.itemKey - Layout key of the removed tile. + * @param root0.rect - Last bounds relative to the grid surface. + * @param root0.children - Cached tile content to render in the ghost. + * @param root0.onAnimationEnd - Called when the exit animation finishes. + */ +export function ItemExitOverlay( { + itemKey, + rect, + children, + onAnimationEnd, +}: ItemExitOverlayProps ) { + return ( +
{ + if ( event.target !== event.currentTarget ) { + return; + } + onAnimationEnd(); + } } + > + { children } +
+ ); +} diff --git a/projects/js-packages/grid/src/shared/layout-shift-animation.module.css b/projects/js-packages/grid/src/shared/layout-shift-animation.module.css new file mode 100644 index 000000000000..54168ec00a88 --- /dev/null +++ b/projects/js-packages/grid/src/shared/layout-shift-animation.module.css @@ -0,0 +1,17 @@ +/* + * Sibling tile reflow during drag/resize/remove in edit mode. Transition + * lives on tiles so the browser can interpolate `transform` reliably + * (inline `transition` + FLIP in the same frame is skipped). + */ +.layout-animating :global([data-wp-grid-item-key]) { + transition: + transform var(--wpds-motion-duration-md) + var(--wpds-motion-easing-balanced); +} + +@media (prefers-reduced-motion: reduce) { + + .layout-animating :global([data-wp-grid-item-key]) { + transition: none; + } +} diff --git a/projects/js-packages/grid/src/shared/resize-handle.module.css b/projects/js-packages/grid/src/shared/resize-handle.module.css new file mode 100644 index 000000000000..3053d43e0754 --- /dev/null +++ b/projects/js-packages/grid/src/shared/resize-handle.module.css @@ -0,0 +1,91 @@ +/* + * Fade with opacity only: `visibility` is not interpolatable and pairing + * it with delayed transitions breaks fade-in. + */ +.resize-handle-slot { + opacity: 1; +} + +@media (prefers-reduced-motion: no-preference) { + + .resize-handle-slot { + transition: + opacity var(--wpds-motion-duration-md) + var(--wpds-motion-easing-subtle); + } +} + +/* + * Grid-level interaction hooks (`data-wp-grid-dragging`, + * `data-wp-grid-resizing`, `data-wp-grid-item-resizing`) drive + * handle visibility. + */ +:global([data-wp-grid-dragging]) .resize-handle-slot, +:global([data-wp-grid-resizing]) .resize-handle-slot { + opacity: 0; + pointer-events: none; +} + +:global([data-wp-grid-resizing]) +:global([data-wp-grid-item-resizing]) +.resize-handle-slot { + opacity: 1; + pointer-events: auto; +} + +.resize-handle { + position: absolute; + bottom: 0; + inset-inline-end: 0; + width: 0; + height: 0; + z-index: 1; + cursor: nwse-resize; + + /* + * Triangle drawn via borders. Use per-side logical shorthands + * instead of `border-width: 0` + `border-block-end-width: 12px`: + * the build's CSS optimizer merges the global shorthands into a + * single `border: 0 solid transparent` and emits it AFTER the + * logical longhands, which then loses the cascade and zeroes + * the triangle widths. The unused sides default to + * `border-style: none` and render as 0 regardless of width. + */ + border-block-end: 12px solid var(--wpds-color-fg-interactive-brand); + border-inline-start: 12px solid transparent; +} + +/* + * When the host surface only resizes horizontally (e.g. lanes, + * where height is content-driven), the corner triangle advertises a + * vertical drag the user cannot perform. Override with a vertical + * grip centered on the trailing edge. + */ +.is-horizontal-only { + cursor: ew-resize; + border-style: none; + bottom: auto; + top: 50%; + transform: translateY(-50%); + width: 16px; + height: 40px; + background: transparent; + display: flex; + align-items: center; + justify-content: center; +} + +.is-horizontal-only::before { + content: ""; + width: 4px; + height: 24px; + border-radius: 2px; + background-color: var(--wpds-color-fg-interactive-brand); +} + +@media (forced-colors: active) { + + .is-horizontal-only::before { + background-color: Highlight; + } +} diff --git a/projects/js-packages/grid/src/shared/resize-handle.tsx b/projects/js-packages/grid/src/shared/resize-handle.tsx new file mode 100644 index 000000000000..0292dc563b77 --- /dev/null +++ b/projects/js-packages/grid/src/shared/resize-handle.tsx @@ -0,0 +1,164 @@ +/** + * External dependencies + */ +import { DndContext, useDraggable } from '@dnd-kit/core'; +import { useMergeRefs, useThrottle } from '@wordpress/compose'; +import { useCallback, useEffect, useRef } from '@wordpress/element'; +import clsx from 'clsx'; + +/** + * WordPress dependencies + */ + +/** + * Internal dependencies + */ +import styles from './resize-handle.module.css'; +import type { ResizeDelta, ResizeHandleProps } from './types'; +import type { DragMoveEvent } from '@dnd-kit/core'; + +/** + * Sets `document.documentElement.style.cursor` for the duration of a drag + * and restores it on cleanup. Lives outside the component so cursor writes + * are not analyzed as mutating values derived from refs in the component + * body (react-hooks/immutability). + * + * @param getDocument - Returns the document whose root element should receive + * the cursor (handle owner, or global `document`). + * @param cursor - CSS cursor value while active. + * @return Cleanup that restores the previous cursor. + */ +function lockDocumentCursorWhileActive( getDocument: () => Document, cursor: string ): () => void { + const root = getDocument().documentElement; + const previous = root.style.cursor; + root.style.cursor = cursor; + return () => { + root.style.cursor = previous; + }; +} + +/** + * + * @param root0 + * @param root0.itemId + * @param root0.verticalResizable + * @param root0.renderResizeHandle + */ +function ResizeHandle( { + itemId, + verticalResizable = true, + renderResizeHandle, +}: ResizeHandleProps ) { + const { attributes, listeners, setNodeRef, isDragging } = useDraggable( { + id: 'draggable', + data: { itemId }, + } ); + + // Snapshot owner document on mount/update via ref callback so the + // cursor-lock effect can resolve the correct document in an iframe. + const ownerDocumentRef = useRef< Document | null >( null ); + const setOwnerDocumentRef = useCallback( ( node: HTMLElement | null ) => { + ownerDocumentRef.current = node?.ownerDocument ?? null; + }, [] ); + const mergedRef = useMergeRefs( [ setOwnerDocumentRef, setNodeRef ] ); + + // Lock the document cursor while the gesture is active. Without + // this, the OS pointer reverts to the default arrow as soon as it + // leaves the handle's hit area, even though the resize is still + // in progress. + useEffect( () => { + if ( ! isDragging ) { + return; + } + const cursor = verticalResizable ? 'nwse-resize' : 'ew-resize'; + return lockDocumentCursorWhileActive( () => ownerDocumentRef.current ?? document, cursor ); + }, [ isDragging, verticalResizable ] ); + + if ( renderResizeHandle ) { + const RenderResizeHandle = renderResizeHandle; + return ( + + ); + } + + return ( +
+ ); +} + +/** + * Renders a corner resize handle inside an isolated ``. + * Reports the cursor offset since the gesture started (in pixels) + * via `onResize`, throttled to one animation frame so the grid + * commit loop runs at most once per paint. + * + * Auto-scroll is enabled with a tight trigger zone and a low + * acceleration so a resize gesture near the viewport edge scrolls + * the page only when the user deliberately pushes against the very + * edge, and even then at a pace the user can interrupt by releasing. + * Default tuning would otherwise produce a runaway loop where the + * page scrolls fast, dnd-kit's document-coordinate `delta` inflates + * with the scroll, and the tile keeps growing without further user + * input. + * + * @param props - Component props. + */ +export default function ResizeHandleWrapper( props: ResizeHandleProps ) { + const throttleDelay = 16; + const throttledResize = useThrottle( ( delta: ResizeDelta ) => { + if ( props.onResize ) { + props.onResize( delta ); + } + }, throttleDelay ); + + // `event.delta` is the cursor offset from the gesture start — + // not from the handle's current position — so it stays stable + // even when the tile (and therefore the handle) jumps a column. + // The grid's resize logic snapshots the start width and adds + // `delta`, so the two must share the same frame of reference. + const handleDragMove = ( event: DragMoveEvent ) => { + if ( event.active.id !== 'draggable' ) { + return; + } + throttledResize( { + width: event.delta.x, + height: event.delta.y, + } ); + }; + + const handleDragEnd = () => { + if ( props.onResizeEnd ) { + props.onResizeEnd(); + } + }; + + return ( + +
+ +
+
+ ); +} diff --git a/projects/js-packages/grid/src/shared/resize-snap.ts b/projects/js-packages/grid/src/shared/resize-snap.ts new file mode 100644 index 000000000000..5690fe7329de --- /dev/null +++ b/projects/js-packages/grid/src/shared/resize-snap.ts @@ -0,0 +1,60 @@ +import type { ResizeDelta } from './types'; + +/** + * Pixel dimensions for the snapped resize preview outline. + */ +export type ResizeSnapSize = { + widthPx: number; + /** When `null`, the preview spans the item's content height (lanes). */ + heightPx: number | null; +}; + +/** + * Clamps a resize delta so the tile cannot shrink below the given + * minimum width (and height when provided). + * + * @param delta - Cursor offset from the gesture start in pixels. + * @param initialSize - Size captured at gesture start. + * @param initialSize.width - Initial width in pixels. + * @param initialSize.height - Initial height in pixels. + * @param minSize - Minimum tile size in pixels. + * @param minSize.width - Minimum width in pixels. + * @param minSize.height - Minimum height in pixels, when vertical resize applies. + */ +export function clampResizeDelta( + delta: ResizeDelta, + initialSize: { width: number; height: number }, + minSize: { width: number; height?: number } +): ResizeDelta { + const maxShrinkWidth = initialSize.width - minSize.width; + const width = Math.max( delta.width, -maxShrinkWidth ); + if ( minSize.height === undefined ) { + return { ...delta, width }; + } + const maxShrinkHeight = initialSize.height - minSize.height; + const height = Math.max( delta.height, -maxShrinkHeight ); + return { width, height }; +} + +/** + * Converts grid spans to pixel width/height for the resize-preview + * outline, using the same track math the surface uses for placement. + * + * @param columnSpan - Number of columns the snap target spans. + * @param rowSpan - Number of rows the snap target spans. + * @param columnWidth - Width of one column track in pixels. + * @param gapPx - Gap between tracks in pixels. + * @param rowHeightPx - Row track height in pixels, or `null` when rows + * are content-sized. + */ +export function gridSpanToPixelSize( + columnSpan: number, + rowSpan: number, + columnWidth: number, + gapPx: number, + rowHeightPx: number | null +): ResizeSnapSize { + const widthPx = columnSpan * columnWidth + ( columnSpan - 1 ) * gapPx; + const heightPx = rowHeightPx === null ? null : rowSpan * rowHeightPx + ( rowSpan - 1 ) * gapPx; + return { widthPx, heightPx }; +} diff --git a/projects/js-packages/grid/src/shared/types.ts b/projects/js-packages/grid/src/shared/types.ts new file mode 100644 index 000000000000..c13477063296 --- /dev/null +++ b/projects/js-packages/grid/src/shared/types.ts @@ -0,0 +1,164 @@ +/** + * External dependencies + */ +import type { useDraggable } from '@dnd-kit/core'; + +// `useDraggable`'s `listeners` and `attributes` types are not exported +// from `@dnd-kit/core`'s public surface, so derive them from the hook +// itself rather than via a deep import. +type DraggableBindings = ReturnType< typeof useDraggable >; + +/** + * Cursor offset reported by the resize handle, in pixels relative to + * the gesture start. Width and height are independent so the surface + * can step columns and rows separately. + */ +export type ResizeDelta = { + width: number; + height: number; +}; + +/** + * Props received by a custom resize handle component. Spread `listeners` + * and `attributes` onto the element that should respond to the gesture, + * and assign `ref` to the same element so dnd-kit can track it. + */ +export interface ResizeHandleRenderProps { + /** + * Ref callback to attach to the gesture-bearing element. + */ + ref: DraggableBindings[ 'setNodeRef' ]; + + /** + * Pointer/keyboard event listeners that initiate the drag. + */ + listeners: DraggableBindings[ 'listeners' ]; + + /** + * Accessibility and dnd-kit attributes (role, aria-*, tabIndex…). + */ + attributes: DraggableBindings[ 'attributes' ]; + + /** + * Whether vertical resizing is allowed for this tile. Useful for + * adapting the cursor or visual cue. + */ + verticalResizable: boolean; + + /** + * True while the user is actively dragging this handle. Use it to + * swap colors, icons, or transforms during the gesture. + */ + isResizing: boolean; + + /** + * Owning item's `key`. Available so consumers can render per-tile + * content if needed. + */ + itemId?: string; +} + +/** + * Props received by a custom drag-preview component. The surface mounts + * the component inside `` and supplies the active tile's + * cloned children plus its `key`. The component is responsible for the + * visual chrome of the dragged clone (shadow, radius, padding); the + * surface keeps a thin functional wrapper around it that owns the lift + * cue, the cursor, and pointer pass-through during the gesture. + */ +export interface DragPreviewRenderProps { + /** + * The cloned tile content the surface mounts inside the + * `` portal. Render it where the visual wrapper + * expects the tile body. + */ + children: React.ReactNode; + + /** + * Owning tile's `key`. Useful when the visual chrome needs to + * vary by which tile is being dragged. + */ + itemId: string; +} + +/** + * Props received by a custom grid overlay component. The overlay + * paints behind the tiles in edit mode to visualize the column tracks + * and (when `rowHeight` is defined) the row tracks. Receives a + * snapshot of the surface's resolved layout parameters so the visual + * can reproduce the tracks pixel-accurately without re-deriving them. + * + * Reused by both `DashboardGrid` and `DashboardLanes`: lanes pass no + * `rowHeight` because heights are content-driven. + */ +export interface GridOverlayRenderProps { + /** + * Number of column tracks in the active surface. In responsive + * mode (`minColumnWidth`), this is the count derived from the + * container width, not the prop value. + */ + columns: number; + + /** + * Row height in pixels for surfaces with uniform rows. Omitted on + * surfaces with content-driven heights (lanes) or when row height + * is `'auto'`; in those cases row markers are omitted. + */ + rowHeight?: number; + + /** + * Number of row tracks to mirror in each column. Derived from the + * grid container height when `rowHeight` is numeric; omitted when + * row height is unknown. + */ + rows?: number; + + /** + * Whether the overlay should be visible. Surfaces render the + * overlay even when `false` so the implementation can transition + * opacity in and out; while `false`, the overlay must hide itself + * (and ideally release paint cost via `visibility: hidden` or an + * equivalent). + */ + isActive: boolean; +} + +/** + * Props for the internal `` wrapper. + */ +export interface ResizeHandleProps { + /** + * Owning item's `key`. Forwarded as `data.itemId` on the draggable + * so the parent can correlate the gesture with a tile if needed. + */ + itemId?: string; + + /** + * Whether the handle should track vertical movement. When false, + * the handle still appears but only emits horizontal deltas, and + * the cursor is constrained to the column resize axis. + * + * @default true + */ + verticalResizable?: boolean; + + /** + * Callback fired while the handle is being dragged. Receives the + * cursor offset from the gesture start in pixels. + */ + onResize?: ( delta: ResizeDelta ) => void; + + /** + * Callback fired when the gesture ends. + */ + onResizeEnd?: () => void; + + /** + * Component that overrides the default corner triangle with a + * custom element. Receives gesture wiring (`ref`, `listeners`, + * `attributes`) plus context. The surface keeps ownership of the + * `` and the throttled delta loop; consumers are only + * responsible for the visual. + */ + renderResizeHandle?: React.ComponentType< ResizeHandleRenderProps >; +} diff --git a/projects/js-packages/grid/src/shared/use-item-exit-animation.ts b/projects/js-packages/grid/src/shared/use-item-exit-animation.ts new file mode 100644 index 000000000000..75a65142f297 --- /dev/null +++ b/projects/js-packages/grid/src/shared/use-item-exit-animation.ts @@ -0,0 +1,185 @@ +/** + * WordPress dependencies + */ +import { useCallback, useLayoutEffect, useRef, useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import type { ItemExitOverlayRect } from './item-exit-overlay'; +import type { RectSnapshot } from './use-layout-shift-animation'; + +/* + * Last-resort cleanup if `animationend` never fires (the overlay's + * `onAnimationEnd` is the primary path). Kept well above the motion + * token durations so the timeout can never clip the exit animation. + */ +const EXIT_SAFETY_TIMEOUT_MS = 1000; + +export type ExitingGridItem = { + key: string; + rect: ItemExitOverlayRect; + child: React.ReactElement; +}; + +type UseItemExitAnimationOptions = { + container: HTMLElement | null; + enabled: boolean; + layoutKeys: ReadonlySet< string >; + getPositionsBeforeLastChange: () => ReadonlyMap< string, RectSnapshot > | null; + childrenCacheRef: React.MutableRefObject< Map< string, React.ReactElement > >; +}; + +type UseItemExitAnimationResult = { + exitingItems: ExitingGridItem[]; + hasExitingItems: boolean; + clearExitingItem: ( key: string ) => void; +}; + +/** + * + */ +function prefersReducedMotion(): boolean { + return ( + typeof window !== 'undefined' && window.matchMedia( '(prefers-reduced-motion: reduce)' ).matches + ); +} + +/** + * When `layout` loses keys in edit mode, keeps a short-lived overlay at + * the removed tile's last position (scale + fade) while siblings FLIP. + * + * @param root0 - Hook options. + * @param root0.container - Surface root that contains grid tiles. + * @param root0.enabled - When false, exiting state is cleared. + * @param root0.layoutKeys - Keys in the committed `layout` prop. + * @param root0.getPositionsBeforeLastChange - Container-relative rects before the latest layout commit. + * @param root0.childrenCacheRef - Last rendered children keyed by tile id. + * @return Exiting overlays and a callback to dismiss one by key. + */ +export function useItemExitAnimation( { + container, + enabled, + layoutKeys, + getPositionsBeforeLastChange, + childrenCacheRef, +}: UseItemExitAnimationOptions ): UseItemExitAnimationResult { + const [ exitingItems, setExitingItems ] = useState< ExitingGridItem[] >( [] ); + const prevLayoutKeysRef = useRef< Set< string > >( new Set() ); + const exitTimeoutsRef = useRef< Map< string, ReturnType< typeof setTimeout > > >( new Map() ); + + const clearExitingItem = useCallback( + ( key: string ) => { + const timeout = exitTimeoutsRef.current.get( key ); + if ( timeout ) { + clearTimeout( timeout ); + exitTimeoutsRef.current.delete( key ); + } + setExitingItems( current => current.filter( item => item.key !== key ) ); + childrenCacheRef.current.delete( key ); + }, + [ childrenCacheRef ] + ); + + const scheduleExitComplete = useCallback( + ( key: string ) => { + if ( exitTimeoutsRef.current.has( key ) ) { + return; + } + const timeout = setTimeout( () => { + exitTimeoutsRef.current.delete( key ); + clearExitingItem( key ); + }, EXIT_SAFETY_TIMEOUT_MS ); + exitTimeoutsRef.current.set( key, timeout ); + }, + [ clearExitingItem ] + ); + + useLayoutEffect( () => { + if ( ! enabled || ! container ) { + prevLayoutKeysRef.current = new Set( layoutKeys ); + for ( const timeout of exitTimeoutsRef.current.values() ) { + clearTimeout( timeout ); + } + exitTimeoutsRef.current.clear(); + setExitingItems( [] ); + return; + } + + const prevKeys = prevLayoutKeysRef.current; + const removed: string[] = []; + for ( const key of prevKeys ) { + if ( ! layoutKeys.has( key ) ) { + removed.push( key ); + } + } + prevLayoutKeysRef.current = new Set( layoutKeys ); + + if ( removed.length === 0 ) { + return; + } + + const lastPositions = getPositionsBeforeLastChange(); + if ( ! lastPositions ) { + return; + } + + const nextExiting: ExitingGridItem[] = []; + for ( const key of removed ) { + const position = lastPositions.get( key ); + const child = childrenCacheRef.current.get( key ); + if ( ! position || ! child ) { + continue; + } + nextExiting.push( { + key, + rect: position, + child, + } ); + } + + if ( nextExiting.length === 0 ) { + return; + } + + if ( prefersReducedMotion() ) { + // Siblings snap into place via the layout-shift hook; skip the + // exit ghost (and its synchronous mount) entirely. + for ( const { key } of nextExiting ) { + childrenCacheRef.current.delete( key ); + } + return; + } + + // A state update inside a layout effect is flushed before paint, + // so the ghost mounts in the same frame the tile is removed. + setExitingItems( current => [ ...current, ...nextExiting ] ); + + for ( const { key } of nextExiting ) { + scheduleExitComplete( key ); + } + }, [ + container, + enabled, + getPositionsBeforeLastChange, + layoutKeys, + childrenCacheRef, + scheduleExitComplete, + ] ); + + useLayoutEffect( () => { + const exitTimeouts = exitTimeoutsRef.current; + return () => { + for ( const timeout of exitTimeouts.values() ) { + clearTimeout( timeout ); + } + exitTimeouts.clear(); + }; + }, [] ); + + return { + exitingItems, + hasExitingItems: exitingItems.length > 0, + clearExitingItem, + }; +} diff --git a/projects/js-packages/grid/src/shared/use-layout-shift-animation.ts b/projects/js-packages/grid/src/shared/use-layout-shift-animation.ts new file mode 100644 index 000000000000..56691c3a2527 --- /dev/null +++ b/projects/js-packages/grid/src/shared/use-layout-shift-animation.ts @@ -0,0 +1,283 @@ +/** + * WordPress dependencies + */ +import { useCallback, useLayoutEffect, useRef } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { GRID_ITEM_DATA_KEY } from './grid-item-key'; + +/* `left`/`top` are relative to the grid container, not the viewport. */ +export type RectSnapshot = { + left: number; + top: number; + width: number; + height: number; +}; + +type UseLayoutShiftAnimationOptions = { + /** + * Surface root that contains grid tiles. + */ + container: HTMLElement | null; + + /** + * When false, snapshots are cleared and no transforms run. + */ + enabled: boolean; + + /** + * Serialized layout/placement state. The hook runs FLIP when this + * value changes while `enabled` is true. + */ + layoutFingerprint: string; + + /** + * Item key to skip (the tile being dragged or resized). + */ + excludeItemKey?: string | null; +}; + +type UseLayoutShiftAnimationResult = { + /** + * Capture tile positions synchronously **before** a layout update + * (call immediately before `setTemporaryLayout` / similar). + */ + captureLayoutSnapshot: () => void; + + /** + * Container-relative rects from the last committed paint (settled, no + * FLIP invert transforms). + */ + getLastPositions: () => ReadonlyMap< string, RectSnapshot > | null; + + /** + * Tile positions immediately before the latest layout commit. Used + * by item-exit animation when keys drop out of `layout`. + */ + getPositionsBeforeLastChange: () => ReadonlyMap< string, RectSnapshot > | null; +}; + +/** + * + * @param container + */ +function queryGridItems( container: HTMLElement ): HTMLElement[] { + return Array.from( + container.querySelectorAll< HTMLElement >( + `[${ GRID_ITEM_DATA_KEY }]:not([data-wp-grid-item-exiting])` + ) + ); +} + +/** + * + * @param element + */ +function readItemKey( element: HTMLElement ): string | null { + return element.getAttribute( GRID_ITEM_DATA_KEY ); +} + +/** + * + * @param container + */ +function snapshotPositions( container: HTMLElement ): Map< string, RectSnapshot > { + // Measure relative to the container so positions stay valid even if the + // page scroll shifts between capture and use (e.g. the document reflowing + // shorter after a tile is removed). + const base = container.getBoundingClientRect(); + const positions = new Map< string, RectSnapshot >(); + for ( const element of queryGridItems( container ) ) { + const key = readItemKey( element ); + if ( ! key ) { + continue; + } + const { left, top, width, height } = element.getBoundingClientRect(); + positions.set( key, { + left: left - base.left, + top: top - base.top, + width, + height, + } ); + } + return positions; +} + +/** + * + * @param element + */ +function clearLayoutShiftStyles( element: HTMLElement ): void { + element.style.removeProperty( 'transform' ); + element.style.removeProperty( 'transition' ); +} + +/** + * + * @param element + * @param deltaX + * @param deltaY + */ +function playLayoutShift( element: HTMLElement, deltaX: number, deltaY: number ): void { + if ( deltaX === 0 && deltaY === 0 ) { + return; + } + + // Invert: show the tile where it was before the layout change. + element.style.transition = 'none'; + element.style.transform = `translate(${ deltaX }px, ${ deltaY }px)`; + void element.offsetHeight; + + // Play on the next frame so the inverted transform paints before + // the transition back to the committed grid position. + requestAnimationFrame( () => { + element.style.removeProperty( 'transition' ); + element.style.transform = ''; + + const onTransitionEnd = ( event: TransitionEvent ) => { + if ( event.propertyName !== 'transform' ) { + return; + } + element.removeEventListener( 'transitionend', onTransitionEnd ); + clearLayoutShiftStyles( element ); + }; + element.addEventListener( 'transitionend', onTransitionEnd ); + } ); +} + +/** + * Animates sibling tiles when grid layout reflows during drag or resize + * using a FLIP transform (see `layout-shift-animation.module.css`). + * + * @param root0 - Hook options. + * @param root0.container - Surface root that contains grid tiles. + * @param root0.enabled - When false, snapshots are cleared and no transforms run. + * @param root0.layoutFingerprint - Serialized layout/placement state. + * @param root0.excludeItemKey - Item key to skip (the tile being dragged or resized). + * @return Snapshot capture callback for use before layout updates. + */ +export function useLayoutShiftAnimation( { + container, + enabled, + layoutFingerprint, + excludeItemKey = null, +}: UseLayoutShiftAnimationOptions ): UseLayoutShiftAnimationResult { + const snapshotBeforeChangeRef = useRef< Map< string, RectSnapshot > | null >( null ); + const lastRenderedPositionsRef = useRef< Map< string, RectSnapshot > | null >( null ); + const positionsBeforeLastChangeRef = useRef< Map< string, RectSnapshot > | null >( null ); + + const captureLayoutSnapshot = useCallback( () => { + if ( container ) { + snapshotBeforeChangeRef.current = snapshotPositions( container ); + } + }, [ container ] ); + + useLayoutEffect( () => { + if ( ! container || ! enabled ) { + snapshotBeforeChangeRef.current = null; + lastRenderedPositionsRef.current = null; + positionsBeforeLastChangeRef.current = null; + if ( container ) { + for ( const element of queryGridItems( container ) ) { + clearLayoutShiftStyles( element ); + } + } + return; + } + + for ( const element of queryGridItems( container ) ) { + clearLayoutShiftStyles( element ); + } + + const previous = snapshotBeforeChangeRef.current ?? lastRenderedPositionsRef.current; + snapshotBeforeChangeRef.current = null; + + positionsBeforeLastChangeRef.current = previous ? new Map( previous ) : null; + + // Record settled grid positions for the next FLIP. Must run before + // invert transforms — measuring after `playLayoutShift` would bake + // translate offsets into the baseline and skew the next animation. + lastRenderedPositionsRef.current = snapshotPositions( container ); + + if ( previous ) { + const base = container.getBoundingClientRect(); + for ( const element of queryGridItems( container ) ) { + const key = readItemKey( element ); + if ( ! key || key === excludeItemKey ) { + continue; + } + const old = previous.get( key ); + if ( ! old ) { + continue; + } + const { left, top } = element.getBoundingClientRect(); + const deltaX = old.left - ( left - base.left ); + const deltaY = old.top - ( top - base.top ); + playLayoutShift( element, deltaX, deltaY ); + } + } + }, [ container, enabled, layoutFingerprint, excludeItemKey ] ); + + const getLastPositions = useCallback( () => { + return lastRenderedPositionsRef.current; + }, [] ); + + const getPositionsBeforeLastChange = useCallback( () => { + return positionsBeforeLastChangeRef.current; + }, [] ); + + return { + captureLayoutSnapshot, + getLastPositions, + getPositionsBeforeLastChange, + }; +} + +/** + * Stable fingerprint for {@link useLayoutShiftAnimation}. Width/height + * values may be numbers or layout keywords (`'fill'`, `'full'`). + * + * @param layout - Layout items to serialize. + * @return Fingerprint string. + */ +export function getLayoutFingerprint( + layout: ReadonlyArray< { + key: string; + width?: number | string; + height?: number; + order?: number; + lane?: number; + } > +): string { + return layout + .map( + item => + `${ item.key }:${ String( item.width ?? '' ) }:${ item.height ?? 1 }:${ + item.order ?? '' + }:${ item.lane ?? '' }` + ) + .join( '|' ); +} + +/** + * Placement fingerprint for lanes polyfill / explicit grid positions. + * + * @param itemStyles - Per-item inline placement styles. + * @return Fingerprint string. + */ +export function getPlacementFingerprint( itemStyles: Map< string, React.CSSProperties > ): string { + return [ ...itemStyles.entries() ] + .sort( ( [ a ], [ b ] ) => a.localeCompare( b ) ) + .map( ( [ key, style ] ) => { + const column = style.gridColumn ?? ''; + const columnStart = style.gridColumnStart ?? ''; + const rowStart = style.gridRowStart ?? ''; + const rowEnd = style.gridRowEnd ?? ''; + return `${ key }:${ String( column ) }:${ String( columnStart ) }:${ String( + rowStart + ) }:${ String( rowEnd ) }`; + } ) + .join( '|' ); +} diff --git a/projects/js-packages/grid/src/style-imports.d.ts b/projects/js-packages/grid/src/style-imports.d.ts new file mode 100644 index 000000000000..42171ccf601c --- /dev/null +++ b/projects/js-packages/grid/src/style-imports.d.ts @@ -0,0 +1,12 @@ +declare module '*.module.css' { + const classes: { [ key: string ]: string }; + export default classes; +} + +declare module '*.module.scss' { + const classes: { [ key: string ]: string }; + export default classes; +} + +declare module '*.css'; +declare module '*.scss'; diff --git a/projects/js-packages/grid/tsconfig.json b/projects/js-packages/grid/tsconfig.json new file mode 100644 index 000000000000..ce68cd9f21b8 --- /dev/null +++ b/projects/js-packages/grid/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "jetpack-js-tools/tsconfig.base.json", + "compilerOptions": { + "noEmit": true + }, + "include": [ "src/**/*" ] +} diff --git a/projects/js-packages/widget-dashboard/.gitignore b/projects/js-packages/widget-dashboard/.gitignore new file mode 100644 index 000000000000..7e5da87a90b0 --- /dev/null +++ b/projects/js-packages/widget-dashboard/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +vendor/ diff --git a/projects/js-packages/widget-dashboard/CHANGELOG.md b/projects/js-packages/widget-dashboard/CHANGELOG.md new file mode 100644 index 000000000000..03a962f457f6 --- /dev/null +++ b/projects/js-packages/widget-dashboard/CHANGELOG.md @@ -0,0 +1,6 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). diff --git a/projects/js-packages/widget-dashboard/README.md b/projects/js-packages/widget-dashboard/README.md new file mode 100644 index 000000000000..de23e1ed67da --- /dev/null +++ b/projects/js-packages/widget-dashboard/README.md @@ -0,0 +1,31 @@ +# @automattic/jetpack-widget-dashboard + +Internal, **private** port of WordPress core's dashboard widget engine (the future +`@wordpress/dashboard`): the stateless `WidgetDashboard` rendering engine plus the dashboard +preference hooks (`useDashboardLayout`, `useDashboardGridSettings`). The consumer owns layout +and edit-mode state; the engine renders and emits change events. + +Depends on `@automattic/jetpack-widget-primitives` (widget contract + discovery) and +`@automattic/jetpack-grid` (grid surface). Consumed as **source** (`exports` → `./src/index.ts`); +the host build (`wp-build`) compiles the TypeScript and CSS modules. Ships no build output. + +## Provenance + +Ported from [`WordPress/gutenberg`](https://github.com/WordPress/gutenberg) +`routes/dashboard/{widget-dashboard, hooks, lock-unlock.ts}` @ commit `8a40c807e86` +(branch `refactor/wp-build-name-as-module-id`). Cross-package imports were rewritten: +`@wordpress/grid` → `@automattic/jetpack-grid`, `../widget-primitives` → +`@automattic/jetpack-widget-primitives`. Keep this commit reference updated when syncing. + +### Local deviations from core + +- `src/widget-dashboard/utils/normalize-grid-settings/normalize-grid-settings.ts`: the return + spread casts `settings as WidgetGridLayoutSettings`. `tsgo` does not narrow the settings union + after the `( settings.model ?? 'grid' ) === 'masonry'` guard, so the cast (mirroring the one + already on the line above) is needed to add `rowHeight`. Semantically identical to core. +- `eslint.config.mjs` relaxes several rules that conflict with core's house style (see that file). + +## Privacy + +`"private": true` in `package.json` + `composer.json` without `npmjs-autopublish`/`mirror-repo` +guarantees this package is never published to npm or mirrored to a standalone repo. diff --git a/projects/js-packages/widget-dashboard/changelog/initial-version b/projects/js-packages/widget-dashboard/changelog/initial-version new file mode 100644 index 000000000000..18b26a8112e5 --- /dev/null +++ b/projects/js-packages/widget-dashboard/changelog/initial-version @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Initial version: private port of WordPress core's dashboard widget engine (the stateless WidgetDashboard rendering engine and dashboard preference hooks). diff --git a/projects/js-packages/widget-dashboard/composer.json b/projects/js-packages/widget-dashboard/composer.json new file mode 100644 index 000000000000..436f45cc9686 --- /dev/null +++ b/projects/js-packages/widget-dashboard/composer.json @@ -0,0 +1,21 @@ +{ + "name": "automattic/jetpack-js-widget-dashboard", + "description": "Stateless rendering engine for widget dashboards. Internal port of @wordpress/dashboard until it is published.", + "type": "library", + "license": "GPL-2.0-or-later", + "require": {}, + "repositories": [ + { + "type": "path", + "url": "../../packages/*", + "options": { + "monorepo": true + } + } + ], + "minimum-stability": "dev", + "prefer-stable": true, + "extra": { + "textdomain": "jetpack-widget-dashboard" + } +} diff --git a/projects/js-packages/widget-dashboard/eslint.config.mjs b/projects/js-packages/widget-dashboard/eslint.config.mjs new file mode 100644 index 000000000000..506fe7cca777 --- /dev/null +++ b/projects/js-packages/widget-dashboard/eslint.config.mjs @@ -0,0 +1,29 @@ +import { makeBaseConfig, defineConfig, javascriptFiles } from 'jetpack-js-tools/eslintrc/base.mjs'; + +// `src/` is vendored verbatim from WordPress core's dashboard widget engine. +// These rules conflict with core's house style; turning them off keeps the port +// faithful and avoids churn on every upstream re-sync. Drop once core publishes it. +export default defineConfig( makeBaseConfig( import.meta.url ), { + files: javascriptFiles, + rules: { + 'react/jsx-no-bind': 'off', + 'import/order': 'off', + 'no-shadow': 'off', + 'no-useless-escape': 'off', + '@stylistic/max-line-length': 'off', + '@typescript-eslint/no-unused-vars': [ + 'error', + { + ignoreRestSiblings: true, + varsIgnorePattern: '^_', + argsIgnorePattern: '^_', + caughtErrors: 'none', + }, + ], + 'jsdoc/check-indentation': 'off', + 'jsdoc/escape-inline-tags': 'off', + 'jsdoc/require-description': 'off', + 'jsdoc/require-param-description': 'off', + 'jsdoc/require-returns': 'off', + }, +} ); diff --git a/projects/js-packages/widget-dashboard/package.json b/projects/js-packages/widget-dashboard/package.json new file mode 100644 index 000000000000..060427112b75 --- /dev/null +++ b/projects/js-packages/widget-dashboard/package.json @@ -0,0 +1,61 @@ +{ + "name": "@automattic/jetpack-widget-dashboard", + "version": "0.1.0-alpha", + "private": true, + "description": "Stateless rendering engine for widget dashboards. Internal port of @wordpress/dashboard until it is published.", + "license": "GPL-2.0-or-later", + "author": "Automattic", + "type": "module", + "sideEffects": [ + "**/*.module.css" + ], + "exports": { + ".": { + "jetpack:src": "./src/index.ts", + "types": "./src/index.ts", + "import": "./src/index.ts", + "default": "./src/index.ts" + } + }, + "main": "./src/index.ts", + "module": "./src/index.ts", + "types": "./src/index.ts", + "scripts": { + "typecheck": "tsgo --noEmit" + }, + "dependencies": { + "@automattic/jetpack-grid": "workspace:*", + "@automattic/jetpack-widget-primitives": "workspace:*", + "@wordpress/api-fetch": "7.46.0", + "@wordpress/commands": "1.46.0", + "@wordpress/components": "33.1.0", + "@wordpress/compose": "7.46.0", + "@wordpress/core-data": "7.46.0", + "@wordpress/data": "10.46.0", + "@wordpress/dataviews": "14.3.0", + "@wordpress/element": "6.46.0", + "@wordpress/i18n": "6.19.0", + "@wordpress/icons": "13.1.0", + "@wordpress/preferences": "4.46.0", + "@wordpress/primitives": "4.46.0", + "@wordpress/private-apis": "1.46.0", + "@wordpress/ui": "0.13.0", + "@wordpress/viewport": "6.46.0", + "clsx": "^2.1.1", + "fast-deep-equal": "^3.1.3", + "uuid": "^14.0.0" + }, + "devDependencies": { + "@types/react": "^18.3.27", + "@types/react-dom": "^18.3.7", + "@typescript/native-preview": "7.0.0-dev.20260225.1", + "jetpack-js-tools": "workspace:*", + "react": "18.3.1", + "react-dom": "18.3.1", + "typescript": "5.9.3" + }, + "peerDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0" + } +} diff --git a/projects/js-packages/widget-dashboard/src/hooks/index.ts b/projects/js-packages/widget-dashboard/src/hooks/index.ts new file mode 100644 index 000000000000..1d43c15e9fdd --- /dev/null +++ b/projects/js-packages/widget-dashboard/src/hooks/index.ts @@ -0,0 +1,2 @@ +export { useDashboardLayout } from './use-dashboard-layout'; +export { useDashboardGridSettings } from './use-dashboard-grid-settings'; diff --git a/projects/js-packages/widget-dashboard/src/hooks/use-dashboard-grid-settings/index.ts b/projects/js-packages/widget-dashboard/src/hooks/use-dashboard-grid-settings/index.ts new file mode 100644 index 000000000000..23780921eb40 --- /dev/null +++ b/projects/js-packages/widget-dashboard/src/hooks/use-dashboard-grid-settings/index.ts @@ -0,0 +1 @@ +export { useDashboardGridSettings } from './use-dashboard-grid-settings'; diff --git a/projects/js-packages/widget-dashboard/src/hooks/use-dashboard-grid-settings/use-dashboard-grid-settings.ts b/projects/js-packages/widget-dashboard/src/hooks/use-dashboard-grid-settings/use-dashboard-grid-settings.ts new file mode 100644 index 000000000000..ccb838d17f5f --- /dev/null +++ b/projects/js-packages/widget-dashboard/src/hooks/use-dashboard-grid-settings/use-dashboard-grid-settings.ts @@ -0,0 +1,85 @@ +/** + * External dependencies + */ +import fastDeepEqual from 'fast-deep-equal/es6/index.js'; + +/** + * WordPress dependencies + */ +import { useDispatch, useSelect } from '@wordpress/data'; +import { store as preferencesStore } from '@wordpress/preferences'; + +/** + * Internal dependencies + */ +import type { WidgetGridSettings } from '../../widget-dashboard/types'; +import { WIDGET_DASHBOARD_COLUMN_COUNT } from '../../widget-dashboard/types'; +import { normalizeGridSettings } from '../../widget-dashboard/utils/normalize-grid-settings'; +import { DEFAULT_ROW_HEIGHT } from '../../widget-dashboard/utils/row-height-presets'; + +const SCOPE = 'core/dashboard'; +const KEY = 'dashboardGridSettings'; + +/** + * Default grid settings applied when the preferences store has no + * entry yet, and the value `resetGridSettings` writes back when the + * user requests a reset. Kept aligned with the in-component default + * in `WidgetDashboardProvider` so consumers see consistent values + * whether or not they wire up this hook. + */ +const DEFAULT_GRID_SETTINGS: WidgetGridSettings = { + model: 'grid', + columns: WIDGET_DASHBOARD_COLUMN_COUNT, + rowHeight: DEFAULT_ROW_HEIGHT, +}; + +/** + * Hook for managing dashboard grid-settings preferences. + * + * Returns the persisted settings, a setter that writes through to the + * preferences store, and a reset action that applies the bundled + * defaults. The preference is shared across dashboards today; if a + * per-dashboard split is needed later, the signature can grow a + * dashboard-identifying parameter without touching call sites that + * pass the dashboard's name through. + * + * @return Tuple `[ settings, setSettings, resetSettings ]`. + */ +export function useDashboardGridSettings(): [ + WidgetGridSettings, + ( settings: WidgetGridSettings ) => void, + () => void, +] { + const settings = useSelect( select => { + const stored = select( preferencesStore ).get( SCOPE, KEY ) as WidgetGridSettings | undefined; + return normalizeGridSettings( stored ?? DEFAULT_GRID_SETTINGS, DEFAULT_ROW_HEIGHT ); + }, [] ); + + const { set } = useDispatch( preferencesStore ); + + /** + * + * @param next + */ + function setSettings( next: WidgetGridSettings ) { + // Persist "back to default" as a cleared preference rather than a stored + // copy of the defaults: the dashboard then tracks the current code + // default and the value can never drift. Reset routes through here (the + // drawer commit fires the setter with the default), so this is what makes + // Reset + Save truly clear the stored preference. + if ( fastDeepEqual( next, DEFAULT_GRID_SETTINGS ) ) { + void set( SCOPE, KEY, null ); + return; + } + void set( SCOPE, KEY, next ); + } + + /** + * + */ + function resetSettings() { + void set( SCOPE, KEY, null ); + } + + return [ settings, setSettings, resetSettings ]; +} diff --git a/projects/js-packages/widget-dashboard/src/hooks/use-dashboard-layout/index.ts b/projects/js-packages/widget-dashboard/src/hooks/use-dashboard-layout/index.ts new file mode 100644 index 000000000000..aa12cedfa261 --- /dev/null +++ b/projects/js-packages/widget-dashboard/src/hooks/use-dashboard-layout/index.ts @@ -0,0 +1,2 @@ +export { useDashboardLayout } from './use-dashboard-layout'; +export type { DashboardName } from './use-dashboard-layout'; diff --git a/projects/js-packages/widget-dashboard/src/hooks/use-dashboard-layout/use-dashboard-layout.ts b/projects/js-packages/widget-dashboard/src/hooks/use-dashboard-layout/use-dashboard-layout.ts new file mode 100644 index 000000000000..539e9c56fad3 --- /dev/null +++ b/projects/js-packages/widget-dashboard/src/hooks/use-dashboard-layout/use-dashboard-layout.ts @@ -0,0 +1,66 @@ +/** + * WordPress dependencies + */ +import apiFetch from '@wordpress/api-fetch'; +import { useDispatch, useSelect } from '@wordpress/data'; +import { store as preferencesStore } from '@wordpress/preferences'; + +/** + * Internal dependencies + */ +import type { DashboardWidget } from '../../widget-dashboard'; + +const SCOPE = 'core/dashboard'; +const KEY = 'dashboardLayout'; + +/** + * Identifier of a dashboard, structured as `_` to mirror + * the underscore form produced by the wp-build pipeline (see + * `{{PREFIX}}_{{PAGE_SLUG_UNDERSCORE}}` in the page templates). + */ +export type DashboardName = `${ string }_${ string }`; + +/** + * Hook for managing dashboard layout preferences. + * + * Returns the persisted layout, a setter that writes through to the + * preferences store, and a reset action that fetches the dashboard's + * registered default from the REST API and applies it locally. + * + * @param dashboardName - Identifier of the dashboard as produced by the + * build pipeline. Used as the `{name}` segment of + * the default-layout route. + * @return Tuple `[ layout, setLayout, resetLayout ]`. + */ +export function useDashboardLayout( + dashboardName: DashboardName +): [ DashboardWidget[], ( layout: DashboardWidget[] ) => void, () => Promise< void > ] { + const layout = useSelect( + select => + ( select( preferencesStore ).get( SCOPE, KEY ) as DashboardWidget[] | undefined ) ?? [], + [] + ); + + const { set } = useDispatch( preferencesStore ); + + /** + * + * @param newLayout + */ + function setLayout( newLayout: DashboardWidget[] ) { + void set( SCOPE, KEY, newLayout ); + } + + /** + * + */ + async function resetLayout() { + const fresh = ( await apiFetch( { + path: `/wp/v2/dashboards/${ dashboardName }/default-layout`, + } ) ) as DashboardWidget[]; + + void set( SCOPE, KEY, fresh ); + } + + return [ layout, setLayout, resetLayout ]; +} diff --git a/projects/js-packages/widget-dashboard/src/index.ts b/projects/js-packages/widget-dashboard/src/index.ts new file mode 100644 index 000000000000..f0a9e1da27f0 --- /dev/null +++ b/projects/js-packages/widget-dashboard/src/index.ts @@ -0,0 +1,10 @@ +/** + * Widget dashboard rendering engine. + */ +export { WidgetDashboard } from './widget-dashboard'; +export type { DashboardWidget } from './widget-dashboard'; + +/** + * Dashboard preference hooks. + */ +export { useDashboardLayout, useDashboardGridSettings } from './hooks'; diff --git a/projects/js-packages/widget-dashboard/src/lock-unlock.ts b/projects/js-packages/widget-dashboard/src/lock-unlock.ts new file mode 100644 index 000000000000..bf6969038fcc --- /dev/null +++ b/projects/js-packages/widget-dashboard/src/lock-unlock.ts @@ -0,0 +1,9 @@ +/** + * WordPress dependencies + */ +import { __dangerousOptInToUnstableAPIsOnlyForCoreModules } from '@wordpress/private-apis'; + +export const { lock, unlock } = __dangerousOptInToUnstableAPIsOnlyForCoreModules( + 'I acknowledge private features are not for use in themes or plugins and doing so will break in the next version of WordPress.', + '@wordpress/routes' +); diff --git a/projects/js-packages/widget-dashboard/src/style-imports.d.ts b/projects/js-packages/widget-dashboard/src/style-imports.d.ts new file mode 100644 index 000000000000..42171ccf601c --- /dev/null +++ b/projects/js-packages/widget-dashboard/src/style-imports.d.ts @@ -0,0 +1,12 @@ +declare module '*.module.css' { + const classes: { [ key: string ]: string }; + export default classes; +} + +declare module '*.module.scss' { + const classes: { [ key: string ]: string }; + export default classes; +} + +declare module '*.css'; +declare module '*.scss'; diff --git a/projects/js-packages/widget-dashboard/src/widget-dashboard/README.md b/projects/js-packages/widget-dashboard/src/widget-dashboard/README.md new file mode 100644 index 000000000000..bb5b2ba933c3 --- /dev/null +++ b/projects/js-packages/widget-dashboard/src/widget-dashboard/README.md @@ -0,0 +1,145 @@ +# `WidgetDashboard` + +Stateless rendering engine for widget dashboards. Renders an editable grid of widget instances, with drag-to-reorder and resize when edit mode is on. +Widget types flow in as a prop and every layout mutation fires `onLayoutChange` with the fully updated array. +The engine owns no data of its own. + +## Usage + +```tsx +import { useState } from '@wordpress/element'; +import { WidgetDashboard } from './widget-dashboard'; + +function Dashboard() { + const [ layout, setLayout ] = useState( defaultLayout ); + + return ( + + ); +} +``` + +`` renders `` by default. Pass `children` to compose the dashboard — header, empty state, footer — around the grid: + +```tsx + + +

{ __( 'No widgets yet.' ) }

+
+ +
+``` + +## Properties + +#### `layout`: `DashboardWidget[]` + +Widget instances to render. Each instance carries a stable `uuid`, a `type` reference, optional `attributes`, and a `placement` describing its slot in the grid. + +#### `onLayoutChange`: `( layout: DashboardWidget[] ) => void` + +Called on every mutation — reorder, resize, or `setAttributes` from a widget render module. Receives the fully updated array; the consumer owns the storage. + +#### `widgetTypes`: `WidgetType[]` + +The widget types available to the dashboard. + +#### `editMode`: `boolean` + +When `true`, the grid enables drag and resize. Defaults to `false`. + +#### `onEditChange`: `( next: boolean ) => void` + +Optional. Called when edit mode toggles via `WidgetDashboard.Actions` (or any consumer-built toggle). When omitted, `WidgetDashboard.Actions` renders nothing. + +#### `resolveWidgetModule`: `( moduleId: string ) => Promise< { default: ComponentType } >` + +Optional. Maps a `WidgetType.renderModule` id to the React component that renders the widget. Defaults to a dynamic `import( /* webpackIgnore */ moduleId )`. Override for tests, Storybook, or remote-URL loading. + +#### `gridSettings`: `WidgetGridSettings` + +Optional. Configures the underlying grid. + +#### `children`: `ReactNode` + +Optional. Composition slot for arbitrary dashboard markup. When omitted, the engine renders `` directly. + +## Compound components + +#### `` + +Iterates `layout`, renders each entry through ``, and feeds the resulting tree into the underlying grid (`@automattic/jetpack-grid`). + +#### `` + +Per-instance wrapper (the `DashboardWidgetChrome` component). Provides widget identity to the render tree via context and hosts the widget's render module under a `Suspense` boundary and an error boundary. The instance is read from `layout`; consumers don't pass it manually. + +#### `` + +Renders its children only when `layout` is empty. Pair it with `` so the empty state shows up in place of the grid until widgets are added. + +#### `` + +Edit-mode toggle: a "Customize" button while `editMode` is off, and "Add widget", "Layout settings" (when `onGridSettingsChange` is provided), "Cancel", "Done" while it is on. Layout settings is only available in customize mode. Clicking "Customize" or "Done" fires `onEditChange` with the toggled value. Clicking "Add widget" opens the inserter (see below). Returns `null` when the dashboard is mounted without `onEditChange`, so surfaces that don't expose edit mode can keep `Actions` in their tree unconditionally. + +`` from `@wordpress/admin-ui` exposes an `actions` slot used across admin screens (DataViews, WidgetDashboard, …). Plug `Actions` straight into it: + +```tsx +import { Page } from '@wordpress/admin-ui'; + + + } + > + + + +``` + +`` is optional. The compound renders inside any container, so a bare `
` or custom chrome works just as well. + +## Inserting widgets + +A modal-based inserter is mounted automatically inside `WidgetDashboard`. It stays hidden until the "Add widget" button in `` is clicked. The inserter lists every entry in the `widgetTypes` prop as a grid of live previews (each preview renders the type's `example` attributes through its own render module), supports search, and exposes a single "Select" action with bulk support so users can insert one or several widgets in a single layout change. + +On confirmation, the inserter creates instances via `createDashboardWidget( widgetType )` (using each type's `example.attributes` as the initial values) and appends them to `layout` through `onLayoutChange`. The dialog closes after a successful insertion or when the user dismisses it. + +The inserter has no opt-out today; hosts that want a custom widget-picking experience should compose their own UI alongside `` and avoid rendering `` (which exposes the trigger). + +## Authoring widgets + +Widget render modules receive only what they need to render and edit: + +```ts +interface WidgetRenderProps< Item = unknown > { + attributes: Item; + setAttributes?: ( next: Partial< Item > ) => void; +} +``` + +`setAttributes` flows back through `onLayoutChange` on the dashboard. Removal, badges, and error chrome are not part of this contract — those belong to the host. + +## Types + +- `DashboardWidget` — a placement of a widget on the dashboard. Carries `uuid`, `type`, `attributes`, `placement`. +- `WidgetType` — runtime widget type. Extends the `widget.json` shape with `renderModule`. +- `WidgetRenderProps` — widget render contract. +- `ResolveWidgetModule` — module resolver signature. +- `WidgetGridSettings` — grid configuration. + +The widget contract types (`WidgetName`, `WidgetType`, `WidgetRenderProps`, `ResolveWidgetModule`) are defined in `@automattic/jetpack-widget-primitives` and imported from there directly; this engine does not re-export them. diff --git a/projects/js-packages/widget-dashboard/src/widget-dashboard/components/actions/actions.module.css b/projects/js-packages/widget-dashboard/src/widget-dashboard/components/actions/actions.module.css new file mode 100644 index 000000000000..6221283ca4af --- /dev/null +++ b/projects/js-packages/widget-dashboard/src/widget-dashboard/components/actions/actions.module.css @@ -0,0 +1,59 @@ +.editActionsEnter, +.editActionsExit { + display: inline-flex; + align-items: center; + gap: var(--wp--preset--spacing--20); + transform-origin: right center; +} + +.editActionsDivider { + flex-shrink: 0; + align-self: center; + width: 1px; + + /* TODO: Replace `$button-size-small` with a WPDS size token once size tokens land in `@wordpress/theme`. */ + height: calc(4 * var(--wpds-dimension-base)); + background-color: var(--wpds-color-stroke-surface-neutral); +} + +@media not ( prefers-reduced-motion ) { + + .editActionsEnter, + .editActionsExit { + will-change: opacity, transform; + } + + .editActionsEnter { + animation: actions-slide-in var(--wpds-motion-duration-md) var(--wpds-motion-easing-expressive) forwards; + } + + .editActionsExit { + animation: actions-fold-out var(--wpds-motion-duration-sm) var(--wpds-motion-easing-balanced) forwards; + } + + @keyframes actions-slide-in { + + from { + opacity: 0; + transform: translateX(12px) scaleX(0.92); + } + + to { + opacity: 1; + transform: translateX(0) scaleX(1); + } + } + + @keyframes actions-fold-out { + + from { + opacity: 1; + transform: translateX(0) scaleX(1); + } + + to { + opacity: 0; + transform: translateX(12px) scaleX(0.92); + } + } +} diff --git a/projects/js-packages/widget-dashboard/src/widget-dashboard/components/actions/actions.tsx b/projects/js-packages/widget-dashboard/src/widget-dashboard/components/actions/actions.tsx new file mode 100644 index 000000000000..5657b31c8379 --- /dev/null +++ b/projects/js-packages/widget-dashboard/src/widget-dashboard/components/actions/actions.tsx @@ -0,0 +1,192 @@ +/** + * WordPress dependencies + */ +import { useSelect } from '@wordpress/data'; +import { useCallback, useEffect, useState } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { layout as layoutIcon, plus } from '@wordpress/icons'; +import { store as viewportStore } from '@wordpress/viewport'; + +import { AlertDialog, Button, Stack } from '@wordpress/ui'; + +/** + * Internal dependencies + */ +import styles from './actions.module.css'; +import { useDashboardInternalContext } from '../../context/dashboard-context'; +import { useDashboardUIContext } from '../../context/ui-context'; +import { LayoutSettings } from '../layout-settings'; +import { MoreActionsDropdown } from '../more-actions-dropdown'; +import type { MoreActionsDropdownItem } from '../more-actions-dropdown'; + +/** + * Header chrome for the dashboard. Customize mode surfaces an edit + * toolbar with Add widget, Layout settings (when grid settings are + * editable), Cancel, and Done. Layout settings opens a side drawer + * for model, column behavior, and row height; Save inside the drawer + * commits the settings staging buffer without leaving customize mode. + * Widget layout edits and grid settings share the same staging layer + * while customize mode is active. + * + * Returns `null` when the dashboard is mounted without `onEditChange` + * so hosts that don't expose edit mode can keep `Actions` in their + * tree unconditionally. + * + * @return {React.ReactNode} - The Actions component. + */ +export function Actions(): React.ReactNode { + const { + editMode, + onEditChange, + onLayoutReset, + commit, + cancel: cancelStaging, + hasUncommittedChanges, + canEditGridSettings, + } = useDashboardInternalContext(); + + const [ isEditActionsMounted, setIsEditActionsMounted ] = useState( editMode ); + const [ isExitingEditActions, setIsExitingEditActions ] = useState( false ); + + useEffect( () => { + if ( editMode ) { + setIsEditActionsMounted( true ); + setIsExitingEditActions( false ); + return; + } + + if ( ! isEditActionsMounted ) { + return; + } + + setIsExitingEditActions( true ); + const exitTimeout = setTimeout( () => { + setIsEditActionsMounted( false ); + setIsExitingEditActions( false ); + }, 220 ); + + return () => clearTimeout( exitTimeout ); + }, [ editMode, isEditActionsMounted ] ); + + const { + setInserterOpen, + layoutSettingsOpen, + setLayoutSettingsOpen, + resetDialogOpen, + setResetDialogOpen, + } = useDashboardUIContext(); + // @TODO: switch to using Admin UI declaratively for mobile viewport support once available. + // https://github.com/WordPress/gutenberg/issues/77628 + const isMobileViewport = useSelect( + select => select( viewportStore ).isViewportMatch( '< small' ), + [] + ); + + const handleEditMode = useCallback( () => { + onEditChange?.( ! editMode ); + }, [ editMode, onEditChange ] ); + + const insert = useCallback( () => { + setInserterOpen( true ); + }, [ setInserterOpen ] ); + + const cancel = useCallback( () => { + cancelStaging(); + }, [ cancelStaging ] ); + + const done = useCallback( () => { + commit(); + }, [ commit ] ); + + const openLayoutSettings = useCallback( () => { + setLayoutSettingsOpen( true ); + }, [ setLayoutSettingsOpen ] ); + + useEffect( () => { + if ( ! editMode && layoutSettingsOpen ) { + setLayoutSettingsOpen( false ); + } + }, [ editMode, layoutSettingsOpen, setLayoutSettingsOpen ] ); + + const moreActionsItems: MoreActionsDropdownItem[] = [ + { + label: __( 'Reset to default', 'jetpack-widget-dashboard' ), + onClick: () => setResetDialogOpen( true ), + disabled: ! onLayoutReset, + }, + ]; + + if ( ! onEditChange ) { + return null; + } + + return ( + + { isEditActionsMounted ? ( + + + + { canEditGridSettings && ( + + ) } + +