mirror of
https://github.com/iflytek/skillhub.git
synced 2026-08-27 11:14:59 +00:00
fix(web): stop React 19 portal removeChild/insertBefore crashes (#624)
* fix(web): replace custom Dialog with Radix to stop portal races Hand-rolled createPortal siblings on document.body raced with Radix Select/DropdownMenu/Sonner under React 19 (insertBefore/removeChild). Use @radix-ui/react-dialog like select/dropdown adapters and bail out overview ResizeObserver setState when values are unchanged. Signed-off-by: Mikhail Neradkov <michael.neradkov@gmail.com> * fix(web): stop portal removeChild races on search and shell Move Radix portals to #skillhub-portals, drop language-switcher body portal (match UserMenu), remove Select/Dropdown exit animations, and keep previous search results while typing to avoid skeleton unmount churn. Signed-off-by: Mikhail Neradkov <michael.neradkov@gmail.com> * fix(web): systemic overlay teardown for React 19 removeChild Render Select/Dropdown in-tree (no Portal), translate=no on overlays, dismiss overlays on pathname change, defer navigate after close, host Toaster in #skillhub-portals, and add recoverable RouteError UI. Signed-off-by: Mikhail Neradkov <michael.neradkov@gmail.com> * fix(web): harden overlay dismiss after code review Clip decorative layer only, dismiss Dialog via overlay pointer events, log RouteError once in useEffect, and defer skill-detail leave navigations. Signed-off-by: Mikhail Neradkov <michael.neradkov@gmail.com> * fix(web): batch starred highlight and defer Sonner toasts Avoid N× per-card star fetches on /search and flushSync toast races during React 19 list re-renders (removeChild / insertBefore). Signed-off-by: Mikhail Neradkov <michael.neradkov@gmail.com> * fix(web): block Chrome Translate DOM races on skill overview Keep html/#root notranslate, sync lang with i18n, and harden overview expand so memoized markdown + quiet ResizeObserver avoid insertBefore crashes. Signed-off-by: Mikhail Neradkov <michael.neradkov@gmail.com> --------- Signed-off-by: Mikhail Neradkov <michael.neradkov@gmail.com>
This commit is contained in:
parent
55e5c1e10e
commit
a063090a52
38 changed files with 1084 additions and 336 deletions
|
|
@ -1,8 +1,9 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<html lang="zh-CN" translate="no" class="notranslate">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="google" content="notranslate" />
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: blob: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' ws: wss: http://localhost:* https://localhost:*; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'"
|
||||
|
|
@ -14,7 +15,10 @@
|
|||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<!-- translate=no: Chrome auto-translate mutates #root and races React 19 insertBefore. -->
|
||||
<div id="root" translate="no" class="notranslate"></div>
|
||||
<!-- Isolated host for Dialog/Sonner portals (avoids body sibling races with #root). -->
|
||||
<div id="skillhub-portals" translate="no" class="notranslate"></div>
|
||||
<script type="module" src="/src/bootstrap.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@
|
|||
"generate-api": "openapi-typescript http://localhost:8080/v3/api-docs -o src/api/generated/schema.d.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dialog": "^1.1.23",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@tanstack/react-query": "^5.64.0",
|
||||
|
|
|
|||
302
web/pnpm-lock.yaml
generated
302
web/pnpm-lock.yaml
generated
|
|
@ -21,6 +21,9 @@ importers:
|
|||
|
||||
.:
|
||||
dependencies:
|
||||
'@radix-ui/react-dialog':
|
||||
specifier: ^1.1.23
|
||||
version: 1.1.23(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-dropdown-menu':
|
||||
specifier: ^2.1.16
|
||||
version: 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
|
|
@ -638,6 +641,9 @@ packages:
|
|||
'@radix-ui/primitive@1.1.3':
|
||||
resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==}
|
||||
|
||||
'@radix-ui/primitive@1.1.7':
|
||||
resolution: {integrity: sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==}
|
||||
|
||||
'@radix-ui/react-arrow@1.1.7':
|
||||
resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==}
|
||||
peerDependencies:
|
||||
|
|
@ -673,6 +679,15 @@ packages:
|
|||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-compose-refs@1.1.5':
|
||||
resolution: {integrity: sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-context@1.1.2':
|
||||
resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==}
|
||||
peerDependencies:
|
||||
|
|
@ -682,6 +697,28 @@ packages:
|
|||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-context@1.2.2':
|
||||
resolution: {integrity: sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-dialog@1.1.23':
|
||||
resolution: {integrity: sha512-Ksw4WeROkO4rC9k/onilX/Ao2Cr1ku1unMNH+XSCcP4jSXYu7HDsg9n4ojMjVb22XpYjAQ9qfrFlVbru1vXDUA==}
|
||||
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
|
||||
|
||||
'@radix-ui/react-direction@1.1.1':
|
||||
resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==}
|
||||
peerDependencies:
|
||||
|
|
@ -704,6 +741,19 @@ packages:
|
|||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-dismissable-layer@1.1.19':
|
||||
resolution: {integrity: sha512-8g4pfOL9HoKKLWGiypT+dphVqjFfmcXO5GBnhsG6zI+lxAx/8feQpr+1LSN8Re3hiZ+XkLNS4O9ztK11/LzQ6w==}
|
||||
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
|
||||
|
||||
'@radix-ui/react-dropdown-menu@2.1.16':
|
||||
resolution: {integrity: sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==}
|
||||
peerDependencies:
|
||||
|
|
@ -726,6 +776,28 @@ packages:
|
|||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-focus-guards@1.1.6':
|
||||
resolution: {integrity: sha512-RNOJjfZMTyBM6xYmV3IVGXkPjIhcBAuv48POevAXwrGJhkWZ9p1rFoIS1JFooPuT193AZmRsCPhpoVJxx6OPoQ==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-focus-scope@1.1.16':
|
||||
resolution: {integrity: sha512-wmRZ2WWLvmt6KHy2rNPOdPUjwq5xOHY02+m+udwJTn0aNIox/rkskAvJTyTLGhPK6KgrUjlJUJpgmx/+wFiFIQ==}
|
||||
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
|
||||
|
||||
'@radix-ui/react-focus-scope@1.1.7':
|
||||
resolution: {integrity: sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==}
|
||||
peerDependencies:
|
||||
|
|
@ -748,6 +820,15 @@ packages:
|
|||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-id@1.1.4':
|
||||
resolution: {integrity: sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-menu@2.1.16':
|
||||
resolution: {integrity: sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==}
|
||||
peerDependencies:
|
||||
|
|
@ -774,6 +855,19 @@ packages:
|
|||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-portal@1.1.17':
|
||||
resolution: {integrity: sha512-vKQLcWypUnwZVvfV7UkGahH2g6ySe8M8R+zYBwPrv5byZ9QAW6cQVvNKo7GgmD+p8aYb6D9JBuvy8/WhOno2wQ==}
|
||||
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
|
||||
|
||||
'@radix-ui/react-portal@1.1.9':
|
||||
resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==}
|
||||
peerDependencies:
|
||||
|
|
@ -787,6 +881,19 @@ packages:
|
|||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-presence@1.1.10':
|
||||
resolution: {integrity: sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw==}
|
||||
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
|
||||
|
||||
'@radix-ui/react-presence@1.1.5':
|
||||
resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==}
|
||||
peerDependencies:
|
||||
|
|
@ -800,6 +907,19 @@ packages:
|
|||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-primitive@2.1.10':
|
||||
resolution: {integrity: sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==}
|
||||
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
|
||||
|
||||
'@radix-ui/react-primitive@2.1.3':
|
||||
resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==}
|
||||
peerDependencies:
|
||||
|
|
@ -848,6 +968,15 @@ packages:
|
|||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-slot@1.3.3':
|
||||
resolution: {integrity: sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-use-callback-ref@1.1.1':
|
||||
resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==}
|
||||
peerDependencies:
|
||||
|
|
@ -857,6 +986,15 @@ packages:
|
|||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-use-callback-ref@1.1.4':
|
||||
resolution: {integrity: sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-use-controllable-state@1.2.2':
|
||||
resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==}
|
||||
peerDependencies:
|
||||
|
|
@ -866,6 +1004,15 @@ packages:
|
|||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-use-controllable-state@1.2.6':
|
||||
resolution: {integrity: sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-use-effect-event@0.0.2':
|
||||
resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==}
|
||||
peerDependencies:
|
||||
|
|
@ -875,6 +1022,15 @@ packages:
|
|||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-use-effect-event@0.0.5':
|
||||
resolution: {integrity: sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-use-escape-keydown@1.1.1':
|
||||
resolution: {integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==}
|
||||
peerDependencies:
|
||||
|
|
@ -893,6 +1049,15 @@ packages:
|
|||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-use-layout-effect@1.1.4':
|
||||
resolution: {integrity: sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-use-previous@1.1.1':
|
||||
resolution: {integrity: sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==}
|
||||
peerDependencies:
|
||||
|
|
@ -3386,6 +3551,8 @@ snapshots:
|
|||
|
||||
'@radix-ui/primitive@1.1.3': {}
|
||||
|
||||
'@radix-ui/primitive@1.1.7': {}
|
||||
|
||||
'@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
|
|
@ -3413,12 +3580,47 @@ snapshots:
|
|||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
|
||||
'@radix-ui/react-compose-refs@1.1.5(@types/react@19.2.14)(react@19.2.4)':
|
||||
dependencies:
|
||||
react: 19.2.4
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
|
||||
'@radix-ui/react-context@1.1.2(@types/react@19.2.14)(react@19.2.4)':
|
||||
dependencies:
|
||||
react: 19.2.4
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
|
||||
'@radix-ui/react-context@1.2.2(@types/react@19.2.14)(react@19.2.4)':
|
||||
dependencies:
|
||||
react: 19.2.4
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
|
||||
'@radix-ui/react-dialog@1.1.23(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.7
|
||||
'@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-context': 1.2.2(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-focus-guards': 1.1.6(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-focus-scope': 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-id': 1.1.4(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-portal': 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-slot': 1.3.3(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.14)(react@19.2.4)
|
||||
aria-hidden: 1.2.6
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.4)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
|
||||
'@radix-ui/react-direction@1.1.1(@types/react@19.2.14)(react@19.2.4)':
|
||||
dependencies:
|
||||
react: 19.2.4
|
||||
|
|
@ -3438,6 +3640,19 @@ snapshots:
|
|||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
|
||||
'@radix-ui/react-dismissable-layer@1.1.19(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.7
|
||||
'@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.2.14)(react@19.2.4)
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
|
||||
'@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
|
|
@ -3459,6 +3674,23 @@ snapshots:
|
|||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
|
||||
'@radix-ui/react-focus-guards@1.1.6(@types/react@19.2.14)(react@19.2.4)':
|
||||
dependencies:
|
||||
react: 19.2.4
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
|
||||
'@radix-ui/react-focus-scope@1.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.14)(react@19.2.4)
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
|
||||
'@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
|
||||
|
|
@ -3477,6 +3709,13 @@ snapshots:
|
|||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
|
||||
'@radix-ui/react-id@1.1.4(@types/react@19.2.14)(react@19.2.4)':
|
||||
dependencies:
|
||||
'@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.14)(react@19.2.4)
|
||||
react: 19.2.4
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
|
||||
'@radix-ui/react-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
|
|
@ -3521,6 +3760,16 @@ snapshots:
|
|||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
|
||||
'@radix-ui/react-portal@1.1.17(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.14)(react@19.2.4)
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
|
||||
'@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
|
|
@ -3531,6 +3780,15 @@ snapshots:
|
|||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
|
||||
'@radix-ui/react-presence@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.14)(react@19.2.4)
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
|
||||
'@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
|
||||
|
|
@ -3541,6 +3799,15 @@ snapshots:
|
|||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
|
||||
'@radix-ui/react-primitive@2.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@radix-ui/react-slot': 1.3.3(@types/react@19.2.14)(react@19.2.4)
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
|
||||
'@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4)
|
||||
|
|
@ -3603,12 +3870,25 @@ snapshots:
|
|||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
|
||||
'@radix-ui/react-slot@1.3.3(@types/react@19.2.14)(react@19.2.4)':
|
||||
dependencies:
|
||||
'@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.14)(react@19.2.4)
|
||||
react: 19.2.4
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
|
||||
'@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.14)(react@19.2.4)':
|
||||
dependencies:
|
||||
react: 19.2.4
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
|
||||
'@radix-ui/react-use-callback-ref@1.1.4(@types/react@19.2.14)(react@19.2.4)':
|
||||
dependencies:
|
||||
react: 19.2.4
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
|
||||
'@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.14)(react@19.2.4)':
|
||||
dependencies:
|
||||
'@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.4)
|
||||
|
|
@ -3617,6 +3897,15 @@ snapshots:
|
|||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
|
||||
'@radix-ui/react-use-controllable-state@1.2.6(@types/react@19.2.14)(react@19.2.4)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.7
|
||||
'@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.14)(react@19.2.4)
|
||||
react: 19.2.4
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
|
||||
'@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.14)(react@19.2.4)':
|
||||
dependencies:
|
||||
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
|
||||
|
|
@ -3624,6 +3913,13 @@ snapshots:
|
|||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
|
||||
'@radix-ui/react-use-effect-event@0.0.5(@types/react@19.2.14)(react@19.2.4)':
|
||||
dependencies:
|
||||
'@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.14)(react@19.2.4)
|
||||
react: 19.2.4
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
|
||||
'@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.14)(react@19.2.4)':
|
||||
dependencies:
|
||||
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4)
|
||||
|
|
@ -3637,6 +3933,12 @@ snapshots:
|
|||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
|
||||
'@radix-ui/react-use-layout-effect@1.1.4(@types/react@19.2.14)(react@19.2.4)':
|
||||
dependencies:
|
||||
react: 19.2.4
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
|
||||
'@radix-ui/react-use-previous@1.1.1(@types/react@19.2.14)(react@19.2.4)':
|
||||
dependencies:
|
||||
react: 19.2.4
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
import { Suspense, useEffect, useState } from 'react'
|
||||
import { Suspense, useEffect, useRef, useState } from 'react'
|
||||
import { Outlet, Link, useRouterState } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAuth } from '@/features/auth/use-auth'
|
||||
import { LanguageSwitcher } from '@/shared/components/language-switcher'
|
||||
import { UserMenu } from '@/shared/components/user-menu'
|
||||
import { NotificationBell } from '@/features/notification/notification-bell'
|
||||
import { dismissOpenOverlays } from '@/shared/lib/dismiss-open-overlays'
|
||||
import { syncDocumentLanguage } from '@/shared/lib/document-language'
|
||||
import { getAppHeaderClassName } from './layout-header-style'
|
||||
import { getAppMainContentLayout, resolveAppMainContentPathname } from './layout-main-content'
|
||||
|
||||
|
|
@ -15,7 +17,7 @@ import { getAppMainContentLayout, resolveAppMainContentPathname } from './layout
|
|||
* fallback used while lazy route modules are loading.
|
||||
*/
|
||||
export function Layout() {
|
||||
const { t } = useTranslation()
|
||||
const { t, i18n } = useTranslation()
|
||||
const { pathname, resolvedPathname } = useRouterState({
|
||||
select: (s) => ({
|
||||
pathname: s.location.pathname,
|
||||
|
|
@ -24,9 +26,14 @@ export function Layout() {
|
|||
})
|
||||
const { user, isLoading } = useAuth()
|
||||
const [isHeaderElevated, setIsHeaderElevated] = useState(false)
|
||||
const previousPathnameRef = useRef(pathname)
|
||||
const contentLayoutPathname = resolveAppMainContentPathname(pathname, resolvedPathname)
|
||||
const mainContentLayout = getAppMainContentLayout(contentLayoutPathname)
|
||||
|
||||
useEffect(() => {
|
||||
syncDocumentLanguage(i18n.resolvedLanguage ?? i18n.language)
|
||||
}, [i18n.language, i18n.resolvedLanguage])
|
||||
|
||||
useEffect(() => {
|
||||
const updateHeaderElevation = () => {
|
||||
setIsHeaderElevated(window.scrollY > 0)
|
||||
|
|
@ -40,6 +47,15 @@ export function Layout() {
|
|||
}
|
||||
}, [])
|
||||
|
||||
// Pathname-only: search debounce on /search must not dismiss overlays mid-typing.
|
||||
useEffect(() => {
|
||||
if (previousPathnameRef.current === pathname) {
|
||||
return
|
||||
}
|
||||
previousPathnameRef.current = pathname
|
||||
dismissOpenOverlays()
|
||||
}, [pathname])
|
||||
|
||||
const navItems: Array<{
|
||||
label: string
|
||||
to: string
|
||||
|
|
@ -60,15 +76,17 @@ export function Layout() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col relative overflow-x-clip" style={{ background: 'var(--bg-page, hsl(var(--background)))' }}>
|
||||
{/* Decorative gradient orb */}
|
||||
<div
|
||||
className="absolute top-0 right-0 w-[600px] h-[500px] rounded-full opacity-90 pointer-events-none z-0"
|
||||
style={{
|
||||
background: 'radial-gradient(ellipse at 70% 20%, rgba(184,94,255,0.25) 0%, rgba(106,109,255,0.15) 40%, transparent 70%)',
|
||||
filter: 'blur(60px)',
|
||||
}}
|
||||
/>
|
||||
<div className="min-h-screen flex flex-col relative" style={{ background: 'var(--bg-page, hsl(var(--background)))' }}>
|
||||
{/* Clip only the decorative layer so in-tree Select/Dropdown are not cropped. */}
|
||||
<div className="pointer-events-none absolute inset-0 z-0 overflow-x-clip" aria-hidden>
|
||||
<div
|
||||
className="absolute top-0 right-0 w-[600px] h-[500px] rounded-full opacity-90"
|
||||
style={{
|
||||
background: 'radial-gradient(ellipse at 70% 20%, rgba(184,94,255,0.25) 0%, rgba(106,109,255,0.15) 40%, transparent 70%)',
|
||||
filter: 'blur(60px)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<header className={getAppHeaderClassName(isHeaderElevated)} style={{ borderColor: 'hsl(var(--border))' }}>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { createRouter, createRoute, createRootRoute, redirect } from '@tanstack/
|
|||
import { Layout } from './layout'
|
||||
import { getCurrentUser } from '@/api/client'
|
||||
import { RoleGuard } from '@/shared/components/role-guard'
|
||||
import { RouteError } from '@/shared/components/route-error'
|
||||
import { createRequireAuth } from '@/shared/lib/auth-route'
|
||||
import { clearDynamicImportReloadGuard, recoverFromDynamicImportError } from '@/shared/lib/dynamic-import-recovery'
|
||||
import { normalizeSearchQuery } from '@/shared/lib/search-query'
|
||||
|
|
@ -157,6 +158,7 @@ function DefaultNotFound() {
|
|||
const rootRoute = createRootRoute({
|
||||
component: Layout,
|
||||
notFoundComponent: DefaultNotFound,
|
||||
errorComponent: RouteError,
|
||||
})
|
||||
|
||||
const requireAuth = createRequireAuth(getCurrentUser)
|
||||
|
|
@ -484,6 +486,7 @@ const routeTree = rootRoute.addChildren([
|
|||
export const router = createRouter({
|
||||
routeTree,
|
||||
defaultNotFoundComponent: DefaultNotFound,
|
||||
defaultErrorComponent: RouteError,
|
||||
})
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
|
|
|
|||
|
|
@ -32,4 +32,24 @@ describe('MarkdownRenderer links', () => {
|
|||
|
||||
expect(screen.getByRole('link', { name: 'Usage' }).getAttribute('href')).toBe('docs/usage.md')
|
||||
})
|
||||
|
||||
it('marks the document container as notranslate', () => {
|
||||
const { container } = render(<MarkdownRenderer content="Hello" />)
|
||||
const root = container.firstElementChild as HTMLElement
|
||||
|
||||
expect(root.getAttribute('translate')).toBe('no')
|
||||
expect(root.classList.contains('notranslate')).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps the same content root when re-rendered with a stable onLinkClick', () => {
|
||||
const onLinkClick = vi.fn()
|
||||
const { container, rerender } = render(
|
||||
<MarkdownRenderer content="[Usage](docs/usage.md)" onLinkClick={onLinkClick} />,
|
||||
)
|
||||
const firstRoot = container.firstElementChild
|
||||
|
||||
rerender(<MarkdownRenderer content="[Usage](docs/usage.md)" onLinkClick={onLinkClick} />)
|
||||
|
||||
expect(container.firstElementChild).toBe(firstRoot)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useMemo, type MouseEvent } from 'react'
|
||||
import { memo, useMemo, type MouseEvent } from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import rehypeHighlight from 'rehype-highlight'
|
||||
import rehypeSanitize from 'rehype-sanitize'
|
||||
|
|
@ -21,7 +21,7 @@ interface MarkdownRendererProps {
|
|||
* dedicated UI sections and should not appear twice in the document body.
|
||||
* Memoized to prevent re-parsing on every render.
|
||||
*/
|
||||
export function MarkdownRenderer({ content, className, onLinkClick }: MarkdownRendererProps) {
|
||||
function MarkdownRendererComponent({ content, className, onLinkClick }: MarkdownRendererProps) {
|
||||
const containerClassName = [
|
||||
className,
|
||||
'max-w-none break-words text-sm text-foreground/90 [overflow-wrap:anywhere]',
|
||||
|
|
@ -36,7 +36,7 @@ export function MarkdownRenderer({ content, className, onLinkClick }: MarkdownRe
|
|||
)
|
||||
|
||||
return (
|
||||
<div className={containerClassName}>
|
||||
<div className={cn(containerClassName, 'notranslate')} translate="no">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm, remarkInferCodeLanguage]}
|
||||
rehypePlugins={[rehypeSanitize, [rehypeHighlight, { detect: true, ignoreMissing: true }]]}
|
||||
|
|
@ -207,3 +207,6 @@ export function MarkdownRenderer({ content, className, onLinkClick }: MarkdownRe
|
|||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Memoized so collapse/expand parent state does not re-parse large skill docs. */
|
||||
export const MarkdownRenderer = memo(MarkdownRendererComponent)
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import {
|
|||
OVERVIEW_COLLAPSE_MOBILE_VIEWPORT_RATIO,
|
||||
getOverviewCollapseMaxHeight,
|
||||
shouldCollapseOverview,
|
||||
shouldReleaseOverviewLayoutQuiet,
|
||||
} from './overview-collapse'
|
||||
|
||||
describe('overview collapse helpers', () => {
|
||||
|
|
@ -21,4 +22,9 @@ describe('overview collapse helpers', () => {
|
|||
expect(shouldCollapseOverview(maxHeight, 1280, 900)).toBe(false)
|
||||
expect(shouldCollapseOverview(maxHeight + 1, 1280, 900)).toBe(true)
|
||||
})
|
||||
|
||||
it('releases overview layout quiet only for the latest toggle generation', () => {
|
||||
expect(shouldReleaseOverviewLayoutQuiet(2, 2)).toBe(true)
|
||||
expect(shouldReleaseOverviewLayoutQuiet(3, 2)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -13,3 +13,8 @@ export function getOverviewCollapseMaxHeight(viewportWidth: number, viewportHeig
|
|||
export function shouldCollapseOverview(contentHeight: number, viewportWidth: number, viewportHeight: number) {
|
||||
return contentHeight > getOverviewCollapseMaxHeight(viewportWidth, viewportHeight)
|
||||
}
|
||||
|
||||
/** True when a quiet-window rAF may clear the ResizeObserver mute flag. */
|
||||
export function shouldReleaseOverviewLayoutQuiet(activeGeneration: number, scheduledGeneration: number) {
|
||||
return activeGeneration === scheduledGeneration
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { SkillSummary } from '@/api/types'
|
||||
import { useAuth } from '@/features/auth/use-auth'
|
||||
import { useStar } from '@/features/social/use-star'
|
||||
import { useStarredIdSet } from '@/features/social/use-star'
|
||||
import { Card } from '@/shared/ui/card'
|
||||
import { NamespaceBadge } from '@/shared/components/namespace-badge'
|
||||
import { getHeadlineVersion } from '@/shared/lib/skill-lifecycle'
|
||||
|
|
@ -18,8 +18,9 @@ interface SkillCardProps {
|
|||
*/
|
||||
export function SkillCard({ skill, onClick, highlightStarred = true }: SkillCardProps) {
|
||||
const { isAuthenticated } = useAuth()
|
||||
const { data: starStatus } = useStar(skill.id, highlightStarred && isAuthenticated)
|
||||
const showStarredHighlight = highlightStarred && isAuthenticated && starStatus?.starred
|
||||
// Batch highlight via shared ['skills','stars'] — never N× useStar per grid row.
|
||||
const { starredIds } = useStarredIdSet(highlightStarred && isAuthenticated)
|
||||
const showStarredHighlight = highlightStarred && isAuthenticated && starredIds.has(skill.id)
|
||||
const headlineVersion = getHeadlineVersion(skill)
|
||||
const isInteractive = typeof onClick === 'function'
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,11 @@ describe('use-star module exports', () => {
|
|||
expect(typeof mod.useStar).toBe('function')
|
||||
})
|
||||
|
||||
it('exports useStarredIdSet as a function', () => {
|
||||
expect(mod.useStarredIdSet).toBeDefined()
|
||||
expect(typeof mod.useStarredIdSet).toBe('function')
|
||||
})
|
||||
|
||||
it('exports useToggleStar as a function', () => {
|
||||
expect(mod.useToggleStar).toBeDefined()
|
||||
expect(typeof mod.useToggleStar).toBe('function')
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { useMemo } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { ApiError, fetchJson, getCsrfHeaders, WEB_API_PREFIX } from '@/api/client'
|
||||
import { useMyStars } from '@/shared/hooks/use-user-queries'
|
||||
|
||||
interface StarStatus {
|
||||
starred: boolean
|
||||
|
|
@ -15,7 +17,7 @@ async function getStarStatus(skillId: number): Promise<StarStatus> {
|
|||
const starred = await fetchJson<boolean>(`${WEB_API_PREFIX}/skills/${skillId}/star`)
|
||||
return { starred }
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 401) {
|
||||
if (error instanceof ApiError && (error.status === 401 || error.status === 403 || error.status === 404)) {
|
||||
return { starred: false }
|
||||
}
|
||||
throw error
|
||||
|
|
@ -41,9 +43,27 @@ export function useStar(skillId: number, enabled = true) {
|
|||
queryKey: ['skills', skillId, 'star'],
|
||||
queryFn: () => getStarStatus(skillId),
|
||||
enabled: !!skillId && enabled,
|
||||
// List cards / shells must not toast+flushSync on transient star failures.
|
||||
meta: { skipGlobalErrorHandler: true },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared starred-id set for list highlight (search/home/landing).
|
||||
*
|
||||
* One `['skills', 'stars']` query is reused across every SkillCard mount instead of
|
||||
* N× per-skill `/star` requests that re-render the grid as each response arrives.
|
||||
*/
|
||||
export function useStarredIdSet(enabled = true) {
|
||||
const query = useMyStars(enabled)
|
||||
const starredIds = useMemo(
|
||||
() => new Set((query.data ?? []).map((skill) => skill.id)),
|
||||
[query.data],
|
||||
)
|
||||
|
||||
return { ...query, starredIds }
|
||||
}
|
||||
|
||||
export function useToggleStar(skillId: number) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
|
|
|
|||
|
|
@ -1523,6 +1523,11 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"routeError": {
|
||||
"title": "Something went wrong",
|
||||
"description": "The page hit an unexpected error. Reset to continue. If this keeps happening in a private window without extensions, please report it.",
|
||||
"reset": "Reset"
|
||||
},
|
||||
"notification": {
|
||||
"title": "Notifications",
|
||||
"empty": "No notifications",
|
||||
|
|
|
|||
|
|
@ -1524,6 +1524,11 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"routeError": {
|
||||
"title": "出现错误",
|
||||
"description": "页面遇到意外错误。请重置后继续。若在无扩展的隐私窗口中仍反复出现,请反馈问题。",
|
||||
"reset": "重置"
|
||||
},
|
||||
"notification": {
|
||||
"title": "通知",
|
||||
"empty": "暂无通知",
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import { usePublishSkill } from '@/shared/hooks/use-skill-queries'
|
|||
import { useMyNamespaces } from '@/shared/hooks/use-namespace-queries'
|
||||
import { ConfirmDialog } from '@/shared/components/confirm-dialog'
|
||||
import { DashboardPageHeader } from '@/shared/components/dashboard-page-header'
|
||||
import { navigateAfterOverlays } from '@/shared/lib/navigate-after-overlays'
|
||||
import { toast } from '@/shared/lib/toast'
|
||||
import { ApiError } from '@/api/client'
|
||||
|
||||
|
|
@ -92,7 +93,9 @@ export function PublishPage() {
|
|||
t('publish.pendingReviewDescription', { skill: skillLabel })
|
||||
)
|
||||
}
|
||||
navigate({ to: '/dashboard/skills' })
|
||||
navigateAfterOverlays(() => {
|
||||
navigate({ to: '/dashboard/skills' })
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 408) {
|
||||
toast.error(t('publish.timeoutTitle'), t('publish.timeoutDescription'))
|
||||
|
|
@ -197,7 +200,6 @@ export function PublishPage() {
|
|||
<div className="space-y-3">
|
||||
<Label className="text-sm font-semibold font-heading">{t('publish.file')}</Label>
|
||||
<UploadZone
|
||||
key={selectedFile ? `${selectedFile.name}-${selectedFile.lastModified}` : 'empty'}
|
||||
onFileSelect={handleFileSelect}
|
||||
disabled={publishMutation.isPending}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useRef, useState, type MouseEvent } from 'react'
|
||||
import { startTransition, useCallback, useEffect, useRef, useState, type MouseEvent } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Link, useParams, useNavigate, useRouterState, useSearch } from '@tanstack/react-router'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
|
|
@ -16,6 +16,7 @@ import {
|
|||
getOverviewCollapseMaxHeight,
|
||||
OVERVIEW_COLLAPSE_DESKTOP_MAX_HEIGHT,
|
||||
shouldCollapseOverview,
|
||||
shouldReleaseOverviewLayoutQuiet,
|
||||
} from '@/features/skill/overview-collapse'
|
||||
import { resolveSkillActionErrorTitle } from '@/features/skill/skill-action-error'
|
||||
import { isPrecheckConfirmationMessage, extractPrecheckWarnings } from '@/features/publish/publish-error-utils'
|
||||
|
|
@ -34,6 +35,7 @@ import { getSkillLabelSearch, getSkillSquareSearch, normalizeSkillDetailReturnTo
|
|||
import { formatCompactCount } from '@/shared/lib/number-format'
|
||||
import { resolveDocumentationFilePath } from '@/shared/lib/skill-documentation'
|
||||
import { getHeadlineVersion, getOwnerPreviewVersion, getPublishedVersion } from '@/shared/lib/skill-lifecycle'
|
||||
import { navigateAfterOverlays } from '@/shared/lib/navigate-after-overlays'
|
||||
import { NamespaceBadge } from '@/shared/components/namespace-badge'
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/shared/ui/tabs'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
|
|
@ -149,6 +151,8 @@ export function SkillDetailPage() {
|
|||
const [fileBrowserOpen, setFileBrowserOpen] = useState(true)
|
||||
const overviewContentRef = useRef<HTMLDivElement | null>(null)
|
||||
const overviewSectionRef = useRef<HTMLDivElement | null>(null)
|
||||
const overviewLayoutQuietRef = useRef(false)
|
||||
const overviewQuietGenerationRef = useRef(0)
|
||||
const { namespace, slug } = useParams({ from: '/space/$namespace/$slug' })
|
||||
const { user, hasRole } = useAuth()
|
||||
const detailQueriesEnabled = isSkillDetailQueriesEnabled(skillDeleted)
|
||||
|
|
@ -205,7 +209,7 @@ export function SkillDetailPage() {
|
|||
}
|
||||
|
||||
const updateOverviewState = () => {
|
||||
if (!overviewContentRef.current) {
|
||||
if (!overviewContentRef.current || overviewLayoutQuietRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -216,11 +220,13 @@ export function SkillDetailPage() {
|
|||
window.innerHeight,
|
||||
)
|
||||
|
||||
setOverviewMaxHeight(nextMaxHeight)
|
||||
setIsOverviewCollapsible(nextCollapsible)
|
||||
// Bail out when ResizeObserver/layout noise repeats the same values to avoid
|
||||
// re-render storms that race with body portals (Select, DropdownMenu, Dialog).
|
||||
setOverviewMaxHeight((current) => (current === nextMaxHeight ? current : nextMaxHeight))
|
||||
setIsOverviewCollapsible((current) => (current === nextCollapsible ? current : nextCollapsible))
|
||||
|
||||
if (!nextCollapsible) {
|
||||
setIsOverviewExpanded(false)
|
||||
setIsOverviewExpanded((current) => (current ? false : current))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -238,19 +244,32 @@ export function SkillDetailPage() {
|
|||
return () => {
|
||||
window.removeEventListener('resize', updateOverviewState)
|
||||
resizeObserver?.disconnect()
|
||||
overviewQuietGenerationRef.current += 1
|
||||
overviewLayoutQuietRef.current = false
|
||||
}
|
||||
}, [readme])
|
||||
|
||||
const handleToggleOverview = () => {
|
||||
if (!isOverviewExpanded) {
|
||||
setIsOverviewExpanded(true)
|
||||
return
|
||||
}
|
||||
|
||||
setIsOverviewExpanded(false)
|
||||
requestAnimationFrame(() => {
|
||||
overviewSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
// Quiet ResizeObserver for the expand/collapse commit (no max-height transition).
|
||||
const quietGeneration = overviewQuietGenerationRef.current + 1
|
||||
overviewQuietGenerationRef.current = quietGeneration
|
||||
overviewLayoutQuietRef.current = true
|
||||
const expanding = !isOverviewExpanded
|
||||
startTransition(() => {
|
||||
setIsOverviewExpanded(expanding)
|
||||
})
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
if (shouldReleaseOverviewLayoutQuiet(overviewQuietGenerationRef.current, quietGeneration)) {
|
||||
overviewLayoutQuietRef.current = false
|
||||
}
|
||||
})
|
||||
})
|
||||
if (!expanding) {
|
||||
requestAnimationFrame(() => {
|
||||
overviewSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const refreshSkill = () => {
|
||||
|
|
@ -300,7 +319,7 @@ export function SkillDetailPage() {
|
|||
setPreviewDialogOpen(true)
|
||||
}
|
||||
|
||||
const handlePackageMarkdownLinkClick = (
|
||||
const handlePackageMarkdownLinkClick = useCallback((
|
||||
href: string,
|
||||
event: MouseEvent<HTMLAnchorElement>,
|
||||
currentFilePath: string | null | undefined,
|
||||
|
|
@ -320,15 +339,15 @@ export function SkillDetailPage() {
|
|||
}
|
||||
|
||||
toast.error(t('skillDetail.packageLinkMissingTitle'), t('skillDetail.packageLinkMissingDescription'))
|
||||
}
|
||||
}, [files, t])
|
||||
|
||||
const handleOverviewLinkClick = (href: string, event: MouseEvent<HTMLAnchorElement>) => {
|
||||
const handleOverviewLinkClick = useCallback((href: string, event: MouseEvent<HTMLAnchorElement>) => {
|
||||
handlePackageMarkdownLinkClick(href, event, documentationPath)
|
||||
}
|
||||
}, [documentationPath, handlePackageMarkdownLinkClick])
|
||||
|
||||
const handlePreviewLinkClick = (href: string, event: MouseEvent<HTMLAnchorElement>) => {
|
||||
const handlePreviewLinkClick = useCallback((href: string, event: MouseEvent<HTMLAnchorElement>) => {
|
||||
handlePackageMarkdownLinkClick(href, event, previewNode?.path)
|
||||
}
|
||||
}, [handlePackageMarkdownLinkClick, previewNode?.path])
|
||||
|
||||
// Download a single file from the skill version
|
||||
const handleDownloadFile = () => {
|
||||
|
|
@ -547,7 +566,9 @@ export function SkillDetailPage() {
|
|||
t('skillDetail.deleteSkillSuccessDescription', { skill: skill.displayName }),
|
||||
)
|
||||
setDeleteSkillInputOpen(false)
|
||||
navigate({ to: resolveDeletedSkillReturnTo(search.returnTo) })
|
||||
navigateAfterOverlays(() => {
|
||||
navigate({ to: resolveDeletedSkillReturnTo(search.returnTo) })
|
||||
})
|
||||
queryClient.removeQueries({ queryKey: ['skills', namespace, slug] })
|
||||
queryClient.invalidateQueries({ queryKey: ['skills', 'my'] })
|
||||
} catch (error) {
|
||||
|
|
@ -584,7 +605,9 @@ export function SkillDetailPage() {
|
|||
t('skillDetail.withdrawReviewSuccessDescription', { version: withdrawVersionTarget }),
|
||||
)
|
||||
setWithdrawVersionTarget(null)
|
||||
navigate({ to: '/dashboard/skills' })
|
||||
navigateAfterOverlays(() => {
|
||||
navigate({ to: '/dashboard/skills' })
|
||||
})
|
||||
} catch (error) {
|
||||
toast.error(t('skillDetail.withdrawReviewErrorTitle'), error instanceof Error ? error.message : '')
|
||||
throw error
|
||||
|
|
@ -886,7 +909,7 @@ export function SkillDetailPage() {
|
|||
<div ref={overviewSectionRef} className="space-y-4">
|
||||
<div
|
||||
className={cn(
|
||||
'relative overflow-hidden transition-[max-height] duration-300 ease-out',
|
||||
'relative overflow-hidden',
|
||||
!isOverviewExpanded && isOverviewCollapsible && 'rounded-2xl',
|
||||
)}
|
||||
style={!isOverviewExpanded && isOverviewCollapsible ? { maxHeight: `${overviewMaxHeight}px` } : undefined}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { ReactNode } from 'react'
|
||||
import { ReactNode, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -36,11 +36,17 @@ export function ConfirmDialog({
|
|||
confirmButtonTestId,
|
||||
}: ConfirmDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const openRef = useRef(open)
|
||||
openRef.current = open
|
||||
const resolvedConfirmText = confirmText ?? t('dialog.confirm')
|
||||
const resolvedCancelText = cancelText ?? t('dialog.cancel')
|
||||
|
||||
const handleConfirm = async () => {
|
||||
await onConfirm()
|
||||
onOpenChange(false)
|
||||
// Skip close if the caller already closed (e.g. publish success + navigate).
|
||||
if (openRef.current) {
|
||||
onOpenChange(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -2,13 +2,8 @@ import { describe, expect, it } from 'vitest'
|
|||
import * as mod from './language-switcher'
|
||||
|
||||
/**
|
||||
* LanguageSwitcher is a React component that renders a dropdown to switch
|
||||
* between Chinese and English using i18next.
|
||||
* All logic depends on i18next hooks and Radix DropdownMenu primitives.
|
||||
* There are no exported pure helpers or constants to test here.
|
||||
*
|
||||
* We verify the module shape so downstream consumers break fast
|
||||
* if the export contract changes.
|
||||
* LanguageSwitcher renders an in-tree locale menu (no Radix portal) driven by i18next.
|
||||
* There are no exported pure helpers; verify the export contract only.
|
||||
*/
|
||||
describe('language-switcher module exports', () => {
|
||||
it('exports the LanguageSwitcher component', () => {
|
||||
|
|
|
|||
|
|
@ -1,20 +1,25 @@
|
|||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { cn } from '@/shared/lib/utils'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/shared/ui/dropdown-menu'
|
||||
import { ChevronDown, Globe } from 'lucide-react'
|
||||
|
||||
interface LanguageSwitcherProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Locale picker for the app shell.
|
||||
*
|
||||
* Intentionally avoids Radix DropdownMenu portals: the switcher lives in the
|
||||
* global header and re-renders with every search/navigation update. Body portals
|
||||
* from that path race React 19 reconciliation (removeChild / insertBefore).
|
||||
* Pattern matches UserMenu (absolute in-tree menu).
|
||||
*/
|
||||
export function LanguageSwitcher({ className }: LanguageSwitcherProps) {
|
||||
const { i18n } = useTranslation()
|
||||
const rootRef = useRef<HTMLDivElement | null>(null)
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const languages = [
|
||||
{ code: 'zh', name: '中文' },
|
||||
|
|
@ -25,37 +30,66 @@ export function LanguageSwitcher({ className }: LanguageSwitcherProps) {
|
|||
const currentLangCode = i18n.language?.split('-')[0] || 'zh'
|
||||
const currentLanguage = languages.find((lang) => lang.code === currentLangCode) || languages[0]
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return
|
||||
}
|
||||
|
||||
const handlePointerDown = (event: MouseEvent) => {
|
||||
if (!rootRef.current?.contains(event.target as Node)) {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handlePointerDown)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handlePointerDown)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const changeLanguage = (langCode: string) => {
|
||||
i18n.changeLanguage(langCode)
|
||||
void i18n.changeLanguage(langCode)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn('cursor-pointer gap-2 text-muted-foreground hover:text-foreground', className)}
|
||||
>
|
||||
<Globe className="h-4 w-4" />
|
||||
<span className="text-sm text-inherit">{currentLanguage.name}</span>
|
||||
<ChevronDown className="h-3.5 w-3.5 opacity-70" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="flex min-w-[9rem] flex-col gap-1.5 p-2">
|
||||
{languages.map((lang) => (
|
||||
<DropdownMenuItem
|
||||
key={lang.code}
|
||||
onClick={() => changeLanguage(lang.code)}
|
||||
className={cn(
|
||||
'cursor-pointer rounded-md px-3 py-2',
|
||||
currentLangCode === lang.code ? 'bg-accent' : ''
|
||||
)}
|
||||
<div ref={rootRef} className="relative">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-expanded={open}
|
||||
aria-haspopup="menu"
|
||||
className={cn('cursor-pointer gap-2 text-muted-foreground hover:text-foreground', className)}
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
>
|
||||
<Globe className="h-4 w-4" />
|
||||
<span className="text-sm text-inherit">{currentLanguage.name}</span>
|
||||
<ChevronDown className="h-3.5 w-3.5 opacity-70" />
|
||||
</Button>
|
||||
{open ? (
|
||||
<div className="absolute right-0 top-full z-50 pt-2">
|
||||
<div
|
||||
role="menu"
|
||||
className="flex min-w-[9rem] flex-col gap-1.5 rounded-md border bg-popover p-2 text-popover-foreground shadow-md"
|
||||
>
|
||||
{lang.name}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{languages.map((lang) => (
|
||||
<button
|
||||
key={lang.code}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => changeLanguage(lang.code)}
|
||||
className={cn(
|
||||
'cursor-pointer rounded-md px-3 py-2 text-left text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground',
|
||||
currentLangCode === lang.code ? 'bg-accent' : ''
|
||||
)}
|
||||
>
|
||||
{lang.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
8
web/src/shared/components/route-error.test.ts
Normal file
8
web/src/shared/components/route-error.test.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import * as mod from './route-error'
|
||||
|
||||
describe('route-error module', () => {
|
||||
it('exports the RouteError component', () => {
|
||||
expect(mod.RouteError).toBeTypeOf('function')
|
||||
})
|
||||
})
|
||||
29
web/src/shared/components/route-error.tsx
Normal file
29
web/src/shared/components/route-error.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
|
||||
interface RouteErrorProps {
|
||||
error: Error
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Recoverable route error UI (replaces TanStack default «Something went wrong»).
|
||||
*/
|
||||
export function RouteError({ error, reset }: RouteErrorProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
useEffect(() => {
|
||||
console.error('[RouteError]', error)
|
||||
}, [error])
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[40vh] flex-col items-center justify-center gap-4 px-6 text-center">
|
||||
<h1 className="text-xl font-semibold text-foreground">{t('routeError.title')}</h1>
|
||||
<p className="max-w-md text-sm text-muted-foreground">{t('routeError.description')}</p>
|
||||
<Button type="button" onClick={() => reset()}>
|
||||
{t('routeError.reset')}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,29 +1,39 @@
|
|||
import { createPortal } from 'react-dom'
|
||||
import { Toaster as Sonner } from 'sonner'
|
||||
import { getPortalContainer } from '@/shared/lib/portal-container'
|
||||
import { CENTER_TOASTER_ID } from '@/shared/lib/toast'
|
||||
|
||||
export function Toaster() {
|
||||
return (
|
||||
<Sonner
|
||||
id={CENTER_TOASTER_ID}
|
||||
position="top-center"
|
||||
className="!left-1/2 !right-auto !top-4 !-translate-x-1/2"
|
||||
offset={16}
|
||||
mobileOffset={16}
|
||||
toastOptions={{
|
||||
toasterId: CENTER_TOASTER_ID,
|
||||
classNames: {
|
||||
toast: 'glass-strong mx-auto w-fit max-w-[min(100vw-2rem,32rem)] border border-border/40',
|
||||
title: 'text-foreground font-semibold text-center',
|
||||
description: 'text-muted-foreground text-center',
|
||||
content: 'w-full text-center',
|
||||
actionButton: 'bg-primary text-primary-foreground',
|
||||
cancelButton: 'bg-muted text-muted-foreground',
|
||||
error: 'border-destructive/40',
|
||||
success: 'border-emerald-500/40',
|
||||
warning: 'border-amber-500/40',
|
||||
info: 'border-blue-500/40',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
const node = (
|
||||
<div translate="no">
|
||||
<Sonner
|
||||
id={CENTER_TOASTER_ID}
|
||||
position="top-center"
|
||||
className="!left-1/2 !right-auto !top-4 !-translate-x-1/2 !z-[100]"
|
||||
offset={16}
|
||||
mobileOffset={16}
|
||||
toastOptions={{
|
||||
toasterId: CENTER_TOASTER_ID,
|
||||
classNames: {
|
||||
toast: 'glass-strong mx-auto w-fit max-w-[min(100vw-2rem,32rem)] border border-border/40',
|
||||
title: 'text-foreground font-semibold text-center',
|
||||
description: 'text-muted-foreground text-center',
|
||||
content: 'w-full text-center',
|
||||
actionButton: 'bg-primary text-primary-foreground',
|
||||
cancelButton: 'bg-muted text-muted-foreground',
|
||||
error: 'border-destructive/40',
|
||||
success: 'border-emerald-500/40',
|
||||
warning: 'border-amber-500/40',
|
||||
info: 'border-blue-500/40',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
const host = typeof document !== 'undefined' ? getPortalContainer() : undefined
|
||||
if (host) {
|
||||
return createPortal(node, host)
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tanstack/react-query'
|
||||
import type { SkillSummary, SkillDetail, SkillVersion, SkillVersionDetail, SkillVersionCompare, SkillFile, SearchParams, PagedResponse, PublishResult } from '@/api/types'
|
||||
import { fetchJson, fetchText, getCsrfHeaders, skillLifecycleApi, WEB_API_PREFIX } from '@/api/client'
|
||||
import { clearDeletedSkillQueries } from '@/features/skill/skill-delete-flow'
|
||||
|
|
@ -64,6 +64,9 @@ export function useSearchSkills(params: SearchParams) {
|
|||
queryKey: ['skills', 'search', params],
|
||||
queryFn: () => searchSkills(params),
|
||||
enabled: params.starredOnly !== true,
|
||||
// Keep prior results while typing/debouncing so the grid is not swapped for
|
||||
// skeletons (unmount churn that races header portals under React 19).
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
37
web/src/shared/lib/dismiss-open-overlays.test.ts
Normal file
37
web/src/shared/lib/dismiss-open-overlays.test.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { dismissOpenOverlays } from './dismiss-open-overlays'
|
||||
|
||||
describe('dismissOpenOverlays', () => {
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('does nothing when no open overlay is present', () => {
|
||||
const dispatch = vi.spyOn(document.body, 'dispatchEvent')
|
||||
dismissOpenOverlays()
|
||||
expect(dispatch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('pointer-dismisses dialog overlay when an open dialog is present', () => {
|
||||
const overlay = document.createElement('div')
|
||||
overlay.setAttribute('data-radix-dialog-overlay', '')
|
||||
document.body.appendChild(overlay)
|
||||
|
||||
const dialog = document.createElement('div')
|
||||
dialog.setAttribute('role', 'dialog')
|
||||
dialog.setAttribute('data-state', 'open')
|
||||
document.body.appendChild(dialog)
|
||||
|
||||
const overlayDispatch = vi.spyOn(overlay, 'dispatchEvent')
|
||||
const bodyDispatch = vi.spyOn(document.body, 'dispatchEvent')
|
||||
|
||||
dismissOpenOverlays()
|
||||
|
||||
expect(overlayDispatch.mock.calls.some(([event]) => (event as Event).type === 'pointerdown')).toBe(true)
|
||||
expect(bodyDispatch.mock.calls.some(([event]) => (event as Event).type === 'pointerdown')).toBe(true)
|
||||
})
|
||||
})
|
||||
55
web/src/shared/lib/dismiss-open-overlays.ts
Normal file
55
web/src/shared/lib/dismiss-open-overlays.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
const OPEN_OVERLAY_SELECTOR = [
|
||||
'[data-radix-select-content][data-state="open"]',
|
||||
'[data-radix-dropdown-menu-content][data-state="open"]',
|
||||
'[data-radix-dialog-content][data-state="open"]',
|
||||
'[role="listbox"][data-state="open"]',
|
||||
'[role="menu"][data-state="open"]',
|
||||
'[role="dialog"][data-state="open"]',
|
||||
].join(',')
|
||||
|
||||
function dispatchPointer(target: EventTarget, type: 'pointerdown' | 'pointerup') {
|
||||
target.dispatchEvent(
|
||||
new PointerEvent(type, {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
pointerType: 'mouse',
|
||||
button: 0,
|
||||
buttons: type === 'pointerdown' ? 1 : 0,
|
||||
clientX: 0,
|
||||
clientY: 0,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss open Radix overlays before a route unmounts their triggers.
|
||||
*
|
||||
* Prefer pointer/outside-dismiss (Radix listens for these) over synthetic Escape,
|
||||
* which is often ignored because `isTrusted === false`.
|
||||
*
|
||||
* Call only on pathname changes (not search debounce) to avoid closing UI while
|
||||
* typing on /search.
|
||||
*/
|
||||
export function dismissOpenOverlays(): void {
|
||||
if (typeof document === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
if (!document.querySelector(OPEN_OVERLAY_SELECTOR)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (document.activeElement instanceof HTMLElement) {
|
||||
document.activeElement.blur()
|
||||
}
|
||||
|
||||
// Dialog: overlay pointerdown is the trusted dismiss path in Radix.
|
||||
document.querySelectorAll('[data-radix-dialog-overlay]').forEach((overlay) => {
|
||||
dispatchPointer(overlay, 'pointerdown')
|
||||
dispatchPointer(overlay, 'pointerup')
|
||||
})
|
||||
|
||||
// Select / DropdownMenu: outside pointerdown on body closes open content.
|
||||
dispatchPointer(document.body, 'pointerdown')
|
||||
dispatchPointer(document.body, 'pointerup')
|
||||
}
|
||||
28
web/src/shared/lib/document-language.test.ts
Normal file
28
web/src/shared/lib/document-language.test.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
/** @vitest-environment jsdom */
|
||||
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { syncDocumentLanguage } from './document-language'
|
||||
|
||||
describe('syncDocumentLanguage', () => {
|
||||
afterEach(() => {
|
||||
document.documentElement.lang = ''
|
||||
document.documentElement.removeAttribute('translate')
|
||||
document.documentElement.classList.remove('notranslate')
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
it('maps zh* to zh-CN and marks html/root as notranslate', () => {
|
||||
document.body.innerHTML = '<div id="root"></div>'
|
||||
syncDocumentLanguage('zh')
|
||||
expect(document.documentElement.lang).toBe('zh-CN')
|
||||
expect(document.documentElement.getAttribute('translate')).toBe('no')
|
||||
expect(document.documentElement.classList.contains('notranslate')).toBe(true)
|
||||
expect(document.getElementById('root')?.getAttribute('translate')).toBe('no')
|
||||
})
|
||||
|
||||
it('uses the primary subtag for other locales', () => {
|
||||
document.body.innerHTML = '<div id="root"></div>'
|
||||
syncDocumentLanguage('ru-RU')
|
||||
expect(document.documentElement.lang).toBe('ru')
|
||||
})
|
||||
})
|
||||
26
web/src/shared/lib/document-language.ts
Normal file
26
web/src/shared/lib/document-language.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
/**
|
||||
* Keep <html lang> aligned with the in-app i18n locale and discourage Chrome /
|
||||
* Google automatic translation from rewriting #root (React insertBefore crashes).
|
||||
*/
|
||||
export function syncDocumentLanguage(language: string) {
|
||||
if (typeof document === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
const primary = language.split('-')[0]?.toLowerCase() || 'en'
|
||||
const lang = primary === 'zh' ? 'zh-CN' : primary
|
||||
const html = document.documentElement
|
||||
|
||||
if (html.lang !== lang) {
|
||||
html.lang = lang
|
||||
}
|
||||
|
||||
html.setAttribute('translate', 'no')
|
||||
html.classList.add('notranslate')
|
||||
|
||||
const root = document.getElementById('root')
|
||||
if (root) {
|
||||
root.setAttribute('translate', 'no')
|
||||
root.classList.add('notranslate')
|
||||
}
|
||||
}
|
||||
28
web/src/shared/lib/navigate-after-overlays.test.ts
Normal file
28
web/src/shared/lib/navigate-after-overlays.test.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { navigateAfterOverlays } from './navigate-after-overlays'
|
||||
|
||||
describe('navigateAfterOverlays', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('navigates after two animation frames', () => {
|
||||
const navigate = vi.fn()
|
||||
const frames: FrameRequestCallback[] = []
|
||||
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
|
||||
frames.push(cb)
|
||||
return frames.length
|
||||
})
|
||||
|
||||
navigateAfterOverlays(navigate)
|
||||
|
||||
expect(navigate).not.toHaveBeenCalled()
|
||||
expect(frames).toHaveLength(1)
|
||||
frames[0]?.(0)
|
||||
expect(frames).toHaveLength(2)
|
||||
expect(navigate).not.toHaveBeenCalled()
|
||||
frames[1]?.(0)
|
||||
expect(navigate).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
21
web/src/shared/lib/navigate-after-overlays.ts
Normal file
21
web/src/shared/lib/navigate-after-overlays.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { dismissOpenOverlays } from './dismiss-open-overlays'
|
||||
|
||||
/**
|
||||
* Close overlays, then navigate on the next paint frames so Dialog/Select
|
||||
* teardown does not race React 19 route unmount (removeChild / insertBefore).
|
||||
*/
|
||||
export function navigateAfterOverlays(navigate: () => void): void {
|
||||
dismissOpenOverlays()
|
||||
|
||||
const raf = globalThis.requestAnimationFrame
|
||||
if (typeof raf !== 'function') {
|
||||
navigate()
|
||||
return
|
||||
}
|
||||
|
||||
raf(() => {
|
||||
raf(() => {
|
||||
navigate()
|
||||
})
|
||||
})
|
||||
}
|
||||
21
web/src/shared/lib/portal-container.test.ts
Normal file
21
web/src/shared/lib/portal-container.test.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
/** @vitest-environment jsdom */
|
||||
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { getPortalContainer, PORTAL_ROOT_ID } from './portal-container'
|
||||
|
||||
describe('getPortalContainer', () => {
|
||||
afterEach(() => {
|
||||
document.getElementById(PORTAL_ROOT_ID)?.remove()
|
||||
})
|
||||
|
||||
it('returns undefined when the host is missing', () => {
|
||||
expect(getPortalContainer()).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns the dedicated portal host when present', () => {
|
||||
const host = document.createElement('div')
|
||||
host.id = PORTAL_ROOT_ID
|
||||
document.body.appendChild(host)
|
||||
expect(getPortalContainer()).toBe(host)
|
||||
})
|
||||
})
|
||||
16
web/src/shared/lib/portal-container.ts
Normal file
16
web/src/shared/lib/portal-container.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
const PORTAL_ROOT_ID = 'skillhub-portals'
|
||||
|
||||
/**
|
||||
* Shared host for Dialog/Sonner portals so they do not fight React's `#root`
|
||||
* reconciliation on `document.body` (insertBefore / removeChild races).
|
||||
* Select/DropdownMenu render in-tree (no Portal) by design.
|
||||
*/
|
||||
export function getPortalContainer(): HTMLElement | undefined {
|
||||
if (typeof document === 'undefined') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return document.getElementById(PORTAL_ROOT_ID) ?? undefined
|
||||
}
|
||||
|
||||
export { PORTAL_ROOT_ID }
|
||||
|
|
@ -22,21 +22,42 @@ function withDefaultToaster(options?: ExternalToast): ExternalToast {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sonner applies toast state with ReactDOM.flushSync. Calling that mid-commit
|
||||
* (e.g. QueryCache onError while /search remounts cards) races React 19 DOM
|
||||
* reconciliation → removeChild / insertBefore. Defer past the current turn.
|
||||
*/
|
||||
function scheduleToast(run: () => void) {
|
||||
if (typeof globalThis.window === 'undefined') {
|
||||
run()
|
||||
return
|
||||
}
|
||||
globalThis.setTimeout(run, 0)
|
||||
}
|
||||
|
||||
export const toast = {
|
||||
success: (message: string, description?: string, options?: ExternalToast) => {
|
||||
sonnerToast.success(message, { description, ...withDefaultToaster(options) })
|
||||
scheduleToast(() => {
|
||||
sonnerToast.success(message, { description, ...withDefaultToaster(options) })
|
||||
})
|
||||
},
|
||||
error: (message: string, description?: string, options?: ExternalToast) => {
|
||||
sonnerToast.error(truncateErrorMessage(message) ?? message, {
|
||||
description: truncateErrorMessage(description),
|
||||
...withDefaultToaster(options),
|
||||
scheduleToast(() => {
|
||||
sonnerToast.error(truncateErrorMessage(message) ?? message, {
|
||||
description: truncateErrorMessage(description),
|
||||
...withDefaultToaster(options),
|
||||
})
|
||||
})
|
||||
},
|
||||
warning: (message: string, description?: string, options?: ExternalToast) => {
|
||||
sonnerToast.warning(message, { description, ...withDefaultToaster(options) })
|
||||
scheduleToast(() => {
|
||||
sonnerToast.warning(message, { description, ...withDefaultToaster(options) })
|
||||
})
|
||||
},
|
||||
info: (message: string, description?: string, options?: ExternalToast) => {
|
||||
sonnerToast.info(message, { description, ...withDefaultToaster(options) })
|
||||
scheduleToast(() => {
|
||||
sonnerToast.info(message, { description, ...withDefaultToaster(options) })
|
||||
})
|
||||
},
|
||||
promise: <T,>(
|
||||
promise: Promise<T>,
|
||||
|
|
|
|||
|
|
@ -1,34 +0,0 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from './dialog'
|
||||
|
||||
describe('Dialog components', () => {
|
||||
it('exports all dialog sub-components', () => {
|
||||
expect(Dialog).toBeDefined()
|
||||
expect(DialogTrigger).toBeDefined()
|
||||
expect(DialogContent).toBeDefined()
|
||||
expect(DialogHeader).toBeDefined()
|
||||
expect(DialogFooter).toBeDefined()
|
||||
expect(DialogTitle).toBeDefined()
|
||||
expect(DialogDescription).toBeDefined()
|
||||
})
|
||||
|
||||
it('sets displayName on forwardRef components', () => {
|
||||
expect(DialogTrigger.displayName).toBe('DialogTrigger')
|
||||
expect(DialogContent.displayName).toBe('DialogContent')
|
||||
expect(DialogTitle.displayName).toBe('DialogTitle')
|
||||
expect(DialogDescription.displayName).toBe('DialogDescription')
|
||||
})
|
||||
|
||||
it('sets displayName on function components', () => {
|
||||
expect(DialogHeader.displayName).toBe('DialogHeader')
|
||||
expect(DialogFooter.displayName).toBe('DialogFooter')
|
||||
})
|
||||
})
|
||||
81
web/src/shared/ui/dialog.test.tsx
Normal file
81
web/src/shared/ui/dialog.test.tsx
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
/** @vitest-environment jsdom */
|
||||
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from './dialog'
|
||||
|
||||
describe('Dialog components', () => {
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('exports all dialog sub-components', () => {
|
||||
expect(Dialog).toBeDefined()
|
||||
expect(DialogTrigger).toBeDefined()
|
||||
expect(DialogContent).toBeDefined()
|
||||
expect(DialogHeader).toBeDefined()
|
||||
expect(DialogFooter).toBeDefined()
|
||||
expect(DialogTitle).toBeDefined()
|
||||
expect(DialogDescription).toBeDefined()
|
||||
})
|
||||
|
||||
it('sets displayName on forwardRef components', () => {
|
||||
expect(DialogTrigger.displayName).toBe('DialogTrigger')
|
||||
expect(DialogContent.displayName).toBe('DialogContent')
|
||||
expect(DialogTitle.displayName).toBe('DialogTitle')
|
||||
expect(DialogDescription.displayName).toBe('DialogDescription')
|
||||
})
|
||||
|
||||
it('sets displayName on function components', () => {
|
||||
expect(DialogHeader.displayName).toBe('DialogHeader')
|
||||
expect(DialogFooter.displayName).toBe('DialogFooter')
|
||||
})
|
||||
|
||||
it('opens through the trigger by notifying onOpenChange', () => {
|
||||
const onOpenChange = vi.fn()
|
||||
|
||||
render(
|
||||
<Dialog open={false} onOpenChange={onOpenChange}>
|
||||
<DialogTrigger>Open dialog</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Token created</DialogTitle>
|
||||
<DialogDescription>Copy the token now</DialogDescription>
|
||||
</DialogHeader>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open dialog' }))
|
||||
expect(onOpenChange).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('renders dialog content when open and closes via the close button', () => {
|
||||
const onOpenChange = vi.fn()
|
||||
|
||||
render(
|
||||
<Dialog open onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Confirm delete</DialogTitle>
|
||||
<DialogDescription>This cannot be undone</DialogDescription>
|
||||
</DialogHeader>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
|
||||
expect(screen.getByRole('dialog')).toBeTruthy()
|
||||
expect(screen.getByText('Confirm delete')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,153 +1,68 @@
|
|||
import * as React from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
||||
import { getPortalContainer } from '@/shared/lib/portal-container'
|
||||
import { cn } from '@/shared/lib/utils'
|
||||
|
||||
interface DialogContextValue {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
const Dialog = DialogPrimitive.Root
|
||||
|
||||
const DialogContext = React.createContext<DialogContextValue | undefined>(undefined)
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
DialogTrigger.displayName = DialogPrimitive.Trigger.displayName ?? 'DialogTrigger'
|
||||
|
||||
function useDialog() {
|
||||
const context = React.useContext(DialogContext)
|
||||
if (!context) {
|
||||
throw new Error('Dialog components must be used within Dialog')
|
||||
}
|
||||
return context
|
||||
}
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
|
||||
interface DialogProps {
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
children: React.ReactNode
|
||||
}
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
DialogClose.displayName = DialogPrimitive.Close.displayName ?? 'DialogClose'
|
||||
|
||||
const Dialog = ({ open: controlledOpen, onOpenChange, children }: DialogProps) => {
|
||||
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(false)
|
||||
const open = controlledOpen ?? uncontrolledOpen
|
||||
const handleOpenChange = onOpenChange ?? setUncontrolledOpen
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open || typeof document === 'undefined') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const { body } = document
|
||||
const previousOverflow = body.style.overflow
|
||||
body.style.overflow = 'hidden'
|
||||
|
||||
return () => {
|
||||
body.style.overflow = previousOverflow
|
||||
}
|
||||
}, [open])
|
||||
|
||||
return (
|
||||
<DialogContext.Provider value={{ open, onOpenChange: handleOpenChange }}>
|
||||
{children}
|
||||
</DialogContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
interface DialogTriggerProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const DialogTrigger = React.forwardRef<HTMLButtonElement, DialogTriggerProps>(
|
||||
({ onClick, asChild, children, ...props }, ref) => {
|
||||
const { onOpenChange } = useDialog()
|
||||
|
||||
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
onOpenChange(true)
|
||||
onClick?.(e)
|
||||
}
|
||||
|
||||
if (asChild && React.isValidElement(children)) {
|
||||
return React.cloneElement(children as React.ReactElement<{ onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void }>, {
|
||||
onClick: handleClick,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<button ref={ref} onClick={handleClick} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
)
|
||||
DialogTrigger.displayName = 'DialogTrigger'
|
||||
|
||||
const DialogPortal = ({ children }: { children: React.ReactNode }) => {
|
||||
const { open } = useDialog()
|
||||
if (!open) return null
|
||||
if (typeof document === 'undefined') {
|
||||
return null
|
||||
}
|
||||
return createPortal(children, document.body)
|
||||
}
|
||||
|
||||
const DialogOverlay = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => {
|
||||
const { onOpenChange } = useDialog()
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/60 backdrop-blur-sm',
|
||||
className
|
||||
)}
|
||||
onClick={() => onOpenChange(false)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
translate="no"
|
||||
className={cn('fixed inset-0 z-50 bg-black/60 backdrop-blur-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = 'DialogOverlay'
|
||||
|
||||
const DialogContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, children, ...props }, ref) => {
|
||||
const { onOpenChange } = useDialog()
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<div
|
||||
ref={ref}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
className={cn(
|
||||
'fixed left-1/2 top-1/2 z-50 grid max-h-[calc(100vh-2rem)] w-[min(calc(100vw-2rem),32rem)] -translate-x-1/2 -translate-y-1/2 gap-4 overflow-y-auto rounded-2xl border border-border/60 bg-card p-8 shadow-card',
|
||||
className
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
{...props}
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal container={getPortalContainer()}>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
translate="no"
|
||||
className={cn(
|
||||
'fixed left-1/2 top-1/2 z-50 grid max-h-[calc(100vh-2rem)] w-[min(calc(100vw-2rem),32rem)] -translate-x-1/2 -translate-y-1/2 gap-4 overflow-y-auto rounded-2xl border border-border/60 bg-card p-8 shadow-card',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
|
||||
<span className="sr-only">Close</span>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="h-4 w-4"
|
||||
>
|
||||
{children}
|
||||
<button
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none"
|
||||
>
|
||||
<span className="sr-only">Close</span>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="h-4 w-4"
|
||||
>
|
||||
<path d="M18 6 6 18" />
|
||||
<path d="m6 6 12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
)
|
||||
<path d="M18 6 6 18" />
|
||||
<path d="m6 6 12 12" />
|
||||
</svg>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = 'DialogContent'
|
||||
|
||||
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
|
|
@ -160,18 +75,28 @@ const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivEleme
|
|||
)
|
||||
DialogFooter.displayName = 'DialogFooter'
|
||||
|
||||
const DialogTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<h2 ref={ref} className={cn('text-center text-xl font-bold font-heading leading-none tracking-tight', className)} {...props} />
|
||||
)
|
||||
)
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn('text-center text-xl font-bold font-heading leading-none tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = 'DialogTitle'
|
||||
|
||||
const DialogDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<p ref={ref} className={cn('text-center text-sm text-muted-foreground', className)} {...props} />
|
||||
)
|
||||
)
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn('text-center text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = 'DialogDescription'
|
||||
|
||||
export {
|
||||
|
|
@ -182,4 +107,7 @@ export {
|
|||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogClose,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,18 +10,20 @@ const DropdownMenuContent = React.forwardRef<
|
|||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
// No Portal: same React 19 removeChild mitigation as Select (in-tree Content).
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
translate="no"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
// No exit animations: delayed unmount races React 19 commits.
|
||||
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md',
|
||||
'data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
|
||||
'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import {
|
|||
SELECT_ITEM_CLASS_NAME,
|
||||
SELECT_SCROLL_BUTTON_CLASS_NAME,
|
||||
SELECT_TRIGGER_CLASS_NAME,
|
||||
SelectContent,
|
||||
normalizeSelectValue,
|
||||
} from './select'
|
||||
|
||||
|
|
@ -24,6 +25,13 @@ describe('shared select contract', () => {
|
|||
expect(SELECT_CONTENT_CLASS_NAME).toContain('text-popover-foreground')
|
||||
expect(SELECT_ITEM_CLASS_NAME).toContain('focus:bg-accent')
|
||||
expect(SELECT_ITEM_CLASS_NAME).toContain('data-[disabled]:opacity-50')
|
||||
expect(SELECT_CONTENT_CLASS_NAME).toContain('data-[state=open]:animate-in')
|
||||
expect(SELECT_CONTENT_CLASS_NAME).not.toContain('data-[state=closed]:animate-out')
|
||||
})
|
||||
|
||||
it('exports SelectContent as an in-tree content component', () => {
|
||||
expect(SelectContent).toBeDefined()
|
||||
expect(SelectContent.displayName).toBeDefined()
|
||||
})
|
||||
|
||||
it('keeps the dropdown and selected items visually discoverable', () => {
|
||||
|
|
|
|||
|
|
@ -13,9 +13,9 @@ export const SELECT_TRIGGER_CLASS_NAME = cn(
|
|||
|
||||
export const SELECT_CONTENT_CLASS_NAME = cn(
|
||||
'z-50 overflow-hidden rounded-lg border border-border bg-popover text-popover-foreground shadow-md',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||
// In-tree (no Portal): avoids React 19 removeChild races on route unmount.
|
||||
// No exit animations: delayed unmount still races commits when Content was portaled.
|
||||
'data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
|
||||
'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2',
|
||||
'data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2'
|
||||
)
|
||||
|
|
@ -43,6 +43,7 @@ const SelectTrigger = React.forwardRef<
|
|||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
translate="no"
|
||||
className={cn(SELECT_TRIGGER_CLASS_NAME, className)}
|
||||
{...props}
|
||||
>
|
||||
|
|
@ -89,31 +90,32 @@ const SelectContent = React.forwardRef<
|
|||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = 'popper', ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
// No Portal: Content stays in the React tree with its trigger so route/Dialog
|
||||
// unmount cannot orphan a body/#skillhub-portals node (removeChild).
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
translate="no"
|
||||
className={cn(
|
||||
SELECT_CONTENT_CLASS_NAME,
|
||||
position === 'popper'
|
||||
&& 'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
SELECT_CONTENT_CLASS_NAME,
|
||||
'p-1',
|
||||
position === 'popper'
|
||||
&& 'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||
className
|
||||
&& 'h-[var(--radix-select-trigger-height)] min-w-[var(--radix-select-trigger-width)]'
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
'p-1',
|
||||
position === 'popper'
|
||||
&& 'h-[var(--radix-select-trigger-height)] min-w-[var(--radix-select-trigger-width)]'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
))
|
||||
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue