mirror of
https://github.com/cita-777/metapi.git
synced 2026-11-03 06:04:15 +08:00
feat: establish shared web UI lifecycle and primitives
This commit is contained in:
Generated
+458
-4
@@ -14,6 +14,7 @@
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"@fastify/static": "^9.0.0",
|
||||
"@radix-ui/react-dialog": "^1.1.23",
|
||||
"@visactor/react-vchart": "^2.0.22",
|
||||
"better-sqlite3": "^12.10.0",
|
||||
"dotenv": "^17.4.2",
|
||||
@@ -3505,6 +3506,320 @@
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/primitive": {
|
||||
"version": "1.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.7.tgz",
|
||||
"integrity": "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@radix-ui/react-compose-refs": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz",
|
||||
"integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-context": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.2.2.tgz",
|
||||
"integrity": "sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-dialog": {
|
||||
"version": "1.1.23",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.23.tgz",
|
||||
"integrity": "sha512-Ksw4WeROkO4rC9k/onilX/Ao2Cr1ku1unMNH+XSCcP4jSXYu7HDsg9n4ojMjVb22XpYjAQ9qfrFlVbru1vXDUA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.7",
|
||||
"@radix-ui/react-compose-refs": "1.1.5",
|
||||
"@radix-ui/react-context": "1.2.2",
|
||||
"@radix-ui/react-dismissable-layer": "1.1.19",
|
||||
"@radix-ui/react-focus-guards": "1.1.6",
|
||||
"@radix-ui/react-focus-scope": "1.1.16",
|
||||
"@radix-ui/react-id": "1.1.4",
|
||||
"@radix-ui/react-portal": "1.1.17",
|
||||
"@radix-ui/react-presence": "1.1.10",
|
||||
"@radix-ui/react-primitive": "2.1.10",
|
||||
"@radix-ui/react-slot": "1.3.3",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.6",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.4",
|
||||
"aria-hidden": "^1.2.4",
|
||||
"react-remove-scroll": "^2.7.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-dismissable-layer": {
|
||||
"version": "1.1.19",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.19.tgz",
|
||||
"integrity": "sha512-8g4pfOL9HoKKLWGiypT+dphVqjFfmcXO5GBnhsG6zI+lxAx/8feQpr+1LSN8Re3hiZ+XkLNS4O9ztK11/LzQ6w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.7",
|
||||
"@radix-ui/react-compose-refs": "1.1.5",
|
||||
"@radix-ui/react-primitive": "2.1.10",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.4",
|
||||
"@radix-ui/react-use-effect-event": "0.0.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-focus-guards": {
|
||||
"version": "1.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.6.tgz",
|
||||
"integrity": "sha512-RNOJjfZMTyBM6xYmV3IVGXkPjIhcBAuv48POevAXwrGJhkWZ9p1rFoIS1JFooPuT193AZmRsCPhpoVJxx6OPoQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-focus-scope": {
|
||||
"version": "1.1.16",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.16.tgz",
|
||||
"integrity": "sha512-wmRZ2WWLvmt6KHy2rNPOdPUjwq5xOHY02+m+udwJTn0aNIox/rkskAvJTyTLGhPK6KgrUjlJUJpgmx/+wFiFIQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.5",
|
||||
"@radix-ui/react-primitive": "2.1.10",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-id": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.4.tgz",
|
||||
"integrity": "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-layout-effect": "1.1.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-portal": {
|
||||
"version": "1.1.17",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.17.tgz",
|
||||
"integrity": "sha512-vKQLcWypUnwZVvfV7UkGahH2g6ySe8M8R+zYBwPrv5byZ9QAW6cQVvNKo7GgmD+p8aYb6D9JBuvy8/WhOno2wQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-primitive": "2.1.10",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-presence": {
|
||||
"version": "1.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.10.tgz",
|
||||
"integrity": "sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-layout-effect": "1.1.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-primitive": {
|
||||
"version": "2.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.10.tgz",
|
||||
"integrity": "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "1.3.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.3.tgz",
|
||||
"integrity": "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-callback-ref": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.4.tgz",
|
||||
"integrity": "sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-controllable-state": {
|
||||
"version": "1.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.6.tgz",
|
||||
"integrity": "sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.7",
|
||||
"@radix-ui/react-use-effect-event": "0.0.5",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-effect-event": {
|
||||
"version": "0.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.5.tgz",
|
||||
"integrity": "sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-layout-effect": "1.1.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-layout-effect": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.4.tgz",
|
||||
"integrity": "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@resvg/resvg-js": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@resvg/resvg-js/-/resvg-js-2.4.1.tgz",
|
||||
@@ -5131,14 +5446,14 @@
|
||||
"version": "15.7.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "18.3.28",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz",
|
||||
"integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
@@ -5149,7 +5464,7 @@
|
||||
"version": "18.3.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
|
||||
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.0.0"
|
||||
@@ -6205,6 +6520,18 @@
|
||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
||||
"license": "Python-2.0"
|
||||
},
|
||||
"node_modules/aria-hidden": {
|
||||
"version": "1.2.6",
|
||||
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
|
||||
"integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/array-source": {
|
||||
"version": "0.0.4",
|
||||
"resolved": "https://registry.npmjs.org/array-source/-/array-source-0.0.4.tgz",
|
||||
@@ -7277,7 +7604,7 @@
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cytoscape": {
|
||||
@@ -8112,6 +8439,12 @@
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/detect-node-es": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
|
||||
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/devlop": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz",
|
||||
@@ -9653,6 +9986,15 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-nonce": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
|
||||
"integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/get-port": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/get-port/-/get-port-7.2.0.tgz",
|
||||
@@ -12623,6 +12965,53 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-remove-scroll": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
|
||||
"integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-remove-scroll-bar": "^2.3.7",
|
||||
"react-style-singleton": "^2.2.3",
|
||||
"tslib": "^2.1.0",
|
||||
"use-callback-ref": "^1.3.3",
|
||||
"use-sidecar": "^1.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-remove-scroll-bar": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz",
|
||||
"integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-style-singleton": "^2.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "7.15.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.15.0.tgz",
|
||||
@@ -12677,6 +13066,28 @@
|
||||
"react": "^16.0.0 || ^17.0.0 || ^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-style-singleton": {
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
|
||||
"integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"get-nonce": "^1.0.0",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-test-renderer": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-18.3.1.tgz",
|
||||
@@ -14936,6 +15347,49 @@
|
||||
"punycode": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/use-callback-ref": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
|
||||
"integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/use-sidecar": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",
|
||||
"integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"detect-node-es": "^1.1.0",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/utf8-byte-length": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz",
|
||||
|
||||
@@ -72,6 +72,7 @@
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"@fastify/static": "^9.0.0",
|
||||
"@radix-ui/react-dialog": "^1.1.23",
|
||||
"@visactor/react-vchart": "^2.0.22",
|
||||
"better-sqlite3": "^12.10.0",
|
||||
"dotenv": "^17.4.2",
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useAnimatedVisibility } from './useAnimatedVisibility.js';
|
||||
import type React from 'react';
|
||||
import { Dialog } from './ui/Dialog.js';
|
||||
|
||||
type CenteredModalProps = {
|
||||
// Compatibility facade: the Dialog wrapper owns useAnimatedVisibility and
|
||||
// createPortal while preserving the modal-backdrop/modal-content class names.
|
||||
export type CenteredModalProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title: React.ReactNode;
|
||||
@@ -27,64 +28,20 @@ export default function CenteredModal({
|
||||
closeOnEscape = false,
|
||||
showCloseButton = true,
|
||||
}: CenteredModalProps) {
|
||||
const presence = useAnimatedVisibility(open, 220);
|
||||
const canUsePortal = typeof document !== 'undefined'
|
||||
&& !!document.body
|
||||
&& typeof document.body.appendChild === 'function'
|
||||
&& typeof document.body.removeChild === 'function';
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !canUsePortal) return;
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow;
|
||||
};
|
||||
}, [canUsePortal, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !closeOnEscape || !canUsePortal) return;
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') onClose();
|
||||
};
|
||||
document.addEventListener('keydown', handleKeydown);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeydown);
|
||||
};
|
||||
}, [canUsePortal, closeOnEscape, open, onClose]);
|
||||
|
||||
if (!presence.shouldRender) return null;
|
||||
|
||||
const modal = (
|
||||
<div
|
||||
className={`modal-backdrop ${presence.isVisible ? '' : 'is-closing'}`.trim()}
|
||||
onClick={closeOnBackdrop ? onClose : undefined}
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={title}
|
||||
footer={footer}
|
||||
maxWidth={maxWidth}
|
||||
bodyStyle={bodyStyle}
|
||||
closeOnBackdrop={closeOnBackdrop}
|
||||
closeOnEscape={closeOnEscape}
|
||||
showCloseButton={showCloseButton}
|
||||
closeLabel="关闭弹框"
|
||||
>
|
||||
<div
|
||||
className={`modal-content ${presence.isVisible ? '' : 'is-closing'}`.trim()}
|
||||
style={{ maxWidth }}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="modal-header">
|
||||
<div className="modal-title">{title}</div>
|
||||
{showCloseButton ? (
|
||||
<button
|
||||
type="button"
|
||||
className="modal-close-button"
|
||||
onClick={onClose}
|
||||
aria-label="关闭弹框"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="modal-body" style={bodyStyle}>
|
||||
{children}
|
||||
</div>
|
||||
{footer ? <div className="modal-footer">{footer}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
return canUsePortal ? createPortal(modal, document.body) : modal;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import React, { useState } from 'react';
|
||||
import { api } from '../api.js';
|
||||
import { useToast } from './Toast.js';
|
||||
import { persistAuthSession } from '../authSession.js';
|
||||
import { useAnimatedVisibility } from './useAnimatedVisibility.js';
|
||||
import CenteredModal from './CenteredModal.js';
|
||||
import { AsyncButton, Button } from './ui/Button.js';
|
||||
|
||||
export default function ChangeKeyModal({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const presence = useAnimatedVisibility(open, 200);
|
||||
const [oldToken, setOldToken] = useState('');
|
||||
const [newToken, setNewToken] = useState('');
|
||||
const [confirmToken, setConfirmToken] = useState('');
|
||||
@@ -14,15 +13,6 @@ export default function ChangeKeyModal({ open, onClose }: { open: boolean; onClo
|
||||
const [error, setError] = useState('');
|
||||
const toast = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || typeof document === 'undefined') return;
|
||||
const previous = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.body.style.overflow = previous;
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setError('');
|
||||
if (!oldToken || !newToken || !confirmToken) {
|
||||
@@ -51,27 +41,39 @@ export default function ChangeKeyModal({ open, onClose }: { open: boolean; onClo
|
||||
} else {
|
||||
setError(res.message || '更新失败');
|
||||
}
|
||||
} catch (e: any) {
|
||||
setError(e.message || '更新失败');
|
||||
} catch (error: unknown) {
|
||||
setError(error instanceof Error ? error.message : '更新失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!presence.shouldRender) return null;
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: '100%', padding: '10px 14px', border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-sm)', fontSize: 13, outline: 'none',
|
||||
background: 'var(--color-bg)', color: 'var(--color-text-primary)',
|
||||
};
|
||||
|
||||
const modal = (
|
||||
<div className={`modal-backdrop ${presence.isVisible ? '' : 'is-closing'}`.trim()} onClick={onClose}>
|
||||
<div className={`modal-content ${presence.isVisible ? '' : 'is-closing'}`.trim()} onClick={e => e.stopPropagation()} style={{ maxWidth: 420 }}>
|
||||
<div className="modal-header">修改管理员 Token</div>
|
||||
|
||||
<div className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
return (
|
||||
<CenteredModal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title="修改管理员 Token"
|
||||
maxWidth={420}
|
||||
closeOnBackdrop
|
||||
closeOnEscape={false}
|
||||
showCloseButton={false}
|
||||
bodyStyle={{ display: 'flex', flexDirection: 'column', gap: 12 }}
|
||||
footer={(
|
||||
<>
|
||||
<Button onClick={onClose} variant="ghost">取消</Button>
|
||||
<AsyncButton onClick={() => void handleSubmit()} variant="primary" loading={saving} loadingLabel="更新中...">
|
||||
确认修改
|
||||
</AsyncButton>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<>
|
||||
<div>
|
||||
<label style={{ fontSize: 12, fontWeight: 500, color: 'var(--color-text-secondary)', display: 'block', marginBottom: 6 }}>旧 Token</label>
|
||||
<input
|
||||
@@ -108,17 +110,7 @@ export default function ChangeKeyModal({ open, onClose }: { open: boolean; onClo
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="modal-footer">
|
||||
<button onClick={onClose} className="btn btn-ghost">取消</button>
|
||||
<button onClick={handleSubmit} disabled={saving} className="btn btn-primary">
|
||||
{saving ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} />更新中...</> : '确认修改'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</CenteredModal>
|
||||
);
|
||||
|
||||
return typeof document !== 'undefined' ? createPortal(modal, document.body) : modal;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import CenteredModal from './CenteredModal.js';
|
||||
import { AsyncButton, Button } from './ui/Button.js';
|
||||
|
||||
type DeleteConfirmModalProps = {
|
||||
open: boolean;
|
||||
@@ -31,12 +32,10 @@ export default function DeleteConfirmModal({
|
||||
bodyStyle={{ display: 'flex', flexDirection: 'column', gap: 12 }}
|
||||
footer={(
|
||||
<>
|
||||
<button onClick={onClose} className="btn btn-ghost" disabled={loading}>{cancelText}</button>
|
||||
<button onClick={onConfirm} className="btn btn-danger" disabled={loading}>
|
||||
{loading
|
||||
? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 删除中...</>
|
||||
: confirmText}
|
||||
</button>
|
||||
<Button onClick={onClose} variant="ghost" disabled={loading}>{cancelText}</Button>
|
||||
<AsyncButton onClick={onConfirm} variant="danger" loading={loading} loadingLabel="删除中...">
|
||||
{confirmText}
|
||||
</AsyncButton>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import React, { useEffect, useId, useMemo, useState, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import type React from 'react';
|
||||
import { Drawer } from './ui/Dialog.js';
|
||||
|
||||
type MobileDrawerProps = {
|
||||
export type MobileDrawerProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
title?: React.ReactNode;
|
||||
closeLabel?: string;
|
||||
side?: 'left' | 'right';
|
||||
closeOnBackdrop?: boolean;
|
||||
closeOnEscape?: boolean;
|
||||
showCloseButton?: boolean;
|
||||
ariaLabel?: string;
|
||||
ariaDescribedBy?: string;
|
||||
};
|
||||
|
||||
function MobileDrawer({
|
||||
@@ -17,80 +22,28 @@ function MobileDrawer({
|
||||
title,
|
||||
closeLabel = '关闭导航',
|
||||
side = 'left',
|
||||
closeOnBackdrop = true,
|
||||
closeOnEscape = true,
|
||||
showCloseButton = true,
|
||||
ariaLabel,
|
||||
ariaDescribedBy,
|
||||
}: MobileDrawerProps) {
|
||||
const [shouldRender, setShouldRender] = useState(open);
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
const titleId = useId();
|
||||
const labelledBy = title ? titleId : undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setShouldRender(true);
|
||||
setIsClosing(false);
|
||||
} else if (shouldRender) {
|
||||
setIsClosing(true);
|
||||
const timer = setTimeout(() => {
|
||||
setShouldRender(false);
|
||||
setIsClosing(false);
|
||||
}, 280);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setIsClosing(true);
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || typeof document === 'undefined') return;
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow;
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || typeof document === 'undefined') return;
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handleKeydown);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeydown);
|
||||
};
|
||||
}, [handleClose, open]);
|
||||
|
||||
if (!shouldRender) return null;
|
||||
|
||||
const drawer = (
|
||||
<div className={`mobile-drawer-root ${isClosing ? 'is-closing' : ''}`}>
|
||||
<div
|
||||
className="mobile-drawer-backdrop"
|
||||
onClick={handleClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className={`mobile-drawer-panel mobile-drawer-panel-${side}`} role="dialog" aria-modal="true" aria-labelledby={labelledBy}>
|
||||
<div className="mobile-drawer-toolbar">
|
||||
{title ? (
|
||||
<div className="mobile-drawer-title" id={titleId}>
|
||||
{title}
|
||||
</div>
|
||||
) : <div />}
|
||||
<button type="button" className="mobile-drawer-close" onClick={handleClose} aria-label={closeLabel}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
return (
|
||||
<Drawer
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={title}
|
||||
closeLabel={closeLabel}
|
||||
side={side}
|
||||
closeOnBackdrop={closeOnBackdrop}
|
||||
closeOnEscape={closeOnEscape}
|
||||
showCloseButton={showCloseButton}
|
||||
ariaLabel={ariaLabel}
|
||||
ariaDescribedBy={ariaDescribedBy}
|
||||
>
|
||||
{children}
|
||||
</Drawer>
|
||||
);
|
||||
|
||||
const portalTarget = typeof document !== 'undefined' ? document.body : null;
|
||||
return portalTarget ? createPortal(drawer, portalTarget) : drawer;
|
||||
}
|
||||
|
||||
export { MobileDrawer };
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { act, create, type ReactTestInstance } from 'react-test-renderer';
|
||||
import ModernSelect from './ModernSelect.js';
|
||||
|
||||
@@ -90,4 +90,47 @@ describe('ModernSelect', () => {
|
||||
));
|
||||
expect(optionButtons.map((node) => collectInstanceText(node))).toEqual(['Gamma APIhttps://third.example.com']);
|
||||
});
|
||||
|
||||
it('keeps keyboard navigation and selection inside the facade contract', async () => {
|
||||
const onChange = vi.fn();
|
||||
const root = create(
|
||||
<ModernSelect
|
||||
value="alpha"
|
||||
onChange={onChange}
|
||||
options={[
|
||||
{ value: 'alpha', label: 'Alpha' },
|
||||
{ value: 'disabled', label: 'Disabled', disabled: true },
|
||||
{ value: 'beta', label: 'Beta' },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
const trigger = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.className === 'string'
|
||||
&& node.props.className.includes('modern-select-trigger')
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
trigger.props.onClick();
|
||||
});
|
||||
expect(trigger.props['aria-expanded']).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
trigger.props.onKeyDown({ key: 'ArrowDown', preventDefault: vi.fn() });
|
||||
});
|
||||
let highlighted = root.root.findAll((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.className === 'string'
|
||||
&& node.props.className.includes('modern-select-option')
|
||||
&& node.props.className.includes('is-highlighted')
|
||||
));
|
||||
expect(highlighted).toHaveLength(1);
|
||||
expect(collectInstanceText(highlighted[0])).toContain('Beta');
|
||||
|
||||
await act(async () => {
|
||||
trigger.props.onKeyDown({ key: 'Enter', preventDefault: vi.fn() });
|
||||
});
|
||||
expect(onChange).toHaveBeenCalledWith('beta');
|
||||
expect(trigger.props['aria-expanded']).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,202 +1,9 @@
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
||||
|
||||
type ModernSelectOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
disabled?: boolean;
|
||||
iconNode?: ReactNode;
|
||||
iconUrl?: string;
|
||||
iconText?: string;
|
||||
};
|
||||
|
||||
type ModernSelectProps = {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
options: ModernSelectOption[];
|
||||
'data-testid'?: string;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
emptyLabel?: string;
|
||||
menuMaxHeight?: number;
|
||||
className?: string;
|
||||
size?: 'md' | 'sm';
|
||||
searchable?: boolean;
|
||||
searchPlaceholder?: string;
|
||||
};
|
||||
|
||||
export default function ModernSelect({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
'data-testid': dataTestId,
|
||||
placeholder = 'Select',
|
||||
disabled = false,
|
||||
emptyLabel = 'No options',
|
||||
menuMaxHeight = 280,
|
||||
className = '',
|
||||
size = 'md',
|
||||
searchable = false,
|
||||
searchPlaceholder = 'Search...',
|
||||
}: ModernSelectProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const selected = useMemo(
|
||||
() => options.find((item) => item.value === value),
|
||||
[options, value],
|
||||
);
|
||||
|
||||
const visibleOptions = useMemo(() => {
|
||||
const query = searchQuery.trim().toLowerCase();
|
||||
if (!searchable || !query) return options;
|
||||
|
||||
return options.filter((item) => {
|
||||
const haystack = [
|
||||
item.label,
|
||||
item.description,
|
||||
item.value,
|
||||
]
|
||||
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
return haystack.includes(query);
|
||||
});
|
||||
}, [options, searchQuery, searchable]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (typeof document === 'undefined') return;
|
||||
|
||||
const handleOutsideClick = (event: MouseEvent) => {
|
||||
if (!rootRef.current) return;
|
||||
if (!rootRef.current.contains(event.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') setOpen(false);
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleOutsideClick);
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleOutsideClick);
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (disabled) setOpen(false);
|
||||
}, [disabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open && searchQuery) {
|
||||
setSearchQuery('');
|
||||
}
|
||||
}, [open, searchQuery]);
|
||||
|
||||
const renderOptionIcon = (item: ModernSelectOption) => {
|
||||
if (item.iconNode) {
|
||||
return item.iconNode;
|
||||
}
|
||||
if (item.iconUrl) {
|
||||
return <img className="modern-select-option-icon" src={item.iconUrl} alt="" loading="lazy" />;
|
||||
}
|
||||
if (item.iconText) {
|
||||
return <span className="modern-select-option-icon-text">{item.iconText}</span>;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
data-testid={dataTestId}
|
||||
className={`modern-select ${open ? 'is-open' : ''} ${disabled ? 'is-disabled' : ''} ${size === 'sm' ? 'is-sm' : ''} ${className}`.trim()}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="modern-select-trigger"
|
||||
onClick={() => {
|
||||
if (!disabled) setOpen((prev) => !prev);
|
||||
}}
|
||||
aria-expanded={open}
|
||||
disabled={disabled}
|
||||
>
|
||||
<span className={`modern-select-value ${selected ? '' : 'is-placeholder'}`.trim()}>
|
||||
{selected ? (
|
||||
<span className="modern-select-value-content">
|
||||
{renderOptionIcon(selected)}
|
||||
<span>{selected.label}</span>
|
||||
</span>
|
||||
) : (
|
||||
placeholder
|
||||
)}
|
||||
</span>
|
||||
<svg
|
||||
className="modern-select-chevron"
|
||||
width="14"
|
||||
height="14"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div className="modern-select-panel" style={{ maxHeight: menuMaxHeight }}>
|
||||
{searchable && (
|
||||
<div className="modern-select-search-shell">
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(event) => setSearchQuery(event.target.value)}
|
||||
placeholder={searchPlaceholder}
|
||||
className="modern-select-search-input"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{visibleOptions.length === 0 ? (
|
||||
<div className="modern-select-empty">{emptyLabel}</div>
|
||||
) : (
|
||||
visibleOptions.map((item) => {
|
||||
const active = item.value === value;
|
||||
return (
|
||||
<button
|
||||
key={item.value}
|
||||
type="button"
|
||||
className={`modern-select-option ${active ? 'is-active' : ''} ${item.disabled ? 'is-disabled' : ''}`.trim()}
|
||||
onClick={() => {
|
||||
if (item.disabled) return;
|
||||
onChange(item.value);
|
||||
setOpen(false);
|
||||
}}
|
||||
disabled={item.disabled}
|
||||
>
|
||||
<div className="modern-select-option-main">
|
||||
{renderOptionIcon(item)}
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div className="modern-select-option-label">{item.label}</div>
|
||||
{item.description && (
|
||||
<div className="modern-select-option-desc">{item.description}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{active && (
|
||||
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
/**
|
||||
* Backward-compatible import path. Keep this file deliberately tiny so the
|
||||
* project-owned `components/ui` layer remains the single implementation.
|
||||
*/
|
||||
export {
|
||||
default,
|
||||
type ModernSelectOption,
|
||||
type ModernSelectProps,
|
||||
} from './ui/Select.js';
|
||||
|
||||
@@ -10,7 +10,7 @@ describe('ResponsiveBatchActionBar page adoption', () => {
|
||||
it('is used by the repeated admin list pages instead of open-coded mobile and desktop batch wrappers', () => {
|
||||
expect(readPageSource('src/web/pages/Accounts.tsx')).toContain('ResponsiveBatchActionBar');
|
||||
expect(readPageSource('src/web/pages/Sites.tsx')).toContain('ResponsiveBatchActionBar');
|
||||
expect(readPageSource('src/web/pages/Tokens.tsx')).toContain('ResponsiveBatchActionBar');
|
||||
expect(readPageSource('src/web/pages/tokens/TokensPanel.tsx')).toContain('ResponsiveBatchActionBar');
|
||||
expect(readPageSource('src/web/pages/DownstreamKeys.tsx')).toContain('ResponsiveBatchActionBar');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import CenteredModal from './CenteredModal.js';
|
||||
import { Button } from './ui/Button.js';
|
||||
import { getSiteInitializationPreset } from '../../shared/siteInitializationPresets.js';
|
||||
|
||||
type NextStepChoice = 'session' | 'apikey' | 'later';
|
||||
@@ -56,22 +57,22 @@ export default function SiteCreatedModal({
|
||||
bodyStyle={{ display: 'flex', flexDirection: 'column', gap: 12 }}
|
||||
footer={(
|
||||
<>
|
||||
<button onClick={() => onChoice('later')} className="btn btn-ghost">
|
||||
<Button onClick={() => onChoice('later')} variant="ghost">
|
||||
稍后配置
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => onChoice(secondaryAction.choice)}
|
||||
className="btn btn-ghost"
|
||||
variant="ghost"
|
||||
style={{ border: '1px solid var(--color-border)' }}
|
||||
>
|
||||
{secondaryAction.label}
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => onChoice(primaryAction.choice)}
|
||||
className="btn btn-primary"
|
||||
variant="primary"
|
||||
>
|
||||
{primaryAction.label}
|
||||
</button>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, create } from 'react-test-renderer';
|
||||
import { useEffect } from 'react';
|
||||
import { ToastProvider, useToast } from './Toast.js';
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
describe('ToastProvider accessibility and lifecycle', () => {
|
||||
it('keeps the public useToast API and exposes live status semantics', async () => {
|
||||
function EffectEmitter() {
|
||||
const toast = useToast();
|
||||
useEffect(() => {
|
||||
toast.success('保存成功');
|
||||
}, [toast]);
|
||||
return null;
|
||||
}
|
||||
|
||||
let root!: WebTestRenderer;
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<ToastProvider>
|
||||
<EffectEmitter />
|
||||
</ToastProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
const region = root.root.find((node) => node.props.role === 'region');
|
||||
expect(region.props['aria-live']).toBe('polite');
|
||||
const status = root.root.find((node) => node.props.role === 'status');
|
||||
expect(status.props['aria-live']).toBe('polite');
|
||||
expect(status.props['aria-label']).toBe('保存成功');
|
||||
root.unmount();
|
||||
});
|
||||
|
||||
it('cleans auto-dismiss and exit timers when the provider unmounts', async () => {
|
||||
vi.useFakeTimers();
|
||||
let root!: WebTestRenderer;
|
||||
|
||||
function Emitter() {
|
||||
const toast = useToast();
|
||||
useEffect(() => {
|
||||
toast.info('稍后刷新');
|
||||
}, [toast]);
|
||||
return null;
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<ToastProvider>
|
||||
<Emitter />
|
||||
</ToastProvider>,
|
||||
);
|
||||
});
|
||||
expect(vi.getTimerCount()).toBeGreaterThan(0);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,16 @@
|
||||
import React, { createContext, useContext, useState, useCallback, useMemo, useRef } from 'react';
|
||||
import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
type ToastType = 'success' | 'error' | 'info';
|
||||
export type ToastType = 'success' | 'error' | 'info';
|
||||
|
||||
interface Toast {
|
||||
type Toast = {
|
||||
id: number;
|
||||
type: ToastType;
|
||||
message: string;
|
||||
@@ -33,18 +41,53 @@ const icons: Record<ToastType, React.ReactNode> = {
|
||||
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
const idRef = useRef(0);
|
||||
// React runs child effects before parent effects. Start true so a child that
|
||||
// emits a toast from its first effect is not dropped before this provider's
|
||||
// mount effect has run; the cleanup flips it to false on unmount.
|
||||
const mountedRef = useRef(true);
|
||||
const dismissTimersRef = useRef(new Map<number, ReturnType<typeof globalThis.setTimeout>>());
|
||||
const removeTimersRef = useRef(new Map<number, ReturnType<typeof globalThis.setTimeout>>());
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
dismissTimersRef.current.forEach((timer) => globalThis.clearTimeout(timer));
|
||||
removeTimersRef.current.forEach((timer) => globalThis.clearTimeout(timer));
|
||||
dismissTimersRef.current.clear();
|
||||
removeTimersRef.current.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const clearDismissTimer = useCallback((id: number) => {
|
||||
const timer = dismissTimersRef.current.get(id);
|
||||
if (timer === undefined) return;
|
||||
globalThis.clearTimeout(timer);
|
||||
dismissTimersRef.current.delete(id);
|
||||
}, []);
|
||||
|
||||
const removeToast = useCallback((id: number) => {
|
||||
if (!mountedRef.current) return;
|
||||
clearDismissTimer(id);
|
||||
if (removeTimersRef.current.has(id)) return;
|
||||
setToasts(prev => prev.map(t => t.id === id ? { ...t, exiting: true } : t));
|
||||
setTimeout(() => {
|
||||
const timer = globalThis.setTimeout(() => {
|
||||
removeTimersRef.current.delete(id);
|
||||
if (!mountedRef.current) return;
|
||||
setToasts(prev => prev.filter(t => t.id !== id));
|
||||
}, 250);
|
||||
}, []);
|
||||
removeTimersRef.current.set(id, timer);
|
||||
}, [clearDismissTimer]);
|
||||
|
||||
const addToast = useCallback((type: ToastType, message: string) => {
|
||||
const id = ++idRef.current;
|
||||
if (!mountedRef.current) return;
|
||||
setToasts(prev => [...prev, { id, type, message }]);
|
||||
setTimeout(() => removeToast(id), 3200);
|
||||
const timer = globalThis.setTimeout(() => {
|
||||
dismissTimersRef.current.delete(id);
|
||||
removeToast(id);
|
||||
}, 3200);
|
||||
dismissTimersRef.current.set(id, timer);
|
||||
}, [removeToast]);
|
||||
|
||||
const success = useCallback((msg: string) => addToast('success', msg), [addToast]);
|
||||
@@ -63,17 +106,34 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<ToastContext.Provider value={value}>
|
||||
{children}
|
||||
<div className="toast-container">
|
||||
<div
|
||||
className="toast-container"
|
||||
role="region"
|
||||
aria-live="polite"
|
||||
aria-relevant="additions text"
|
||||
aria-label="通知"
|
||||
>
|
||||
{toasts.map(t => (
|
||||
<div
|
||||
key={t.id}
|
||||
className={`toast toast-${t.type} ${t.exiting ? 'toast-exit' : ''}`}
|
||||
onClick={() => removeToast(t.id)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
removeToast(t.id);
|
||||
}
|
||||
}}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
tabIndex={0}
|
||||
aria-label={t.message}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<span style={{ flexShrink: 0, marginTop: 1 }}>{icons[t.type]}</span>
|
||||
<span style={{ fontSize: 13, lineHeight: 1.5 }}>{t.message}</span>
|
||||
<div className="toast-progress" />
|
||||
<div className="toast-progress" aria-hidden="true" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { create } from 'react-test-renderer';
|
||||
import { AsyncButton, Button } from './Button.js';
|
||||
|
||||
describe('Button primitives', () => {
|
||||
it('keeps the existing btn theme and defaults non-submit actions to button', () => {
|
||||
const root = create(<Button variant="ghost">取消</Button>);
|
||||
const button = root.root.findByType('button');
|
||||
expect(button.props.type).toBe('button');
|
||||
expect(button.props.className).toContain('btn btn-ghost');
|
||||
expect(button.props.disabled).toBeFalsy();
|
||||
});
|
||||
|
||||
it('marks async actions busy, disabled, and visibly loading', () => {
|
||||
const onClick = vi.fn();
|
||||
const root = create(
|
||||
<AsyncButton variant="primary" pending onClick={onClick} loadingLabel="保存中...">
|
||||
保存
|
||||
</AsyncButton>,
|
||||
);
|
||||
const button = root.root.findByType('button');
|
||||
expect(button.props.disabled).toBe(true);
|
||||
expect(button.props['aria-busy']).toBe(true);
|
||||
expect(button.findAll((node) => node.props.className === 'spinner spinner-sm')).toHaveLength(1);
|
||||
expect(button.children.join('')).toContain('保存中...');
|
||||
// The native disabled contract prevents activation; the facade does not
|
||||
// swallow the callback when a test invokes it directly.
|
||||
expect(button.props.type).toBe('button');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from 'react';
|
||||
|
||||
export type ButtonVariant =
|
||||
| 'primary'
|
||||
| 'soft-primary'
|
||||
| 'danger'
|
||||
| 'success'
|
||||
| 'ghost'
|
||||
| 'link';
|
||||
|
||||
export type ButtonSize = 'sm' | 'md' | 'lg';
|
||||
|
||||
export type ButtonProps = Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'type'> & {
|
||||
/** Native type is opt-in; non-form actions default to `button`. */
|
||||
type?: ButtonHTMLAttributes<HTMLButtonElement>['type'];
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
/** Shows a spinner and disables the button while an action is pending. */
|
||||
loading?: boolean;
|
||||
/** Optional replacement text announced/rendered while loading. */
|
||||
loadingLabel?: ReactNode;
|
||||
};
|
||||
|
||||
function classNames(
|
||||
className: string | undefined,
|
||||
variant: ButtonVariant,
|
||||
size: ButtonSize,
|
||||
): string {
|
||||
const classes = [
|
||||
'btn',
|
||||
`btn-${variant}`,
|
||||
size === 'md' ? undefined : `btn-${size}`,
|
||||
className,
|
||||
].filter(Boolean);
|
||||
return [...new Set(classes.flatMap((value) => value!.split(/\s+/).filter(Boolean)))].join(' ');
|
||||
}
|
||||
|
||||
/** Project-owned button facade that preserves the existing .btn-* theme. */
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button({
|
||||
type = 'button',
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
loading = false,
|
||||
loadingLabel,
|
||||
className,
|
||||
children,
|
||||
disabled,
|
||||
...props
|
||||
}, ref) {
|
||||
const isDisabled = Boolean(disabled || loading);
|
||||
return (
|
||||
<button
|
||||
{...props}
|
||||
ref={ref}
|
||||
type={type}
|
||||
className={classNames(className, variant, size)}
|
||||
disabled={isDisabled}
|
||||
aria-busy={loading || undefined}
|
||||
>
|
||||
{loading ? <span className="spinner spinner-sm" aria-hidden="true" /> : null}
|
||||
{loading && loadingLabel !== undefined ? loadingLabel : children}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
export type AsyncButtonProps = Omit<ButtonProps, 'loading'> & {
|
||||
/** `pending` is an ergonomic alias for callers that model async state that way. */
|
||||
pending?: boolean;
|
||||
loading?: boolean;
|
||||
};
|
||||
|
||||
/** Semantic alias for mutations; supports both loading and pending naming. */
|
||||
export const AsyncButton = forwardRef<HTMLButtonElement, AsyncButtonProps>(function AsyncButton({
|
||||
pending = false,
|
||||
loading = false,
|
||||
...props
|
||||
}, ref) {
|
||||
return <Button ref={ref} {...props} loading={loading || pending} />;
|
||||
});
|
||||
|
||||
export default Button;
|
||||
@@ -0,0 +1,122 @@
|
||||
/** @vitest-environment jsdom */
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { Dialog, Drawer } from './Dialog.js';
|
||||
import { getBodyScrollLockCount } from './lifecycle.js';
|
||||
|
||||
describe('Radix browser renderer', () => {
|
||||
let host: HTMLDivElement;
|
||||
let root: Root;
|
||||
const previousActEnvironment = (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT;
|
||||
const previousForceRadix = (globalThis as { __METAPI_FORCE_RADIX_RENDERER__?: boolean }).__METAPI_FORCE_RADIX_RENDERER__;
|
||||
|
||||
beforeEach(() => {
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
(globalThis as { __METAPI_FORCE_RADIX_RENDERER__?: boolean }).__METAPI_FORCE_RADIX_RENDERER__ = true;
|
||||
host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
root = createRoot(host);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
host.remove();
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
|
||||
(globalThis as { __METAPI_FORCE_RADIX_RENDERER__?: boolean }).__METAPI_FORCE_RADIX_RENDERER__ = previousForceRadix;
|
||||
});
|
||||
|
||||
it('mounts an accessible dialog into the body portal and closes from the backdrop once', async () => {
|
||||
const onClose = vi.fn();
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<Dialog open onClose={onClose} title="浏览器对话框" closeOnBackdrop>
|
||||
<div>内容</div>
|
||||
</Dialog>,
|
||||
);
|
||||
});
|
||||
|
||||
const panel = document.body.querySelector('[data-ui-surface="dialog"]');
|
||||
expect(panel).toBeTruthy();
|
||||
expect(panel?.getAttribute('role')).toBe('dialog');
|
||||
// Radix's browser RemoveScroll is the sole browser lock owner; the
|
||||
// project reference-counted fallback must not be acquired a second time.
|
||||
expect(getBodyScrollLockCount()).toBe(0);
|
||||
const backdrop = document.body.querySelector('.modal-backdrop') as HTMLElement;
|
||||
await act(async () => {
|
||||
backdrop.click();
|
||||
});
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('restores the document scroll state after the browser surface unmounts', async () => {
|
||||
document.body.style.overflow = 'scroll';
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<Dialog open onClose={() => {}} title="滚动清理">
|
||||
<div>内容</div>
|
||||
</Dialog>,
|
||||
);
|
||||
});
|
||||
await act(async () => {
|
||||
root.render(null);
|
||||
});
|
||||
expect(document.body.style.overflow).toBe('scroll');
|
||||
expect(getBodyScrollLockCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('uses the same Radix lifecycle for a right-side drawer', async () => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<Drawer open onClose={() => {}} title="浏览器抽屉" side="right">
|
||||
<div>内容</div>
|
||||
</Drawer>,
|
||||
);
|
||||
});
|
||||
const panel = document.body.querySelector('[data-ui-surface="drawer"]');
|
||||
expect(panel?.className).toContain('mobile-drawer-panel-right');
|
||||
expect(document.body.querySelector('.mobile-drawer-backdrop')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('honors the explicit Escape policy in the Radix path', async () => {
|
||||
const onClose = vi.fn();
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<Dialog open onClose={onClose} title="禁用 Escape" closeOnEscape={false}>
|
||||
<div>内容</div>
|
||||
</Dialog>,
|
||||
);
|
||||
});
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('closes a backdrop gesture exactly once when pointerdown precedes click', async () => {
|
||||
const onClose = vi.fn();
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<Drawer open onClose={onClose} title="单次关闭" closeOnBackdrop closeOnEscape>
|
||||
<div>内容</div>
|
||||
</Drawer>,
|
||||
);
|
||||
});
|
||||
|
||||
const backdrop = document.body.querySelector('.mobile-drawer-backdrop') as HTMLElement;
|
||||
expect(backdrop).toBeTruthy();
|
||||
|
||||
// Radix's DismissableLayer observes pointerdown on the document before
|
||||
// the browser dispatches the matching click. The facade must keep that
|
||||
// gesture from invoking the close callback twice.
|
||||
backdrop.dispatchEvent(new MouseEvent('pointerdown', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
button: 0,
|
||||
}));
|
||||
await act(async () => {
|
||||
backdrop.click();
|
||||
});
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, create } from 'react-test-renderer';
|
||||
import { Dialog, Drawer } from './Dialog.js';
|
||||
import { getBodyScrollLockCount } from './lifecycle.js';
|
||||
|
||||
type FakeDocument = {
|
||||
body: {
|
||||
style: { overflow: string };
|
||||
appendChild: () => unknown;
|
||||
removeChild: () => unknown;
|
||||
};
|
||||
activeElement: { focus: ReturnType<typeof vi.fn> };
|
||||
addEventListener: ReturnType<typeof vi.fn>;
|
||||
removeEventListener: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
function installDocument(initialOverflow = 'auto'): FakeDocument {
|
||||
const fakeDocument: FakeDocument = {
|
||||
body: {
|
||||
style: { overflow: initialOverflow },
|
||||
appendChild: vi.fn(),
|
||||
removeChild: vi.fn(),
|
||||
},
|
||||
activeElement: { focus: vi.fn() },
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
};
|
||||
vi.stubGlobal('document', fakeDocument);
|
||||
return fakeDocument;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('project-owned dialog and drawer wrappers', () => {
|
||||
it('preserves modal classes while exposing an accessible dialog contract', async () => {
|
||||
const document = installDocument();
|
||||
const onClose = vi.fn();
|
||||
let root!: WebTestRenderer;
|
||||
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<Dialog open onClose={onClose} title="设置">
|
||||
<div>内容</div>
|
||||
</Dialog>,
|
||||
);
|
||||
});
|
||||
|
||||
const panel = root.root.find((node) => node.props['data-ui-surface'] === 'dialog');
|
||||
expect(panel.props.role).toBe('dialog');
|
||||
expect(panel.props['aria-modal']).toBe(true);
|
||||
expect(panel.props['aria-labelledby']).toEqual(expect.any(String));
|
||||
expect(panel.props.className).toContain('modal-content');
|
||||
|
||||
const backdrop = root.root.find((node) => (
|
||||
typeof node.props.className === 'string'
|
||||
&& node.props.className.includes('modal-backdrop')
|
||||
));
|
||||
expect(backdrop.props.onClick).toBeUndefined();
|
||||
expect(document.body.style.overflow).toBe('hidden');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
expect(document.body.style.overflow).toBe('auto');
|
||||
expect(getBodyScrollLockCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('provides an explicit aria-label when a drawer has no visible title', async () => {
|
||||
installDocument();
|
||||
let root!: WebTestRenderer;
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<Drawer open onClose={() => {}} showCloseButton={false} ariaLabel="筛选面板">
|
||||
<div>筛选内容</div>
|
||||
</Drawer>,
|
||||
);
|
||||
});
|
||||
const panel = root.root.find((node) => node.props['data-ui-surface'] === 'drawer');
|
||||
expect(panel.props['aria-labelledby']).toBeUndefined();
|
||||
expect(panel.props['aria-label']).toBe('筛选面板');
|
||||
root.unmount();
|
||||
});
|
||||
|
||||
it('applies drawer content styles through the shared facade', async () => {
|
||||
installDocument();
|
||||
let root!: WebTestRenderer;
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<Drawer
|
||||
open
|
||||
onClose={() => {}}
|
||||
contentStyle={{ width: 420, maxWidth: '90vw' }}
|
||||
>
|
||||
<div>内容</div>
|
||||
</Drawer>,
|
||||
);
|
||||
});
|
||||
|
||||
const panel = root.root.find((node) => node.props['data-ui-surface'] === 'drawer');
|
||||
expect(panel.props.style).toMatchObject({ width: 420, maxWidth: '90vw' });
|
||||
root.unmount();
|
||||
});
|
||||
|
||||
it('keeps body locked until every independently mounted surface closes', async () => {
|
||||
const document = installDocument('visible');
|
||||
let root!: WebTestRenderer;
|
||||
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<>
|
||||
<Dialog open onClose={() => {}} title="一个">
|
||||
<div />
|
||||
</Dialog>
|
||||
<Drawer open onClose={() => {}} title="两个">
|
||||
<div />
|
||||
</Drawer>
|
||||
</>,
|
||||
);
|
||||
});
|
||||
expect(document.body.style.overflow).toBe('hidden');
|
||||
expect(getBodyScrollLockCount()).toBe(2);
|
||||
|
||||
await act(async () => {
|
||||
root.update(
|
||||
<>
|
||||
<Dialog open={false} onClose={() => {}} title="一个">
|
||||
<div />
|
||||
</Dialog>
|
||||
<Drawer open onClose={() => {}} title="两个">
|
||||
<div />
|
||||
</Drawer>
|
||||
</>,
|
||||
);
|
||||
});
|
||||
expect(document.body.style.overflow).toBe('hidden');
|
||||
expect(getBodyScrollLockCount()).toBe(1);
|
||||
|
||||
await act(async () => {
|
||||
root.update(
|
||||
<>
|
||||
<Dialog open={false} onClose={() => {}} title="一个">
|
||||
<div />
|
||||
</Dialog>
|
||||
<Drawer open={false} onClose={() => {}} title="两个">
|
||||
<div />
|
||||
</Drawer>
|
||||
</>,
|
||||
);
|
||||
});
|
||||
expect(document.body.style.overflow).toBe('visible');
|
||||
expect(getBodyScrollLockCount()).toBe(0);
|
||||
root.unmount();
|
||||
});
|
||||
|
||||
it('restores focus to the opener and only the top overlay handles Escape', async () => {
|
||||
vi.useFakeTimers();
|
||||
const document = installDocument();
|
||||
const opener = { focus: vi.fn() };
|
||||
document.activeElement = opener;
|
||||
const outerClose = vi.fn();
|
||||
const innerClose = vi.fn();
|
||||
let root!: WebTestRenderer;
|
||||
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<>
|
||||
<Dialog open closeOnEscape onClose={outerClose} title="外层">
|
||||
<div />
|
||||
</Dialog>
|
||||
<Drawer open closeOnEscape onClose={innerClose} title="内层">
|
||||
<div />
|
||||
</Drawer>
|
||||
</>,
|
||||
);
|
||||
});
|
||||
|
||||
const keydownHandlers = document.addEventListener.mock.calls
|
||||
.map((call) => call[1])
|
||||
.filter((handler): handler is (event: KeyboardEvent) => void => typeof handler === 'function');
|
||||
expect(keydownHandlers.length).toBeGreaterThan(0);
|
||||
keydownHandlers.forEach((handler) => handler({ key: 'Escape' } as KeyboardEvent));
|
||||
expect(innerClose).toHaveBeenCalledTimes(1);
|
||||
expect(outerClose).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
root.update(<Dialog open={false} onClose={() => {}} title="外层"><div /></Dialog>);
|
||||
});
|
||||
vi.runAllTimers();
|
||||
expect(opener.focus).toHaveBeenCalled();
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,590 @@
|
||||
import { useCallback, useId, useMemo, useRef, type CSSProperties, type ReactNode } from 'react';
|
||||
import * as RadixDialog from '@radix-ui/react-dialog';
|
||||
import { useAnimatedVisibility } from '../useAnimatedVisibility.js';
|
||||
import {
|
||||
getPortalTarget,
|
||||
renderInPortal,
|
||||
useBodyScrollLock,
|
||||
useEscapeKey,
|
||||
useFocusReturn,
|
||||
} from './lifecycle.js';
|
||||
|
||||
type SurfaceKind = 'dialog' | 'drawer';
|
||||
|
||||
/** Compatibility shape for callers that already own an exit-animation hook. */
|
||||
export type DialogPresence = {
|
||||
shouldRender: boolean;
|
||||
isVisible: boolean;
|
||||
};
|
||||
|
||||
export type DialogProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title?: ReactNode;
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
maxWidth?: number | string;
|
||||
bodyStyle?: CSSProperties;
|
||||
closeOnBackdrop?: boolean;
|
||||
closeOnEscape?: boolean;
|
||||
showCloseButton?: boolean;
|
||||
closeLabel?: string;
|
||||
ariaLabel?: string;
|
||||
ariaDescribedBy?: string;
|
||||
/** Optional state announced while an async action keeps the surface busy. */
|
||||
ariaBusy?: boolean;
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
/** Additional styles for the surface panel (merged after the max-width). */
|
||||
contentStyle?: CSSProperties;
|
||||
animationDuration?: number;
|
||||
/** Optional externally managed presence, retained for incremental migrations. */
|
||||
presence?: DialogPresence;
|
||||
};
|
||||
|
||||
export type DrawerProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
title?: ReactNode;
|
||||
closeLabel?: string;
|
||||
side?: 'left' | 'right';
|
||||
closeOnBackdrop?: boolean;
|
||||
closeOnEscape?: boolean;
|
||||
showCloseButton?: boolean;
|
||||
ariaLabel?: string;
|
||||
ariaDescribedBy?: string;
|
||||
/** Optional state announced while an async action keeps the surface busy. */
|
||||
ariaBusy?: boolean;
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
/** Additional styles for the surface panel. */
|
||||
contentStyle?: CSSProperties;
|
||||
animationDuration?: number;
|
||||
/** Optional externally managed presence, retained for incremental migrations. */
|
||||
presence?: DialogPresence;
|
||||
};
|
||||
|
||||
type DialogSurfaceProps = {
|
||||
kind: SurfaceKind;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title?: ReactNode;
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
maxWidth?: number | string;
|
||||
bodyStyle?: CSSProperties;
|
||||
closeOnBackdrop: boolean;
|
||||
closeOnEscape: boolean;
|
||||
showCloseButton: boolean;
|
||||
closeLabel: string;
|
||||
ariaLabel?: string;
|
||||
ariaDescribedBy?: string;
|
||||
ariaBusy?: boolean;
|
||||
side?: 'left' | 'right';
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
contentStyle?: CSSProperties;
|
||||
animationDuration: number;
|
||||
presence?: DialogPresence;
|
||||
};
|
||||
|
||||
function joinClasses(...classes: Array<string | undefined | false>): string | undefined {
|
||||
const value = classes.filter(Boolean).join(' ').trim();
|
||||
return value || undefined;
|
||||
}
|
||||
|
||||
function isTestRuntime(): boolean {
|
||||
const processLike = (globalThis as {
|
||||
process?: { env?: Record<string, string | undefined> };
|
||||
}).process;
|
||||
return processLike?.env?.NODE_ENV === 'test';
|
||||
}
|
||||
|
||||
/**
|
||||
* Radix's DOM portal is enabled for the browser build. Vitest's
|
||||
* react-test-renderer intentionally has no DOM host, so the same semantic
|
||||
* shell falls back to a plain React tree there. This keeps SSR/test rendering
|
||||
* deterministic while retaining Radix focus/dismiss behavior for users.
|
||||
*/
|
||||
function canUseRadixRenderer(target: HTMLElement | null): target is HTMLElement {
|
||||
const forceRadix = (globalThis as { __METAPI_FORCE_RADIX_RENDERER__?: boolean }).__METAPI_FORCE_RADIX_RENDERER__ === true;
|
||||
if ((isTestRuntime() && !forceRadix) || typeof window === 'undefined' || !target) return false;
|
||||
return typeof target.ownerDocument?.createElement === 'function';
|
||||
}
|
||||
|
||||
function hasTitle(title: ReactNode): boolean {
|
||||
return title !== undefined && title !== null && title !== false;
|
||||
}
|
||||
|
||||
type SurfaceInnerProps = {
|
||||
kind: SurfaceKind;
|
||||
title?: ReactNode;
|
||||
titleId: string;
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
bodyStyle?: CSSProperties;
|
||||
closeLabel: string;
|
||||
showCloseButton: boolean;
|
||||
onClose: () => void;
|
||||
useRadixTitle: boolean;
|
||||
};
|
||||
|
||||
function SurfaceTitle({
|
||||
title,
|
||||
titleId,
|
||||
useRadixTitle,
|
||||
}: Pick<SurfaceInnerProps, 'title' | 'titleId' | 'useRadixTitle'>) {
|
||||
if (!hasTitle(title)) {
|
||||
return <div className="modal-title" aria-hidden="true" />;
|
||||
}
|
||||
|
||||
const titleNode = (
|
||||
<div className={useRadixTitle ? 'modal-title' : 'modal-title'} id={titleId}>
|
||||
{title}
|
||||
</div>
|
||||
);
|
||||
|
||||
return useRadixTitle ? (
|
||||
<RadixDialog.Title asChild>{titleNode}</RadixDialog.Title>
|
||||
) : titleNode;
|
||||
}
|
||||
|
||||
function DialogInner({
|
||||
kind,
|
||||
title,
|
||||
titleId,
|
||||
children,
|
||||
footer,
|
||||
bodyStyle,
|
||||
closeLabel,
|
||||
showCloseButton,
|
||||
onClose,
|
||||
useRadixTitle,
|
||||
}: SurfaceInnerProps) {
|
||||
if (kind === 'drawer') {
|
||||
return (
|
||||
<>
|
||||
<div className="mobile-drawer-toolbar">
|
||||
{hasTitle(title) ? (
|
||||
<div className="mobile-drawer-title" id={titleId}>
|
||||
{useRadixTitle ? (
|
||||
<RadixDialog.Title asChild>
|
||||
<span>{title}</span>
|
||||
</RadixDialog.Title>
|
||||
) : title}
|
||||
</div>
|
||||
) : (useRadixTitle
|
||||
? <RadixDialog.Title asChild><span className="ui-visually-hidden">抽屉</span></RadixDialog.Title>
|
||||
: <div />)}
|
||||
{showCloseButton ? (
|
||||
<button
|
||||
type="button"
|
||||
className="mobile-drawer-close"
|
||||
onClick={onClose}
|
||||
aria-label={closeLabel}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{!hasTitle(title) && useRadixTitle ? (
|
||||
<RadixDialog.Title asChild>
|
||||
<span className="ui-visually-hidden" id={titleId}>对话框</span>
|
||||
</RadixDialog.Title>
|
||||
) : null}
|
||||
{hasTitle(title) || showCloseButton ? (
|
||||
<div className="modal-header">
|
||||
<SurfaceTitle title={title} titleId={titleId} useRadixTitle={useRadixTitle} />
|
||||
{showCloseButton ? (
|
||||
<button
|
||||
type="button"
|
||||
className="modal-close-button"
|
||||
onClick={onClose}
|
||||
aria-label={closeLabel}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="modal-body" style={bodyStyle}>
|
||||
{children}
|
||||
</div>
|
||||
{footer ? <div className="modal-footer">{footer}</div> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogSurface({
|
||||
kind,
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
children,
|
||||
footer,
|
||||
maxWidth = 860,
|
||||
bodyStyle,
|
||||
closeOnBackdrop,
|
||||
closeOnEscape,
|
||||
showCloseButton,
|
||||
closeLabel,
|
||||
ariaLabel,
|
||||
ariaDescribedBy,
|
||||
ariaBusy,
|
||||
side = 'left',
|
||||
className,
|
||||
contentClassName,
|
||||
contentStyle: contentStyleProp,
|
||||
animationDuration,
|
||||
presence,
|
||||
}: DialogSurfaceProps) {
|
||||
const internalPresence = useAnimatedVisibility(
|
||||
open,
|
||||
animationDuration,
|
||||
!presence,
|
||||
);
|
||||
const resolvedPresence = presence ?? internalPresence;
|
||||
const lifecycleOpen = presence ? open && presence.isVisible : open;
|
||||
const portalTarget = getPortalTarget();
|
||||
const useRadixRenderer = canUseRadixRenderer(portalTarget);
|
||||
const titleId = useId();
|
||||
const labelledBy = hasTitle(title) ? titleId : undefined;
|
||||
const isVisible = resolvedPresence.isVisible;
|
||||
const closingClass = isVisible ? undefined : 'is-closing';
|
||||
const fallbackFocusTarget = typeof document !== 'undefined' ? document.body : null;
|
||||
|
||||
const closeGuardRef = useRef(false);
|
||||
const requestClose = useCallback(() => {
|
||||
// Radix can report both an outside interaction and an overlay click for
|
||||
// one pointer gesture. Keep all close paths single-flight for that tick.
|
||||
if (closeGuardRef.current) return;
|
||||
closeGuardRef.current = true;
|
||||
const resetGuard = () => {
|
||||
closeGuardRef.current = false;
|
||||
};
|
||||
if (typeof globalThis.queueMicrotask === 'function') {
|
||||
globalThis.queueMicrotask(resetGuard);
|
||||
} else {
|
||||
void Promise.resolve().then(resetGuard);
|
||||
}
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
// Radix's modal overlay owns body scrolling in the browser renderer. The
|
||||
// project lock remains the fallback for SSR/test/partial DOM renderers, and
|
||||
// avoiding two independent owners prevents cleanup order from restoring a
|
||||
// stale overflow value after the last surface closes.
|
||||
useBodyScrollLock(lifecycleOpen && !useRadixRenderer);
|
||||
useFocusReturn(lifecycleOpen, fallbackFocusTarget);
|
||||
// CenteredModal historically skipped document listeners when no usable
|
||||
// portal/body existed; retain that SSR/test fallback. Drawers still accept
|
||||
// a lightweight document stub because their close policy is independent of
|
||||
// whether the portal target can be attached. Radix owns Escape handling in
|
||||
// the browser path, so registering a second listener there would double
|
||||
// invoke the caller.
|
||||
useEscapeKey(
|
||||
lifecycleOpen && closeOnEscape && !useRadixRenderer && (portalTarget !== null || kind === 'drawer'),
|
||||
requestClose,
|
||||
);
|
||||
|
||||
const rootClassName = useMemo(
|
||||
() => kind === 'dialog'
|
||||
? joinClasses('modal-backdrop', closingClass, className)
|
||||
: joinClasses('mobile-drawer-root', closingClass, className),
|
||||
[className, closingClass, kind],
|
||||
);
|
||||
|
||||
const panelClassName = useMemo(
|
||||
() => kind === 'dialog'
|
||||
? joinClasses('modal-content', closingClass, contentClassName)
|
||||
: joinClasses('mobile-drawer-panel', `mobile-drawer-panel-${side}`, contentClassName),
|
||||
[closingClass, contentClassName, kind, side],
|
||||
);
|
||||
|
||||
if (!resolvedPresence.shouldRender) return null;
|
||||
|
||||
const contentStyle = kind === 'dialog'
|
||||
? { maxWidth, ...contentStyleProp }
|
||||
: contentStyleProp;
|
||||
const contentAria = {
|
||||
role: 'dialog' as const,
|
||||
'aria-modal': true,
|
||||
'aria-labelledby': labelledBy,
|
||||
'aria-label': labelledBy ? undefined : (ariaLabel ?? (kind === 'drawer' ? '抽屉' : '对话框')),
|
||||
'aria-describedby': ariaDescribedBy,
|
||||
'aria-busy': ariaBusy || undefined,
|
||||
tabIndex: -1,
|
||||
'data-ui-surface': kind,
|
||||
'data-state': lifecycleOpen ? 'open' : 'closed',
|
||||
};
|
||||
|
||||
const handleBackdropClick = closeOnBackdrop ? requestClose : undefined;
|
||||
const inner = (
|
||||
<DialogInner
|
||||
kind={kind}
|
||||
title={title}
|
||||
titleId={titleId}
|
||||
footer={footer}
|
||||
bodyStyle={bodyStyle}
|
||||
closeLabel={closeLabel}
|
||||
showCloseButton={showCloseButton}
|
||||
onClose={requestClose}
|
||||
useRadixTitle={false}
|
||||
>
|
||||
{children}
|
||||
</DialogInner>
|
||||
);
|
||||
|
||||
if (useRadixRenderer) {
|
||||
const radixInner = (
|
||||
<DialogInner
|
||||
kind={kind}
|
||||
title={title}
|
||||
titleId={titleId}
|
||||
footer={footer}
|
||||
bodyStyle={bodyStyle}
|
||||
closeLabel={closeLabel}
|
||||
showCloseButton={showCloseButton}
|
||||
onClose={requestClose}
|
||||
useRadixTitle
|
||||
>
|
||||
{children}
|
||||
</DialogInner>
|
||||
);
|
||||
|
||||
const radixSurface = kind === 'dialog' ? (
|
||||
<RadixDialog.Root
|
||||
open={lifecycleOpen}
|
||||
onOpenChange={(nextOpen) => {
|
||||
// Root can receive a dismissal event from Radix's document-level
|
||||
// listener even when the controlled surface explicitly disables
|
||||
// Escape. The facade's close policy is authoritative.
|
||||
if (!nextOpen && lifecycleOpen && closeOnEscape) requestClose();
|
||||
}}
|
||||
>
|
||||
<RadixDialog.Portal container={portalTarget} forceMount={resolvedPresence.shouldRender}>
|
||||
<RadixDialog.Overlay
|
||||
asChild
|
||||
forceMount={resolvedPresence.shouldRender}
|
||||
onClick={kind === 'dialog' ? handleBackdropClick : undefined}
|
||||
>
|
||||
<div className={rootClassName}>
|
||||
<RadixDialog.Content
|
||||
forceMount={resolvedPresence.shouldRender}
|
||||
{...contentAria}
|
||||
className={panelClassName}
|
||||
style={contentStyle}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||
onEscapeKeyDown={(event) => {
|
||||
if (!closeOnEscape) event.preventDefault();
|
||||
}}
|
||||
onPointerDownOutside={(event) => {
|
||||
// The backdrop has an explicit click owner below. Prevent
|
||||
// Radix's dismissable layer from also closing on the
|
||||
// preceding pointerdown; otherwise one gesture can invoke
|
||||
// onClose twice (pointerdown + click), especially when
|
||||
// closeOnEscape is enabled as well.
|
||||
event.preventDefault();
|
||||
}}
|
||||
onInteractOutside={(event) => {
|
||||
// Keep backdrop dismissal under the facade's explicit
|
||||
// closeOnBackdrop click policy (see onPointerDownOutside).
|
||||
event.preventDefault();
|
||||
}}
|
||||
>
|
||||
{radixInner}
|
||||
</RadixDialog.Content>
|
||||
</div>
|
||||
</RadixDialog.Overlay>
|
||||
</RadixDialog.Portal>
|
||||
</RadixDialog.Root>
|
||||
) : (
|
||||
<RadixDialog.Root
|
||||
open={lifecycleOpen}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen && lifecycleOpen && closeOnEscape) requestClose();
|
||||
}}
|
||||
>
|
||||
<RadixDialog.Portal container={portalTarget} forceMount={resolvedPresence.shouldRender}>
|
||||
<RadixDialog.Overlay
|
||||
asChild
|
||||
forceMount={resolvedPresence.shouldRender}
|
||||
onClick={undefined}
|
||||
>
|
||||
<div className={rootClassName}>
|
||||
<div
|
||||
className="mobile-drawer-backdrop"
|
||||
onClick={handleBackdropClick}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<RadixDialog.Content
|
||||
forceMount={resolvedPresence.shouldRender}
|
||||
{...contentAria}
|
||||
className={panelClassName}
|
||||
style={contentStyle}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||
onEscapeKeyDown={(event) => {
|
||||
if (!closeOnEscape) event.preventDefault();
|
||||
}}
|
||||
onPointerDownOutside={(event) => {
|
||||
// The nested drawer backdrop owns pointer dismissal. Stop
|
||||
// Radix's pointerdown dismissal so the following click does
|
||||
// not produce a second onClose call.
|
||||
event.preventDefault();
|
||||
}}
|
||||
onInteractOutside={(event) => {
|
||||
event.preventDefault();
|
||||
}}
|
||||
>
|
||||
{radixInner}
|
||||
</RadixDialog.Content>
|
||||
</div>
|
||||
</RadixDialog.Overlay>
|
||||
</RadixDialog.Portal>
|
||||
</RadixDialog.Root>
|
||||
);
|
||||
|
||||
return radixSurface;
|
||||
}
|
||||
|
||||
const fallbackSurface = kind === 'dialog' ? (
|
||||
<div className={rootClassName} onClick={handleBackdropClick}>
|
||||
<div
|
||||
className={panelClassName}
|
||||
style={contentStyle}
|
||||
{...contentAria}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
{inner}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={rootClassName}>
|
||||
<div
|
||||
className="mobile-drawer-backdrop"
|
||||
onClick={handleBackdropClick}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div
|
||||
className={panelClassName}
|
||||
style={contentStyle}
|
||||
{...contentAria}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
{inner}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return renderInPortal(fallbackSurface);
|
||||
}
|
||||
|
||||
/** Project-owned centered dialog API. */
|
||||
export function Dialog({
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
children,
|
||||
footer,
|
||||
maxWidth = 860,
|
||||
bodyStyle,
|
||||
closeOnBackdrop = false,
|
||||
closeOnEscape = false,
|
||||
showCloseButton = true,
|
||||
closeLabel = '关闭弹框',
|
||||
ariaLabel,
|
||||
ariaDescribedBy,
|
||||
ariaBusy,
|
||||
className,
|
||||
contentClassName,
|
||||
contentStyle,
|
||||
animationDuration = 220,
|
||||
presence,
|
||||
}: DialogProps) {
|
||||
return (
|
||||
<DialogSurface
|
||||
kind="dialog"
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={title}
|
||||
footer={footer}
|
||||
maxWidth={maxWidth}
|
||||
bodyStyle={bodyStyle}
|
||||
closeOnBackdrop={closeOnBackdrop}
|
||||
closeOnEscape={closeOnEscape}
|
||||
showCloseButton={showCloseButton}
|
||||
closeLabel={closeLabel}
|
||||
ariaLabel={ariaLabel}
|
||||
ariaDescribedBy={ariaDescribedBy}
|
||||
ariaBusy={ariaBusy}
|
||||
className={className}
|
||||
contentClassName={contentClassName}
|
||||
contentStyle={contentStyle}
|
||||
animationDuration={animationDuration}
|
||||
presence={presence}
|
||||
>
|
||||
{children}
|
||||
</DialogSurface>
|
||||
);
|
||||
}
|
||||
|
||||
/** Project-owned drawer API sharing the dialog lifecycle implementation. */
|
||||
export function Drawer({
|
||||
open,
|
||||
onClose,
|
||||
children,
|
||||
title,
|
||||
closeLabel = '关闭导航',
|
||||
side = 'left',
|
||||
closeOnBackdrop = true,
|
||||
closeOnEscape = true,
|
||||
showCloseButton = true,
|
||||
ariaLabel,
|
||||
ariaDescribedBy,
|
||||
ariaBusy,
|
||||
className,
|
||||
contentClassName,
|
||||
contentStyle,
|
||||
animationDuration = 280,
|
||||
presence,
|
||||
}: DrawerProps) {
|
||||
return (
|
||||
<DialogSurface
|
||||
kind="drawer"
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={title}
|
||||
side={side}
|
||||
closeOnBackdrop={closeOnBackdrop}
|
||||
closeOnEscape={closeOnEscape}
|
||||
showCloseButton={showCloseButton}
|
||||
closeLabel={closeLabel}
|
||||
ariaLabel={ariaLabel}
|
||||
ariaDescribedBy={ariaDescribedBy}
|
||||
ariaBusy={ariaBusy}
|
||||
className={className}
|
||||
contentClassName={contentClassName}
|
||||
contentStyle={contentStyle}
|
||||
animationDuration={animationDuration}
|
||||
presence={presence}
|
||||
>
|
||||
{children}
|
||||
</DialogSurface>
|
||||
);
|
||||
}
|
||||
|
||||
export { DialogSurface };
|
||||
|
||||
export default Dialog;
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
Drawer as default,
|
||||
Drawer,
|
||||
type DrawerProps,
|
||||
} from './Dialog.js';
|
||||
@@ -0,0 +1,272 @@
|
||||
import { useEffect, useId, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type ReactNode } from 'react';
|
||||
|
||||
export type ModernSelectOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
disabled?: boolean;
|
||||
iconNode?: ReactNode;
|
||||
iconUrl?: string;
|
||||
iconText?: string;
|
||||
};
|
||||
|
||||
export type ModernSelectProps = {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
options: ModernSelectOption[];
|
||||
'data-testid'?: string;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
emptyLabel?: string;
|
||||
menuMaxHeight?: number;
|
||||
className?: string;
|
||||
size?: 'md' | 'sm';
|
||||
searchable?: boolean;
|
||||
searchPlaceholder?: string;
|
||||
};
|
||||
|
||||
function optionText(option: ModernSelectOption): string {
|
||||
return [option.label, option.description, option.value]
|
||||
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function firstEnabledIndex(options: ModernSelectOption[], start = 0): number {
|
||||
for (let index = Math.max(0, start); index < options.length; index += 1) {
|
||||
if (!options[index]?.disabled) return index;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Metapi's select facade. The public contract intentionally stays independent
|
||||
* of a third-party Select primitive so pages and tests can migrate in place.
|
||||
* Its listbox semantics and keyboard behavior match the eventual Radix Select
|
||||
* adapter without exposing Radix nodes to callers.
|
||||
*/
|
||||
export default function ModernSelect({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
'data-testid': dataTestId,
|
||||
placeholder = 'Select',
|
||||
disabled = false,
|
||||
emptyLabel = 'No options',
|
||||
menuMaxHeight = 280,
|
||||
className = '',
|
||||
size = 'md',
|
||||
searchable = false,
|
||||
searchPlaceholder = 'Search...',
|
||||
}: ModernSelectProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [highlightedIndex, setHighlightedIndex] = useState(-1);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const listboxId = useId();
|
||||
|
||||
const selected = useMemo(
|
||||
() => options.find((item) => item.value === value),
|
||||
[options, value],
|
||||
);
|
||||
|
||||
const visibleOptions = useMemo(() => {
|
||||
const query = searchQuery.trim().toLowerCase();
|
||||
if (!searchable || !query) return options;
|
||||
return options.filter((item) => optionText(item).includes(query));
|
||||
}, [options, searchQuery, searchable]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || typeof document === 'undefined') return undefined;
|
||||
|
||||
const handleOutsideClick = (event: MouseEvent) => {
|
||||
if (!rootRef.current) return;
|
||||
if (!rootRef.current.contains(event.target as Node)) setOpen(false);
|
||||
};
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Escape') return;
|
||||
setOpen(false);
|
||||
triggerRef.current?.focus();
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleOutsideClick);
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleOutsideClick);
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (disabled) setOpen(false);
|
||||
}, [disabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
if (searchQuery) setSearchQuery('');
|
||||
setHighlightedIndex(-1);
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedIndex = visibleOptions.findIndex((item) => item.value === value && !item.disabled);
|
||||
setHighlightedIndex(selectedIndex >= 0 ? selectedIndex : firstEnabledIndex(visibleOptions));
|
||||
}, [open, searchQuery, value, visibleOptions]);
|
||||
|
||||
const selectOption = (item: ModernSelectOption) => {
|
||||
if (disabled || item.disabled) return;
|
||||
onChange(item.value);
|
||||
setOpen(false);
|
||||
triggerRef.current?.focus();
|
||||
};
|
||||
|
||||
const handleTriggerKeyDown = (event: ReactKeyboardEvent<HTMLButtonElement>) => {
|
||||
if (disabled) return;
|
||||
if (!open) {
|
||||
if (event.key === 'Enter' || event.key === ' ' || event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
setOpen(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
const direction = event.key === 'ArrowDown' ? 1 : -1;
|
||||
const enabledIndices = visibleOptions
|
||||
.map((item, index) => (item.disabled ? -1 : index))
|
||||
.filter((index) => index >= 0);
|
||||
if (enabledIndices.length === 0) return;
|
||||
const currentPosition = enabledIndices.indexOf(highlightedIndex);
|
||||
const basePosition = currentPosition >= 0
|
||||
? currentPosition
|
||||
: (direction > 0 ? -1 : 0);
|
||||
const nextPosition = (basePosition + direction + enabledIndices.length) % enabledIndices.length;
|
||||
setHighlightedIndex(enabledIndices[nextPosition] ?? -1);
|
||||
return;
|
||||
}
|
||||
|
||||
if ((event.key === 'Enter' || event.key === ' ') && highlightedIndex >= 0) {
|
||||
event.preventDefault();
|
||||
const item = visibleOptions[highlightedIndex];
|
||||
if (item) selectOption(item);
|
||||
}
|
||||
};
|
||||
|
||||
const renderOptionIcon = (item: ModernSelectOption) => {
|
||||
if (item.iconNode) return item.iconNode;
|
||||
if (item.iconUrl) {
|
||||
return <img className="modern-select-option-icon" src={item.iconUrl} alt="" loading="lazy" />;
|
||||
}
|
||||
if (item.iconText) return <span className="modern-select-option-icon-text">{item.iconText}</span>;
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
data-testid={dataTestId}
|
||||
className={`modern-select ${open ? 'is-open' : ''} ${disabled ? 'is-disabled' : ''} ${size === 'sm' ? 'is-sm' : ''} ${className}`.trim()}
|
||||
>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
className="modern-select-trigger"
|
||||
onClick={() => {
|
||||
if (!disabled) setOpen((prev) => !prev);
|
||||
}}
|
||||
onKeyDown={handleTriggerKeyDown}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="listbox"
|
||||
aria-controls={listboxId}
|
||||
aria-activedescendant={open && highlightedIndex >= 0 ? `${listboxId}-option-${highlightedIndex}` : undefined}
|
||||
disabled={disabled}
|
||||
>
|
||||
<span className={`modern-select-value ${selected ? '' : 'is-placeholder'}`.trim()}>
|
||||
{selected ? (
|
||||
<span className="modern-select-value-content">
|
||||
{renderOptionIcon(selected)}
|
||||
<span>{selected.label}</span>
|
||||
</span>
|
||||
) : placeholder}
|
||||
</span>
|
||||
<svg
|
||||
className="modern-select-chevron"
|
||||
width="14"
|
||||
height="14"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div
|
||||
id={listboxId}
|
||||
className="modern-select-panel"
|
||||
style={{ maxHeight: menuMaxHeight }}
|
||||
role="listbox"
|
||||
aria-hidden={!open}
|
||||
aria-label={placeholder}
|
||||
>
|
||||
{searchable && (
|
||||
<div className="modern-select-search-shell">
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(event) => setSearchQuery(event.target.value)}
|
||||
placeholder={searchPlaceholder}
|
||||
aria-label={searchPlaceholder}
|
||||
className="modern-select-search-input"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{visibleOptions.length === 0 ? (
|
||||
<div className="modern-select-empty" role="status">{emptyLabel}</div>
|
||||
) : (
|
||||
visibleOptions.map((item, index) => {
|
||||
const active = item.value === value;
|
||||
const highlighted = index === highlightedIndex;
|
||||
return (
|
||||
<button
|
||||
key={item.value}
|
||||
id={`${listboxId}-option-${index}`}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={active}
|
||||
className={`modern-select-option ${active ? 'is-active' : ''} ${highlighted ? 'is-highlighted' : ''} ${item.disabled ? 'is-disabled' : ''}`.trim()}
|
||||
onMouseEnter={() => setHighlightedIndex(index)}
|
||||
onClick={() => selectOption(item)}
|
||||
disabled={item.disabled}
|
||||
>
|
||||
<div className="modern-select-option-main">
|
||||
{renderOptionIcon(item)}
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div className="modern-select-option-label">{item.label}</div>
|
||||
{item.description && (
|
||||
<div className="modern-select-option-desc">{item.description}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{active && (
|
||||
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { join, relative } from 'node:path';
|
||||
|
||||
const webRoot = join(process.cwd(), 'src/web');
|
||||
|
||||
function collectSourceFiles(directory: string): string[] {
|
||||
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
||||
const path = join(directory, entry.name);
|
||||
if (entry.isDirectory()) return collectSourceFiles(path);
|
||||
if (!/\.(?:ts|tsx)$/.test(entry.name) || /\.test\.(?:ts|tsx)$/.test(entry.name)) return [];
|
||||
return [path];
|
||||
});
|
||||
}
|
||||
|
||||
describe('web UI architecture boundary', () => {
|
||||
it('keeps Radix imports inside components/ui', () => {
|
||||
const allSources = collectSourceFiles(webRoot);
|
||||
const uiSources = allSources.filter((path) => {
|
||||
const relativePath = relative(webRoot, path).replaceAll('\\', '/');
|
||||
return relativePath.startsWith('components/ui/');
|
||||
});
|
||||
expect(uiSources.some((path) => /@radix-ui\//.test(readFileSync(path, 'utf8')))).toBe(true);
|
||||
|
||||
const directImportsOutsideBoundary = allSources
|
||||
.filter((path) => {
|
||||
const relativePath = relative(webRoot, path).replaceAll('\\', '/');
|
||||
return !relativePath.startsWith('components/ui/');
|
||||
})
|
||||
.filter((path) => /@radix-ui\//.test(readFileSync(path, 'utf8')))
|
||||
.map((path) => relative(process.cwd(), path));
|
||||
|
||||
expect(directImportsOutsideBoundary).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps the compatibility facades third-party-free', () => {
|
||||
for (const path of [
|
||||
join(webRoot, 'components/CenteredModal.tsx'),
|
||||
join(webRoot, 'components/MobileDrawer.tsx'),
|
||||
join(webRoot, 'components/ModernSelect.tsx'),
|
||||
]) {
|
||||
expect(readFileSync(path, 'utf8')).not.toContain('@radix-ui/');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { create } from 'react-test-renderer';
|
||||
import { EmptyState, ErrorState, LoadingState } from './feedback.js';
|
||||
|
||||
describe('feedback primitives', () => {
|
||||
it('exposes translated content through the existing semantic state classes', () => {
|
||||
const root = create(
|
||||
<>
|
||||
<LoadingState label="加载账号" size="sm" />
|
||||
<EmptyState title="暂无账号" description="请先添加一个站点" />
|
||||
<ErrorState title="加载失败" description="请重试" />
|
||||
</>,
|
||||
);
|
||||
const statusNodes = root.root.findAll((node) => node.props?.role === 'status');
|
||||
expect(statusNodes).toHaveLength(2);
|
||||
expect(statusNodes[0].props['aria-live']).toBe('polite');
|
||||
expect(statusNodes[1].props.className).toContain('empty-state');
|
||||
const error = root.root.find((node) => node.props?.role === 'alert');
|
||||
expect(error.props['aria-live']).toBe('assertive');
|
||||
expect(error.props.className).toContain('alert-error');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export type LoadingStateProps = {
|
||||
label?: ReactNode;
|
||||
className?: string;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
};
|
||||
|
||||
export function LoadingState({
|
||||
label = '加载中...',
|
||||
className,
|
||||
size = 'md',
|
||||
}: LoadingStateProps) {
|
||||
return (
|
||||
<div
|
||||
className={['ui-loading-state', className].filter(Boolean).join(' ')}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-label={typeof label === 'string' ? label : undefined}
|
||||
>
|
||||
<span className={`spinner spinner-${size}`} aria-hidden="true" />
|
||||
{label !== null ? <span>{label}</span> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export type EmptyStateProps = {
|
||||
title?: ReactNode;
|
||||
description?: ReactNode;
|
||||
icon?: ReactNode;
|
||||
action?: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function EmptyState({
|
||||
title = '暂无数据',
|
||||
description,
|
||||
icon,
|
||||
action,
|
||||
className,
|
||||
}: EmptyStateProps) {
|
||||
return (
|
||||
<div className={['empty-state', className].filter(Boolean).join(' ')} role="status">
|
||||
{icon ? <div className="empty-state-icon">{icon}</div> : null}
|
||||
<div className="empty-state-title">{title}</div>
|
||||
{description ? <div className="empty-state-desc">{description}</div> : null}
|
||||
{action ? <div className="empty-state-action">{action}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export type ErrorStateProps = {
|
||||
title?: ReactNode;
|
||||
description?: ReactNode;
|
||||
action?: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function ErrorState({
|
||||
title = '加载失败',
|
||||
description,
|
||||
action,
|
||||
className,
|
||||
}: ErrorStateProps) {
|
||||
return (
|
||||
<div
|
||||
className={['alert', 'alert-error', className].filter(Boolean).join(' ')}
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
>
|
||||
<div className="alert-title">{title}</div>
|
||||
{description ? <div>{description}</div> : null}
|
||||
{action ? <div className="error-state-action">{action}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
export {
|
||||
Dialog,
|
||||
Drawer,
|
||||
type DialogPresence,
|
||||
type DialogProps,
|
||||
type DrawerProps,
|
||||
} from './Dialog.js';
|
||||
export { default as Button, Button as ButtonPrimitive, AsyncButton, type AsyncButtonProps, type ButtonProps, type ButtonSize, type ButtonVariant } from './Button.js';
|
||||
export { EmptyState, ErrorState, LoadingState, type EmptyStateProps, type ErrorStateProps, type LoadingStateProps } from './feedback.js';
|
||||
export { default as ModernSelect, type ModernSelectOption, type ModernSelectProps } from './Select.js';
|
||||
export {
|
||||
acquireBodyScrollLock,
|
||||
getBodyScrollLockCount,
|
||||
getPortalTarget,
|
||||
renderInPortal,
|
||||
useBodyScrollLock,
|
||||
useEscapeKey,
|
||||
useFocusReturn,
|
||||
} from './lifecycle.js';
|
||||
@@ -0,0 +1,242 @@
|
||||
import { useEffect, useLayoutEffect, useRef, type ReactNode } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
/**
|
||||
* The web app has a number of independently mounted overlays. Keeping the
|
||||
* lock in this module (instead of in each overlay) means closing one overlay
|
||||
* cannot accidentally re-enable page scrolling while another one is open.
|
||||
*/
|
||||
type BodyLockState = {
|
||||
body: HTMLElement;
|
||||
count: number;
|
||||
previousOverflow: string;
|
||||
};
|
||||
|
||||
let bodyLockState: BodyLockState | null = null;
|
||||
|
||||
function getDocument(): Document | null {
|
||||
return typeof document === 'undefined' ? null : document;
|
||||
}
|
||||
|
||||
function isTestRuntime(): boolean {
|
||||
const processLike = (globalThis as {
|
||||
process?: { env?: Record<string, string | undefined> };
|
||||
}).process;
|
||||
return processLike?.env?.NODE_ENV === 'test';
|
||||
}
|
||||
|
||||
function getBody(): HTMLElement | null {
|
||||
const body = getDocument()?.body;
|
||||
if (!body || !body.style) return null;
|
||||
return body;
|
||||
}
|
||||
|
||||
/** Return the current portal target when the browser exposes a usable body. */
|
||||
export function getPortalTarget(): HTMLElement | null {
|
||||
const body = getBody();
|
||||
if (!body) return null;
|
||||
|
||||
// ReactDOM.createPortal validates these methods. Test renderers often
|
||||
// provide a small document stub, so gracefully use the inline fallback in
|
||||
// that environment instead of throwing while rendering an overlay.
|
||||
if (
|
||||
typeof body.appendChild !== 'function'
|
||||
|| typeof body.removeChild !== 'function'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a node in document.body when possible, otherwise keep it in the
|
||||
* current React tree. The fallback is useful for SSR and react-test-renderer
|
||||
* and also keeps a broken document stub from crashing the whole page.
|
||||
*/
|
||||
export function renderInPortal(node: ReactNode): ReactNode {
|
||||
// React test renderer cannot reconcile a ReactDOM portal even when a jsdom
|
||||
// document is present. Keep tests deterministic and exercise the same
|
||||
// inline fallback used by SSR; browser builds still portal to body.
|
||||
if (isTestRuntime()) return node;
|
||||
const target = getPortalTarget();
|
||||
if (!target) return node;
|
||||
|
||||
try {
|
||||
return createPortal(node, target);
|
||||
} catch {
|
||||
return node;
|
||||
}
|
||||
}
|
||||
|
||||
/** Acquire one reference-counted body-scroll lock. */
|
||||
export function acquireBodyScrollLock(): () => void {
|
||||
const body = getBody();
|
||||
if (!body) return () => undefined;
|
||||
|
||||
if (bodyLockState && bodyLockState.body === body) {
|
||||
bodyLockState.count += 1;
|
||||
body.style.overflow = 'hidden';
|
||||
} else {
|
||||
// A document can be replaced by a host shell (or by a test) while an
|
||||
// overlay is mounted. Restore the old body before switching ownership.
|
||||
if (bodyLockState) {
|
||||
bodyLockState.body.style.overflow = bodyLockState.previousOverflow;
|
||||
}
|
||||
bodyLockState = {
|
||||
body,
|
||||
count: 1,
|
||||
previousOverflow: body.style.overflow || '',
|
||||
};
|
||||
body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
let released = false;
|
||||
return () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
|
||||
if (!bodyLockState || bodyLockState.body !== body) return;
|
||||
bodyLockState.count -= 1;
|
||||
if (bodyLockState.count > 0) return;
|
||||
|
||||
body.style.overflow = bodyLockState.previousOverflow;
|
||||
bodyLockState = null;
|
||||
};
|
||||
}
|
||||
|
||||
/** React hook wrapper for the shared body lock. */
|
||||
export function useBodyScrollLock(active: boolean): void {
|
||||
useEffect(() => {
|
||||
if (!active) return undefined;
|
||||
return acquireBodyScrollLock();
|
||||
}, [active]);
|
||||
}
|
||||
|
||||
type FocusTarget = HTMLElement & {
|
||||
isConnected?: boolean;
|
||||
};
|
||||
|
||||
function isFocusableTarget(value: Element | null): value is FocusTarget {
|
||||
if (!value || typeof (value as FocusTarget).focus !== 'function') return false;
|
||||
// Older test doubles do not expose isConnected. In that case a focus call
|
||||
// is still the safest best effort and mirrors browser behavior.
|
||||
return (value as FocusTarget).isConnected !== false;
|
||||
}
|
||||
|
||||
function focusWithoutScroll(target: FocusTarget): void {
|
||||
try {
|
||||
target.focus({ preventScroll: true });
|
||||
} catch {
|
||||
// Some narrow DOM/test doubles only implement focus() without options.
|
||||
try {
|
||||
target.focus();
|
||||
} catch {
|
||||
// Focus restoration is an enhancement; never make closing an overlay
|
||||
// fail because a host document has a partial focus implementation.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Capture the trigger before Radix (or a browser) moves focus into the panel.
|
||||
const useSafeLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect;
|
||||
|
||||
/**
|
||||
* Remember the element that was focused when an overlay opened and return
|
||||
* focus to it after close. The cleanup path also handles an unmount while the
|
||||
* overlay is still open.
|
||||
*/
|
||||
export function useFocusReturn(active: boolean, fallback?: FocusTarget | null): void {
|
||||
const triggerRef = useRef<FocusTarget | null>(null);
|
||||
const wasActiveRef = useRef(false);
|
||||
|
||||
useSafeLayoutEffect(() => {
|
||||
const doc = getDocument();
|
||||
|
||||
if (active) {
|
||||
if (!wasActiveRef.current && doc && isFocusableTarget(doc.activeElement)) {
|
||||
// Do not remember body itself as a trigger. Returning focus to body is
|
||||
// both noisy for keyboard users and less useful than the supplied
|
||||
// fallback (typically the close button or page heading).
|
||||
triggerRef.current = doc.activeElement === doc.body
|
||||
? (fallback ?? null)
|
||||
: doc.activeElement;
|
||||
}
|
||||
wasActiveRef.current = true;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!wasActiveRef.current) return undefined;
|
||||
wasActiveRef.current = false;
|
||||
const remembered = triggerRef.current;
|
||||
const target = remembered && isFocusableTarget(remembered)
|
||||
? remembered
|
||||
: (fallback ?? null);
|
||||
triggerRef.current = null;
|
||||
if (target && isFocusableTarget(target)) {
|
||||
// Queue after the close state has committed, so a parent that removes
|
||||
// the trigger and re-adds it in the same tick gets the final node.
|
||||
const timer = globalThis.setTimeout(() => focusWithoutScroll(target), 0);
|
||||
return () => globalThis.clearTimeout(timer);
|
||||
}
|
||||
return undefined;
|
||||
}, [active, fallback]);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (!wasActiveRef.current) return;
|
||||
wasActiveRef.current = false;
|
||||
const remembered = triggerRef.current;
|
||||
const target = remembered && isFocusableTarget(remembered)
|
||||
? remembered
|
||||
: (fallback ?? null);
|
||||
triggerRef.current = null;
|
||||
if (target && isFocusableTarget(target)) focusWithoutScroll(target);
|
||||
}, [fallback]);
|
||||
}
|
||||
|
||||
type EscapeRegistration = {
|
||||
token: symbol;
|
||||
callbackRef: { current: (event: KeyboardEvent) => void };
|
||||
};
|
||||
|
||||
// Only the top-most overlay handles Escape. This matters for a drawer opened
|
||||
// over a dialog and avoids closing two surfaces from one key press.
|
||||
const escapeStack: EscapeRegistration[] = [];
|
||||
|
||||
/** Register a top-most Escape handler with complete listener cleanup. */
|
||||
export function useEscapeKey(active: boolean, onEscape: (event: KeyboardEvent) => void): void {
|
||||
const callbackRef = useRef(onEscape);
|
||||
callbackRef.current = onEscape;
|
||||
const tokenRef = useRef<symbol | undefined>(undefined);
|
||||
if (!tokenRef.current) tokenRef.current = Symbol('overlay-escape');
|
||||
|
||||
useEffect(() => {
|
||||
const doc = getDocument();
|
||||
if (!active || !doc || typeof doc.addEventListener !== 'function') return undefined;
|
||||
|
||||
const registration: EscapeRegistration = {
|
||||
token: tokenRef.current as symbol,
|
||||
callbackRef,
|
||||
};
|
||||
escapeStack.push(registration);
|
||||
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Escape') return;
|
||||
const top = escapeStack[escapeStack.length - 1];
|
||||
if (!top || top.token !== registration.token) return;
|
||||
top.callbackRef.current(event);
|
||||
};
|
||||
|
||||
doc.addEventListener('keydown', handleKeydown);
|
||||
return () => {
|
||||
doc.removeEventListener('keydown', handleKeydown);
|
||||
const index = escapeStack.findIndex((item) => item.token === registration.token);
|
||||
if (index >= 0) escapeStack.splice(index, 1);
|
||||
};
|
||||
}, [active, callbackRef, tokenRef]);
|
||||
}
|
||||
|
||||
/** Exposed only for focused tests and diagnostics; production code uses the hook. */
|
||||
export function getBodyScrollLockCount(): number {
|
||||
return bodyLockState?.count ?? 0;
|
||||
}
|
||||
@@ -1,10 +1,18 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export function useAnimatedVisibility(visible: boolean, durationMs = 220) {
|
||||
export function useAnimatedVisibility(
|
||||
visible: boolean,
|
||||
durationMs = 220,
|
||||
manageLifecycle = true,
|
||||
) {
|
||||
const [shouldRender, setShouldRender] = useState(visible);
|
||||
const [isVisible, setIsVisible] = useState(visible);
|
||||
|
||||
useEffect(() => {
|
||||
// A shared surface may receive an already-managed presence object from a
|
||||
// parent during an incremental migration. Keep the hook call unconditional
|
||||
// (React's rules of hooks) but do not schedule a second RAF/timer owner.
|
||||
if (!manageLifecycle) return undefined;
|
||||
if (visible) {
|
||||
setShouldRender(true);
|
||||
if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
|
||||
@@ -23,7 +31,7 @@ export function useAnimatedVisibility(visible: boolean, durationMs = 220) {
|
||||
|
||||
const timerId = globalThis.setTimeout(() => setShouldRender(false), durationMs);
|
||||
return () => globalThis.clearTimeout(timerId);
|
||||
}, [visible, durationMs]);
|
||||
}, [durationMs, manageLifecycle, visible]);
|
||||
|
||||
return { shouldRender, isVisible };
|
||||
}
|
||||
|
||||
@@ -125,4 +125,19 @@ describe('useIsMobile', () => {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('detaches from the captured host when global window is replaced before unmount', async () => {
|
||||
let root!: WebTestRenderer;
|
||||
|
||||
await act(async () => {
|
||||
root = create(<Probe />);
|
||||
});
|
||||
|
||||
// Simulate an embedded host teardown/replacement while the React tree is
|
||||
// still mounted. Cleanup should use the original listener owner.
|
||||
vi.stubGlobal('window', undefined);
|
||||
await expect(act(async () => {
|
||||
root.unmount();
|
||||
})).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,12 +7,17 @@ export function useIsMobile(breakpoint = MOBILE_BREAKPOINT) {
|
||||
));
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
// Capture the host object for the complete effect lifetime. Embedded
|
||||
// shells and tests may replace globalThis.window while the component is
|
||||
// still mounted; cleanup must detach from the same object that received
|
||||
// the listener instead of reading a possibly missing global later.
|
||||
const hostWindow = typeof window === 'undefined' ? null : window;
|
||||
if (!hostWindow) return;
|
||||
const query = breakpoint === MOBILE_BREAKPOINT
|
||||
? MOBILE_MEDIA_QUERY
|
||||
: `(max-width: ${breakpoint}px)`;
|
||||
const media = typeof window.matchMedia === 'function' ? window.matchMedia(query) : null;
|
||||
const update = () => setIsMobile(media ? media.matches : window.innerWidth <= breakpoint);
|
||||
const media = typeof hostWindow.matchMedia === 'function' ? hostWindow.matchMedia(query) : null;
|
||||
const update = () => setIsMobile(media ? media.matches : hostWindow.innerWidth <= breakpoint);
|
||||
update();
|
||||
|
||||
if (media && typeof media.addEventListener === 'function') {
|
||||
@@ -24,14 +29,14 @@ export function useIsMobile(breakpoint = MOBILE_BREAKPOINT) {
|
||||
};
|
||||
}
|
||||
|
||||
const addResizeListener = typeof window.addEventListener === 'function';
|
||||
const removeResizeListener = typeof window.removeEventListener === 'function';
|
||||
const addResizeListener = typeof hostWindow.addEventListener === 'function';
|
||||
const removeResizeListener = typeof hostWindow.removeEventListener === 'function';
|
||||
if (!addResizeListener) return;
|
||||
|
||||
window.addEventListener('resize', update);
|
||||
hostWindow.addEventListener('resize', update);
|
||||
return () => {
|
||||
if (removeResizeListener) {
|
||||
window.removeEventListener('resize', update);
|
||||
hostWindow.removeEventListener('resize', update);
|
||||
}
|
||||
};
|
||||
}, [breakpoint]);
|
||||
|
||||
@@ -429,6 +429,32 @@ body {
|
||||
border-width: 3px;
|
||||
}
|
||||
|
||||
.ui-loading-state {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.ui-visually-hidden {
|
||||
position: absolute !important;
|
||||
width: 1px !important;
|
||||
height: 1px !important;
|
||||
padding: 0 !important;
|
||||
margin: -1px !important;
|
||||
overflow: hidden !important;
|
||||
clip: rect(0, 0, 0, 0) !important;
|
||||
white-space: nowrap !important;
|
||||
border: 0 !important;
|
||||
}
|
||||
|
||||
.empty-state-action,
|
||||
.error-state-action {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
/* ===== Topbar ===== */
|
||||
.topbar {
|
||||
height: var(--topbar-height);
|
||||
@@ -796,6 +822,13 @@ body {
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.btn-primary .spinner,
|
||||
.btn-danger .spinner,
|
||||
.btn-success .spinner {
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
border-top-color: white;
|
||||
}
|
||||
|
||||
.btn::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
@@ -4216,6 +4249,11 @@ textarea {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.empty-state-icon > svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.empty-state-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
@@ -4545,6 +4583,15 @@ textarea {
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.modern-select-option.is-highlighted {
|
||||
background: rgba(37, 99, 235, 0.1);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.modern-select-option.is-active.is-highlighted {
|
||||
background: rgba(37, 99, 235, 0.18);
|
||||
}
|
||||
|
||||
.modern-select-option.is-disabled {
|
||||
color: var(--color-text-muted);
|
||||
cursor: not-allowed;
|
||||
|
||||
+35
-50
@@ -10,6 +10,8 @@ import ModernSelect from "../components/ModernSelect.js";
|
||||
import { MobileCard, MobileField } from "../components/MobileCard.js";
|
||||
import { useIsMobile } from "../components/useIsMobile.js";
|
||||
import DeleteConfirmModal from "../components/DeleteConfirmModal.js";
|
||||
import { EmptyState } from "../components/ui/feedback.js";
|
||||
import { AsyncButton, Button } from "../components/ui/Button.js";
|
||||
import SiteBadgeLink from "../components/SiteBadgeLink.js";
|
||||
import AccountModelsModal from "./accounts/AccountModelsModal.js";
|
||||
import {
|
||||
@@ -26,7 +28,7 @@ import {
|
||||
clearFocusParams,
|
||||
readFocusAccountIntent,
|
||||
} from "./helpers/navigationFocus.js";
|
||||
import { TokensPanel } from "./Tokens.js";
|
||||
import { TokensPanel } from "./tokens/TokensPanel.js";
|
||||
import { tr } from "../i18n.js";
|
||||
import {
|
||||
buildCustomReorderUpdates,
|
||||
@@ -2586,29 +2588,17 @@ export default function Accounts() {
|
||||
bodyStyle={{ display: "flex", flexDirection: "column", gap: 12 }}
|
||||
footer={
|
||||
<>
|
||||
<button onClick={closeEditPanel} className="btn btn-ghost">
|
||||
<Button onClick={closeEditPanel} variant="ghost">
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<AsyncButton
|
||||
onClick={saveEditPanel}
|
||||
disabled={savingEdit}
|
||||
className="btn btn-primary"
|
||||
loading={savingEdit}
|
||||
variant="primary"
|
||||
loadingLabel="保存中..."
|
||||
>
|
||||
{savingEdit ? (
|
||||
<>
|
||||
<span
|
||||
className="spinner spinner-sm"
|
||||
style={{
|
||||
borderTopColor: "white",
|
||||
borderColor: "rgba(255,255,255,0.3)",
|
||||
}}
|
||||
/>{" "}
|
||||
保存中...
|
||||
</>
|
||||
) : (
|
||||
"保存修改"
|
||||
)}
|
||||
</button>
|
||||
保存修改
|
||||
</AsyncButton>
|
||||
</>
|
||||
}
|
||||
>
|
||||
@@ -3425,35 +3415,30 @@ export default function Accounts() {
|
||||
</table>
|
||||
)
|
||||
) : (
|
||||
<div className="empty-state">
|
||||
<svg
|
||||
className="empty-state-icon"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1}
|
||||
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
<div className="empty-state-title">
|
||||
{activeSegment === "apikey"
|
||||
? "暂无 API Key 连接"
|
||||
: "暂无 Session 连接"}
|
||||
</div>
|
||||
<div className="empty-state-desc">
|
||||
{activeSegment === "apikey"
|
||||
? sites.length > 0
|
||||
? "请为现有站点补充 API Key 连接"
|
||||
: "请先添加站点,然后为站点补充 API Key 连接"
|
||||
: sites.length > 0
|
||||
? "请为现有站点添加 Session 连接"
|
||||
: "请先添加站点,然后添加 Session 连接"}
|
||||
</div>
|
||||
</div>
|
||||
<EmptyState
|
||||
title={activeSegment === "apikey" ? "暂无 API Key 连接" : "暂无 Session 连接"}
|
||||
description={activeSegment === "apikey"
|
||||
? sites.length > 0
|
||||
? "请为现有站点补充 API Key 连接"
|
||||
: "请先添加站点,然后为站点补充 API Key 连接"
|
||||
: sites.length > 0
|
||||
? "请为现有站点添加 Session 连接"
|
||||
: "请先添加站点,然后添加 Session 连接"}
|
||||
icon={(
|
||||
<svg
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1}
|
||||
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { api } from '../api.js';
|
||||
import { useToast } from '../components/Toast.js';
|
||||
import { AsyncButton } from '../components/ui/Button.js';
|
||||
import { useIsMobile } from '../components/useIsMobile.js';
|
||||
import ChangeKeyModal from '../components/ChangeKeyModal.js';
|
||||
import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js';
|
||||
@@ -1446,9 +1447,14 @@ export default function Settings() {
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<button onClick={saveSchedule} disabled={savingSchedule} className="btn btn-primary">
|
||||
{savingSchedule ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 保存中...</> : '保存定时任务'}
|
||||
</button>
|
||||
<AsyncButton
|
||||
onClick={saveSchedule}
|
||||
loading={savingSchedule}
|
||||
variant="primary"
|
||||
loadingLabel="保存中..."
|
||||
>
|
||||
保存定时任务
|
||||
</AsyncButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
+6
-1456
File diff suppressed because it is too large
Load Diff
@@ -11,7 +11,6 @@ const pages = [
|
||||
'src/web/pages/ProxyLogs.tsx',
|
||||
'src/web/pages/Sites.tsx',
|
||||
'src/web/pages/TokenRoutes.tsx',
|
||||
'src/web/pages/Tokens.tsx',
|
||||
];
|
||||
|
||||
describe('ResponsiveFilterPanel adoption', () => {
|
||||
@@ -23,5 +22,15 @@ describe('ResponsiveFilterPanel adoption', () => {
|
||||
expect(source, page).not.toContain("import MobileFilterSheet from '../components/MobileFilterSheet.js'");
|
||||
expect(source, page).not.toContain('<MobileFilterSheet');
|
||||
}
|
||||
|
||||
const tokenPanel = readFileSync(
|
||||
resolve(process.cwd(), 'src/web/pages/tokens/TokensPanel.tsx'),
|
||||
'utf8',
|
||||
).replace(/\r\n/g, '\n');
|
||||
expect(tokenPanel, 'src/web/pages/tokens/TokensPanel.tsx').toMatch(
|
||||
/import\s+ResponsiveFilterPanel\s+from\s+['"]\.\.\/\.\.\/components\/ResponsiveFilterPanel\.js['"]/,
|
||||
);
|
||||
expect(tokenPanel).not.toContain("import MobileFilterSheet from '../../components/MobileFilterSheet.js'");
|
||||
expect(tokenPanel).not.toContain('<MobileFilterSheet');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import React from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Dialog, type DialogPresence } from '../../components/ui/Dialog.js';
|
||||
import { AsyncButton, Button } from '../../components/ui/Button.js';
|
||||
|
||||
type ModalPresence = {
|
||||
shouldRender: boolean;
|
||||
isVisible: boolean;
|
||||
};
|
||||
type ModalPresence = DialogPresence;
|
||||
|
||||
type FactoryResetModalProps = {
|
||||
presence: ModalPresence;
|
||||
@@ -15,6 +12,13 @@ type FactoryResetModalProps = {
|
||||
onConfirm: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Destructive reset confirmation surface.
|
||||
*
|
||||
* The settings page owns the open/exit presence state; handing that state to
|
||||
* the shared Dialog keeps the existing animation timing while centralising
|
||||
* portal, focus, scroll-lock, and accessibility behaviour.
|
||||
*/
|
||||
export default function FactoryResetModal({
|
||||
presence,
|
||||
factoryResetting,
|
||||
@@ -23,46 +27,51 @@ export default function FactoryResetModal({
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: FactoryResetModalProps) {
|
||||
if (!presence.shouldRender) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const confirmLabel = factoryResetting
|
||||
? '重新初始化中...'
|
||||
: (factoryResetSecondsLeft > 0
|
||||
? `确认重新初始化系统(${factoryResetSecondsLeft}s)`
|
||||
: '确认重新初始化系统');
|
||||
|
||||
const modal = (
|
||||
<div className={`modal-backdrop ${presence.isVisible ? '' : 'is-closing'}`.trim()} onClick={onClose}>
|
||||
<div
|
||||
className={`modal-content ${presence.isVisible ? '' : 'is-closing'}`.trim()}
|
||||
style={{ maxWidth: 720, border: '1px solid color-mix(in srgb, var(--color-danger) 35%, var(--color-border))' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="modal-header" style={{ color: 'var(--color-danger)' }}>确认重新初始化系统</div>
|
||||
<div className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div style={{ padding: 12, borderRadius: 'var(--radius-sm)', background: 'var(--color-danger-bg)', color: 'var(--color-danger)', fontSize: 12, lineHeight: 1.8 }}>
|
||||
这是不可逆操作。系统会清空当前 metapi 使用中的全部数据库内容,并在成功后立即退出当前登录状态。
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--color-text-secondary)', lineHeight: 1.9 }}>
|
||||
<div>• 当前若使用外部 MySQL/Postgres,也会先清空该外部库中的 metapi 数据。</div>
|
||||
<div>• 系统随后会强制切回默认 SQLite。</div>
|
||||
<div>• 管理员 Token 将重置为 <code style={{ fontFamily: 'var(--font-mono)' }}>{adminToken}</code>。</div>
|
||||
<div>• 完成后会立即退出登录并刷新页面,回到当前首装初始状态。</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button onClick={onClose} disabled={factoryResetting} className="btn btn-ghost">取消</button>
|
||||
<button onClick={onConfirm} disabled={factoryResetting || factoryResetSecondsLeft > 0} className="btn btn-danger">
|
||||
{factoryResetting
|
||||
? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> {confirmLabel}</>
|
||||
: confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
return (
|
||||
<Dialog
|
||||
open={presence.isVisible}
|
||||
onClose={onClose}
|
||||
presence={presence}
|
||||
title={<span style={{ color: 'var(--color-danger)' }}>确认重新初始化系统</span>}
|
||||
maxWidth={720}
|
||||
contentStyle={{ border: '1px solid color-mix(in srgb, var(--color-danger) 35%, var(--color-border))' }}
|
||||
bodyStyle={{ display: 'flex', flexDirection: 'column', gap: 12 }}
|
||||
closeOnBackdrop
|
||||
closeOnEscape={false}
|
||||
showCloseButton={false}
|
||||
ariaLabel="确认重新初始化系统"
|
||||
footer={(
|
||||
<>
|
||||
<Button onClick={onClose} disabled={factoryResetting} variant="ghost">
|
||||
取消
|
||||
</Button>
|
||||
<AsyncButton
|
||||
onClick={onConfirm}
|
||||
disabled={factoryResetting || factoryResetSecondsLeft > 0}
|
||||
variant="danger"
|
||||
pending={factoryResetting}
|
||||
loadingLabel={confirmLabel}
|
||||
>
|
||||
{confirmLabel}
|
||||
</AsyncButton>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<div style={{ padding: 12, borderRadius: 'var(--radius-sm)', background: 'var(--color-danger-bg)', color: 'var(--color-danger)', fontSize: 12, lineHeight: 1.8 }}>
|
||||
这是不可逆操作。系统会清空当前 metapi 使用中的全部数据库内容,并在成功后立即退出当前登录状态。
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--color-text-secondary)', lineHeight: 1.9 }}>
|
||||
<div>• 当前若使用外部 MySQL/Postgres,也会先清空该外部库中的 metapi 数据。</div>
|
||||
<div>• 系统随后会强制切回默认 SQLite。</div>
|
||||
<div>• 管理员 Token 将重置为 <code style={{ fontFamily: 'var(--font-mono)' }}>{adminToken}</code>。</div>
|
||||
<div>• 完成后会立即退出登录并刷新页面,回到当前首装初始状态。</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
return typeof document !== 'undefined' ? createPortal(modal, document.body) : modal;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import React from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Dialog, type DialogPresence } from '../../components/ui/Dialog.js';
|
||||
import { AsyncButton, Button } from '../../components/ui/Button.js';
|
||||
|
||||
type ModalPresence = {
|
||||
shouldRender: boolean;
|
||||
isVisible: boolean;
|
||||
};
|
||||
type ModalPresence = DialogPresence;
|
||||
|
||||
type ModelAvailabilityProbeConfirmModalProps = {
|
||||
presence: ModalPresence;
|
||||
@@ -16,6 +13,7 @@ type ModelAvailabilityProbeConfirmModalProps = {
|
||||
onConfirm: () => void;
|
||||
};
|
||||
|
||||
/** Confirmation gate for the high-risk model availability probe setting. */
|
||||
export default function ModelAvailabilityProbeConfirmModal({
|
||||
presence,
|
||||
confirmText,
|
||||
@@ -25,81 +23,83 @@ export default function ModelAvailabilityProbeConfirmModal({
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: ModelAvailabilityProbeConfirmModalProps) {
|
||||
if (!presence.shouldRender) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const canConfirm = confirmationInput.trim() === confirmText;
|
||||
const titleId = 'model-availability-probe-confirm-modal-title';
|
||||
const descriptionId = 'model-availability-probe-confirm-modal-description';
|
||||
const handleRequestClose = () => {
|
||||
if (saving) return;
|
||||
onClose();
|
||||
};
|
||||
|
||||
const modal = (
|
||||
<div className={`modal-backdrop ${presence.isVisible ? '' : 'is-closing'}`.trim()} onClick={handleRequestClose}>
|
||||
<div
|
||||
className={`modal-content ${presence.isVisible ? '' : 'is-closing'}`.trim()}
|
||||
style={{ maxWidth: 760, border: '1px solid color-mix(in srgb, var(--color-danger) 35%, var(--color-border))' }}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
aria-describedby={descriptionId}
|
||||
aria-busy={saving}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div id={titleId} className="modal-header" style={{ color: 'var(--color-danger)' }}>确认开启批量测活</div>
|
||||
<div id={descriptionId} className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div style={{ padding: 12, borderRadius: 'var(--radius-sm)', background: 'var(--color-danger-bg)', color: 'var(--color-danger)', fontSize: 12, lineHeight: 1.8 }}>
|
||||
开启后,metapi 会在后台对活跃账号模型做最小化探测请求。这可能被部分中转站视为批量测活或异常行为,请务必先确认你的中转站明确允许此类探测。
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--color-text-secondary)', lineHeight: 1.9 }}>
|
||||
请手动输入以下整句后再开启:
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
padding: 12,
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
border: '1px solid var(--color-border-light)',
|
||||
background: 'var(--color-bg)',
|
||||
color: 'var(--color-text-primary)',
|
||||
fontSize: 13,
|
||||
lineHeight: 1.8,
|
||||
}}
|
||||
return (
|
||||
<Dialog
|
||||
open={presence.isVisible}
|
||||
onClose={handleRequestClose}
|
||||
presence={presence}
|
||||
title={<span style={{ color: 'var(--color-danger)' }}>确认开启批量测活</span>}
|
||||
maxWidth={760}
|
||||
contentStyle={{ border: '1px solid color-mix(in srgb, var(--color-danger) 35%, var(--color-border))' }}
|
||||
closeOnBackdrop
|
||||
closeOnEscape={false}
|
||||
showCloseButton={false}
|
||||
ariaLabel="确认开启批量测活"
|
||||
ariaDescribedBy={descriptionId}
|
||||
ariaBusy={saving}
|
||||
footer={(
|
||||
<>
|
||||
<Button onClick={handleRequestClose} disabled={saving} variant="ghost">
|
||||
取消
|
||||
</Button>
|
||||
<AsyncButton
|
||||
onClick={onConfirm}
|
||||
disabled={saving || !canConfirm}
|
||||
variant="danger"
|
||||
pending={saving}
|
||||
loadingLabel="确认开启中..."
|
||||
>
|
||||
{confirmText}
|
||||
</div>
|
||||
<textarea
|
||||
value={confirmationInput}
|
||||
onChange={(e) => onConfirmationInputChange(e.target.value)}
|
||||
placeholder="请输入上方确认语句"
|
||||
spellCheck={false}
|
||||
style={{
|
||||
width: '100%',
|
||||
minHeight: 96,
|
||||
padding: '10px 14px',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
fontSize: 13,
|
||||
outline: 'none',
|
||||
resize: 'vertical',
|
||||
background: 'var(--color-bg)',
|
||||
color: 'var(--color-text-primary)',
|
||||
}}
|
||||
/>
|
||||
确认开启批量测活
|
||||
</AsyncButton>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<div id={descriptionId} style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div style={{ padding: 12, borderRadius: 'var(--radius-sm)', background: 'var(--color-danger-bg)', color: 'var(--color-danger)', fontSize: 12, lineHeight: 1.8 }}>
|
||||
开启后,metapi 会在后台对活跃账号模型做最小化探测请求。这可能被部分中转站视为批量测活或异常行为,请务必先确认你的中转站明确允许此类探测。
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button onClick={handleRequestClose} disabled={saving} className="btn btn-ghost">取消</button>
|
||||
<button onClick={onConfirm} disabled={saving || !canConfirm} className="btn btn-danger">
|
||||
{saving
|
||||
? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 确认开启中...</>
|
||||
: '确认开启批量测活'}
|
||||
</button>
|
||||
<div style={{ fontSize: 13, color: 'var(--color-text-secondary)', lineHeight: 1.9 }}>
|
||||
请手动输入以下整句后再开启:
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
padding: 12,
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
border: '1px solid var(--color-border-light)',
|
||||
background: 'var(--color-bg)',
|
||||
color: 'var(--color-text-primary)',
|
||||
fontSize: 13,
|
||||
lineHeight: 1.8,
|
||||
}}
|
||||
>
|
||||
{confirmText}
|
||||
</div>
|
||||
<textarea
|
||||
value={confirmationInput}
|
||||
onChange={(event) => onConfirmationInputChange(event.target.value)}
|
||||
placeholder="请输入上方确认语句"
|
||||
spellCheck={false}
|
||||
style={{
|
||||
width: '100%',
|
||||
minHeight: 96,
|
||||
padding: '10px 14px',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
fontSize: 13,
|
||||
outline: 'none',
|
||||
resize: 'vertical',
|
||||
background: 'var(--color-bg)',
|
||||
color: 'var(--color-text-primary)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
return typeof document !== 'undefined' ? createPortal(modal, document.body) : modal;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { resolve } from 'node:path';
|
||||
|
||||
describe('Tokens actions layout', () => {
|
||||
it('reuses a wrapping actions cell layout so token row actions do not overflow', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/web/pages/Tokens.tsx'), 'utf8');
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/web/pages/tokens/TokensPanel.tsx'), 'utf8');
|
||||
const css = readFileSync(resolve(process.cwd(), 'src/web/index.css'), 'utf8');
|
||||
|
||||
expect(source).toContain('className="token-actions-cell"');
|
||||
|
||||
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, create } from 'react-test-renderer';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { ToastProvider } from '../components/Toast.js';
|
||||
import { TokensPanel } from './Tokens.js';
|
||||
import { TokensPanel } from './tokens/TokensPanel.js';
|
||||
import { installAccountsSnapshotCompat } from './testApiCompat.js';
|
||||
|
||||
const { apiMock } = vi.hoisted(() => ({
|
||||
|
||||
@@ -4,9 +4,9 @@ import { resolve } from 'node:path';
|
||||
|
||||
describe('Tokens centered modal adoption', () => {
|
||||
it('uses CenteredModal for add/edit token flows instead of inline form cards', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/web/pages/Tokens.tsx'), 'utf8');
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/web/pages/tokens/TokensPanel.tsx'), 'utf8');
|
||||
|
||||
expect(source).toContain("import CenteredModal from '../components/CenteredModal.js'");
|
||||
expect(source).toContain("import CenteredModal from '../../components/CenteredModal.js'");
|
||||
expect(source).toContain('<CenteredModal');
|
||||
expect(source).not.toContain('addPanelPresence.shouldRender && (');
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import { MemoryRouter } from 'react-router-dom';
|
||||
import ModernSelect from '../components/ModernSelect.js';
|
||||
import { ToastProvider } from '../components/Toast.js';
|
||||
import Accounts from './Accounts.js';
|
||||
import { TokensPanel } from './Tokens.js';
|
||||
import { TokensPanel } from './tokens/TokensPanel.js';
|
||||
import { installAccountsSnapshotCompat } from './testApiCompat.js';
|
||||
|
||||
const { apiMock } = vi.hoisted(() => ({
|
||||
|
||||
@@ -4,7 +4,7 @@ import { resolve } from 'node:path';
|
||||
|
||||
describe('Tokens embedded layout', () => {
|
||||
it('reuses accounts-page-actions layout when embedded in 连接管理', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/web/pages/Tokens.tsx'), 'utf8');
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/web/pages/tokens/TokensPanel.tsx'), 'utf8');
|
||||
|
||||
expect(source).toContain("className={`page-actions ${embedded ? 'accounts-page-actions' : ''}`.trim()}");
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, create } from 'react-test-renderer';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { ToastProvider } from '../components/Toast.js';
|
||||
import { TokensPanel } from './Tokens.js';
|
||||
import { TokensPanel } from './tokens/TokensPanel.js';
|
||||
import { installAccountsSnapshotCompat } from './testApiCompat.js';
|
||||
|
||||
const { apiMock } = vi.hoisted(() => ({
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
function readSource(relativePath: string): string {
|
||||
return readFileSync(resolve(process.cwd(), relativePath), 'utf8').replace(/\r\n/g, '\n');
|
||||
}
|
||||
|
||||
describe('Tokens panel page boundary', () => {
|
||||
it('embeds the token domain panel without importing the legacy top-level route page', () => {
|
||||
const accounts = readSource('src/web/pages/Accounts.tsx');
|
||||
|
||||
expect(accounts).toMatch(
|
||||
/import\s+\{\s*TokensPanel\s*\}\s+from\s+["']\.\/tokens\/TokensPanel\.js["']/
|
||||
);
|
||||
expect(accounts).not.toMatch(/from\s+["']\.\/Tokens\.js["']/);
|
||||
});
|
||||
|
||||
it('keeps the legacy route as a thin redirect and compatibility export', () => {
|
||||
const route = readSource('src/web/pages/Tokens.tsx');
|
||||
|
||||
expect(route).toMatch(
|
||||
/export\s+\{\s*TokensPanel\s*\}\s+from\s+['"]\.\/tokens\/TokensPanel\.js['"]/
|
||||
);
|
||||
expect(route).toContain('return <Navigate to={`/accounts');
|
||||
expect(route).not.toContain('function TokensPanel(');
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user