Compare commits

..

5 Commits

Author SHA1 Message Date
1101bb8cb8 fix: preserve app tag label casing 2026-05-25 10:33:53 +08:00
yyh
ed17b6161f refactor(dify-ui): refine switch contract (#36539) 2026-05-25 02:22:43 +00:00
yyh
baf0cf8e4e chore(web): remove select-auto in body (#36554) 2026-05-25 02:22:39 +00:00
yyh
1e9c94b788 fix(web): clean up header logo accessibility (#36567) 2026-05-25 02:22:34 +00:00
yyh
ffd336cfe8 feat: add and unify pagination components across UI and app surfaces (#36569)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-25 02:22:31 +00:00
67 changed files with 1817 additions and 2106 deletions

View File

@ -12,19 +12,19 @@ dependencies = [
"dify-agent",
"flask>=3.1.3,<4.0.0",
"flask-cors>=6.0.2,<7.0.0",
"gevent>=26.5.0,<26.6.0",
"gevent>=26.4.0,<26.5.0",
"gevent-websocket==0.10.1",
"gmpy2>=2.3.0,<3.0.0",
"google-api-python-client>=2.196.0,<3.0.0",
"gunicorn>=26.0.0,<27.0.0",
"psycogreen>=1.0.2,<2.0.0",
"psycopg2-binary>=2.9.12,<3.0.0",
"python-socketio>=5.16.2,<6.0.0",
"python-socketio>=5.13.0,<6.0.0",
"redis[hiredis]>=7.4.0,<8.0.0",
"sendgrid>=6.12.5,<7.0.0",
"sseclient-py>=1.8.0,<2.0.0",
# Stable: production-proven, cap below the next major
"aliyun-log-python-sdk==0.9.46",
"aliyun-log-python-sdk==0.9.44",
"azure-identity>=1.25.3,<2.0.0",
"flask-compress>=1.24,<2.0.0",
"flask-login==0.6.3",
@ -41,12 +41,12 @@ dependencies = [
"opentelemetry-instrumentation-sqlalchemy==0.62b1",
"opentelemetry-propagator-b3>=1.41.1,<2.0.0",
"readabilipy==0.3.0",
"resend>=2.30.1,<3.0.0",
"resend>=2.27.0,<3.0.0",
# Emerging: newer and fast-moving, use compatible pins
"fastopenapi[flask]==0.7.0",
"graphon==0.4.0",
"httpx-sse==0.4.3",
"json-repair==0.59.10",
"json-repair==0.59.4",
]
# Before adding new dependency, consider place it in
# alphabet order (a-z) and suitable group.

119
api/uv.lock generated
View File

@ -242,7 +242,7 @@ wheels = [
[[package]]
name = "aliyun-log-python-sdk"
version = "0.9.46"
version = "0.9.44"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "dateparser" },
@ -254,7 +254,7 @@ dependencies = [
{ name = "requests" },
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/cc/20/0453695e70d2738bb393022739bfcef88d63236cc01887b8ce18eb2d137b/aliyun_log_python_sdk-0.9.46.tar.gz", hash = "sha256:b7377360f0af27a517a588ce650caf9b45a0254fe69ae957b11c24735e8699ba", size = 167434, upload-time = "2026-04-29T11:00:39.946Z" }
sdist = { url = "https://files.pythonhosted.org/packages/2d/5c/f4076b129fe9168f5424f9d89afc587baf36a844f4ae7b619a951a97c76c/aliyun_log_python_sdk-0.9.44.tar.gz", hash = "sha256:891d0ba91cdce8e5e6b430a50512e092751621680bc9f0b7c7325aaa7c1944f1", size = 154147, upload-time = "2026-03-30T08:40:59.04Z" }
[[package]]
name = "aliyun-python-sdk-core"
@ -1082,26 +1082,26 @@ wheels = [
[[package]]
name = "coverage"
version = "7.14.0"
version = "7.13.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/23/7f/d0720730a397a999ffc0fd3f5bebef347338e3a47b727da66fbb228e2ff2/coverage-7.14.0.tar.gz", hash = "sha256:057a6af2f160a85384cde4ab36f0d2777bae1057bae255f95413cdd382aa5c74", size = 919489, upload-time = "2026-05-10T18:02:31.397Z" }
sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/09/1e/2f996b2c8415cbb6f54b0f5ec1ee850c96d7911961afb4fc05f4a89d8c58/coverage-7.14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7ffd19fc8aed057fd686a17a4935eef5f9859d69208f96310e893e64b9b6ccf5", size = 219967, upload-time = "2026-05-10T18:00:13.756Z" },
{ url = "https://files.pythonhosted.org/packages/34/23/35c7aea1274aef7525bdd2dc92f710bdde6d11652239d71d1ec450067939/coverage-7.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:829994cfe1aeb773ca27bf246d4badc1e764893e3bfb98fff820fcecd1ca4662", size = 220329, upload-time = "2026-05-10T18:00:15.264Z" },
{ url = "https://files.pythonhosted.org/packages/75/cf/a8f4b43a16e194b0261257ad28ded5853ec052570afef4a84e1d81189f3b/coverage-7.14.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b4f07cf7edcb7ec39431a5074d7ea83b29a9f71fcfc494f0f40af4e65180420f", size = 251839, upload-time = "2026-05-10T18:00:17.16Z" },
{ url = "https://files.pythonhosted.org/packages/69/ff/6699e7b71e60d3049eb2bdcbc95ee3f35707b2b0e48f32e9e63d3ce30c08/coverage-7.14.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ca3d9cf2c32b521bd9518385608787fa86f38daf993695307531822c3430ed67", size = 254576, upload-time = "2026-05-10T18:00:18.829Z" },
{ url = "https://files.pythonhosted.org/packages/22/ec/c936d495fcd67f48f03a9c4ad3297ff80d1f222a5df3980f15b34c186c21/coverage-7.14.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92af52828e7f29d827346b0294e5a0853fa206db77db0395b282918d41e28db9", size = 255690, upload-time = "2026-05-10T18:00:20.648Z" },
{ url = "https://files.pythonhosted.org/packages/5c/42/5af63f636cc62a4a2b1b3ba9146f6ee6f53a35a50d5cefc54d5670f60999/coverage-7.14.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7b2bb6c9d7e769360d0f20a0f219603fd64f0c8f97de17ab25853261602be0fb", size = 257949, upload-time = "2026-05-10T18:00:22.28Z" },
{ url = "https://files.pythonhosted.org/packages/26/d3/a225317bd2012132a27e1176d51660b826f99bb975876463c44ea0d7ee5a/coverage-7.14.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1c9ed6ef99f88fb8c14aa8e2bf8eb0fe55fa2edfea68f8675d78741df1a5ac0e", size = 252242, upload-time = "2026-05-10T18:00:24.076Z" },
{ url = "https://files.pythonhosted.org/packages/f1/7f/9e65495298c3ea414742998539c37d048b5e81cc818fb1828cc6b51d10bf/coverage-7.14.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8231ade007f37959fbf58acc677f26b922c02eda6f0428ea307da0fd39681bf3", size = 253608, upload-time = "2026-05-10T18:00:25.588Z" },
{ url = "https://files.pythonhosted.org/packages/94/46/1522b524a35bdad22b2b8c4f9d32d0a104b524726ec380b2db68db1746f5/coverage-7.14.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d8b013632cc1ce1d09dbe4f32667b4d320ec2f54fc326ebeffcd0b0bcc2bb6c4", size = 251753, upload-time = "2026-05-10T18:00:27.104Z" },
{ url = "https://files.pythonhosted.org/packages/f3/e9/cdf00d38817742c541ade405e115a3f7bf36e6f2a8b99d4f209861b85a2d/coverage-7.14.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1733198802d71ec4c524f322e2867ee05c62e9e75df86bdca545407a221827d1", size = 255823, upload-time = "2026-05-10T18:00:29.038Z" },
{ url = "https://files.pythonhosted.org/packages/38/fc/5e7877cf5f902d08a17ff1c532511476d87e1bea355bd5028cb97f902e79/coverage-7.14.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:72a305291fa8ee01332f1aaf38b348ca34097f6aa0b0ef627eef2837e57bbba5", size = 251323, upload-time = "2026-05-10T18:00:30.647Z" },
{ url = "https://files.pythonhosted.org/packages/18/9d/50f05a72dff8487464fdd4178dda5daed642a060e60afb644e3d45123559/coverage-7.14.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fcaba850dd317c65423a9d63d88f9573c53b00354d6dd95724576cc98a131595", size = 253197, upload-time = "2026-05-10T18:00:32.211Z" },
{ url = "https://files.pythonhosted.org/packages/00/3f/6f61ffe6439df266c3cf60f5c99cfaa21103d0210d706a42fc6c30683ff8/coverage-7.14.0-cp312-cp312-win32.whl", hash = "sha256:5ac83957a80d0701310e96d8bec68cdcf4f90a7674b7d13f15a344315b41ab27", size = 222515, upload-time = "2026-05-10T18:00:33.717Z" },
{ url = "https://files.pythonhosted.org/packages/85/19/93853133df2cb371083285ef6a93982a0173e7a233b0f61373ba9fd30eb2/coverage-7.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:70390b0da32cb90b501953716302906e8bcce087cb283e70d8c97729f22e92b2", size = 223324, upload-time = "2026-05-10T18:00:35.172Z" },
{ url = "https://files.pythonhosted.org/packages/74/18/9f7fe62f659f24b7a82a0be56bf94c1bd0a89e0ae7ab4c668f6e82404294/coverage-7.14.0-cp312-cp312-win_arm64.whl", hash = "sha256:91b993743d959b8be85b4abf9d5478216a69329c321efe5be0433c1a841d691d", size = 221944, upload-time = "2026-05-10T18:00:37.014Z" },
{ url = "https://files.pythonhosted.org/packages/61/e8/cb8e80d6f9f55b99588625062822bf946cf03ed06315df4bd8397f5632a1/coverage-7.14.0-py3-none-any.whl", hash = "sha256:8de5b61163aee3d05c8a2beab6f47913df7981dad1baf82c414d99158c286ab1", size = 211764, upload-time = "2026-05-10T18:02:29.538Z" },
{ url = "https://files.pythonhosted.org/packages/a0/c3/a396306ba7db865bf96fc1fb3b7fd29bcbf3d829df642e77b13555163cd6/coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01", size = 219554, upload-time = "2026-03-17T10:30:42.208Z" },
{ url = "https://files.pythonhosted.org/packages/a6/16/a68a19e5384e93f811dccc51034b1fd0b865841c390e3c931dcc4699e035/coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422", size = 219908, upload-time = "2026-03-17T10:30:43.906Z" },
{ url = "https://files.pythonhosted.org/packages/29/72/20b917c6793af3a5ceb7fb9c50033f3ec7865f2911a1416b34a7cfa0813b/coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f", size = 251419, upload-time = "2026-03-17T10:30:45.545Z" },
{ url = "https://files.pythonhosted.org/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5", size = 254159, upload-time = "2026-03-17T10:30:47.204Z" },
{ url = "https://files.pythonhosted.org/packages/9d/00/7b0edcfe64e2ed4c0340dac14a52ad0f4c9bd0b8b5e531af7d55b703db7c/coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376", size = 255270, upload-time = "2026-03-17T10:30:48.812Z" },
{ url = "https://files.pythonhosted.org/packages/93/89/7ffc4ba0f5d0a55c1e84ea7cee39c9fc06af7b170513d83fbf3bbefce280/coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256", size = 257538, upload-time = "2026-03-17T10:30:50.77Z" },
{ url = "https://files.pythonhosted.org/packages/81/bd/73ddf85f93f7e6fa83e77ccecb6162d9415c79007b4bc124008a4995e4a7/coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c", size = 251821, upload-time = "2026-03-17T10:30:52.5Z" },
{ url = "https://files.pythonhosted.org/packages/a0/81/278aff4e8dec4926a0bcb9486320752811f543a3ce5b602cc7a29978d073/coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5", size = 253191, upload-time = "2026-03-17T10:30:54.543Z" },
{ url = "https://files.pythonhosted.org/packages/70/ee/fe1621488e2e0a58d7e94c4800f0d96f79671553488d401a612bebae324b/coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09", size = 251337, upload-time = "2026-03-17T10:30:56.663Z" },
{ url = "https://files.pythonhosted.org/packages/37/a6/f79fb37aa104b562207cc23cb5711ab6793608e246cae1e93f26b2236ed9/coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9", size = 255404, upload-time = "2026-03-17T10:30:58.427Z" },
{ url = "https://files.pythonhosted.org/packages/75/f0/ed15262a58ec81ce457ceb717b7f78752a1713556b19081b76e90896e8d4/coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf", size = 250903, upload-time = "2026-03-17T10:31:00.093Z" },
{ url = "https://files.pythonhosted.org/packages/0f/e9/9129958f20e7e9d4d56d51d42ccf708d15cac355ff4ac6e736e97a9393d2/coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c", size = 252780, upload-time = "2026-03-17T10:31:01.916Z" },
{ url = "https://files.pythonhosted.org/packages/a4/d7/0ad9b15812d81272db94379fe4c6df8fd17781cc7671fdfa30c76ba5ff7b/coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf", size = 222093, upload-time = "2026-03-17T10:31:03.642Z" },
{ url = "https://files.pythonhosted.org/packages/29/3d/821a9a5799fac2556bcf0bd37a70d1d11fa9e49784b6d22e92e8b2f85f18/coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810", size = 222900, upload-time = "2026-03-17T10:31:05.651Z" },
{ url = "https://files.pythonhosted.org/packages/d4/fa/2238c2ad08e35cf4f020ea721f717e09ec3152aea75d191a7faf3ef009a8/coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de", size = 221515, upload-time = "2026-03-17T10:31:07.293Z" },
{ url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" },
]
[[package]]
@ -1609,7 +1609,7 @@ vdb-xinference = [
[package.metadata]
requires-dist = [
{ name = "aliyun-log-python-sdk", specifier = "==0.9.46" },
{ name = "aliyun-log-python-sdk", specifier = "==0.9.44" },
{ name = "azure-identity", specifier = ">=1.25.3,<2.0.0" },
{ name = "bleach", specifier = ">=6.3.0,<7.0.0" },
{ name = "boto3", specifier = ">=1.43.10,<2.0.0" },
@ -1624,7 +1624,7 @@ requires-dist = [
{ name = "flask-migrate", specifier = ">=4.1.0,<5.0.0" },
{ name = "flask-orjson", specifier = ">=2.0.0,<3.0.0" },
{ name = "flask-restx", specifier = ">=1.3.2,<2.0.0" },
{ name = "gevent", specifier = ">=26.5.0,<26.6.0" },
{ name = "gevent", specifier = ">=26.4.0,<26.5.0" },
{ name = "gevent-websocket", specifier = "==0.10.1" },
{ name = "gmpy2", specifier = ">=2.3.0,<3.0.0" },
{ name = "google-api-python-client", specifier = ">=2.196.0,<3.0.0" },
@ -1633,7 +1633,7 @@ requires-dist = [
{ name = "gunicorn", specifier = ">=26.0.0,<27.0.0" },
{ name = "httpx", extras = ["socks"], specifier = "==0.28.1" },
{ name = "httpx-sse", specifier = "==0.4.3" },
{ name = "json-repair", specifier = "==0.59.10" },
{ name = "json-repair", specifier = "==0.59.4" },
{ name = "opentelemetry-distro", specifier = "==0.62b1" },
{ name = "opentelemetry-instrumentation-celery", specifier = "==0.62b1" },
{ name = "opentelemetry-instrumentation-flask", specifier = "==0.62b1" },
@ -1643,10 +1643,10 @@ requires-dist = [
{ name = "opentelemetry-propagator-b3", specifier = ">=1.41.1,<2.0.0" },
{ name = "psycogreen", specifier = ">=1.0.2,<2.0.0" },
{ name = "psycopg2-binary", specifier = ">=2.9.12,<3.0.0" },
{ name = "python-socketio", specifier = ">=5.16.2,<6.0.0" },
{ name = "python-socketio", specifier = ">=5.13.0,<6.0.0" },
{ name = "readabilipy", specifier = "==0.3.0" },
{ name = "redis", extras = ["hiredis"], specifier = ">=7.4.0,<8.0.0" },
{ name = "resend", specifier = ">=2.30.1,<3.0.0" },
{ name = "resend", specifier = ">=2.27.0,<3.0.0" },
{ name = "sendgrid", specifier = ">=6.12.5,<7.0.0" },
{ name = "sseclient-py", specifier = ">=1.8.0,<2.0.0" },
]
@ -2399,7 +2399,7 @@ wheels = [
[[package]]
name = "fastapi"
version = "0.136.3"
version = "0.135.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
@ -2408,9 +2408,9 @@ dependencies = [
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/81/2d/ff8d91d7b564d464629a0fd50a4489c97fcb836ac230bf3a7269232a9b1f/fastapi-0.136.3.tar.gz", hash = "sha256:e487fae93ad408e6f47641ee4dfe389864fd7bec92e547ea8498fc13f43e83ab", size = 396410, upload-time = "2026-05-23T18:53:15.192Z" }
sdist = { url = "https://files.pythonhosted.org/packages/e7/7b/f8e0211e9380f7195ba3f3d40c292594fd81ba8ec4629e3854c353aaca45/fastapi-0.135.1.tar.gz", hash = "sha256:d04115b508d936d254cea545b7312ecaa58a7b3a0f84952535b4c9afae7668cd", size = 394962, upload-time = "2026-03-01T18:18:29.369Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e0/82/45359b62a067409bd929ae8a56b8ed13e5a8c8a61194b3c236920999ab83/fastapi-0.136.3-py3-none-any.whl", hash = "sha256:3d2a69bdf04b7e9f3afa292c3bc7a98816bbfafa10bc9b45f3f3700d2f761620", size = 117481, upload-time = "2026-05-23T18:53:16.924Z" },
{ url = "https://files.pythonhosted.org/packages/e4/72/42e900510195b23a56bde950d26a51f8b723846bfcaa0286e90287f0422b/fastapi-0.135.1-py3-none-any.whl", hash = "sha256:46e2fc5745924b7c840f71ddd277382af29ce1cdb7d5eab5bf697e3fb9999c9e", size = 116999, upload-time = "2026-03-01T18:18:30.831Z" },
]
[[package]]
@ -2658,7 +2658,7 @@ wheels = [
[[package]]
name = "gevent"
version = "26.5.0"
version = "26.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation == 'CPython' and sys_platform == 'win32'" },
@ -2666,17 +2666,16 @@ dependencies = [
{ name = "zope-event" },
{ name = "zope-interface" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c4/cb/98aa3a299e2fc4a2372b5d124863e02965b64579ffc29fe54d0641e65b2f/gevent-26.5.0.tar.gz", hash = "sha256:1655eb04c1e20d71b2aa4a3c7528162dd58ff6cc46a037af1f01f534c80fefba", size = 6712354, upload-time = "2026-05-20T21:22:45.132Z" }
sdist = { url = "https://files.pythonhosted.org/packages/20/27/1062fa31333dc3428a1f5f33cd6598b0552165ba679ca3ba116de42c9e8e/gevent-26.4.0.tar.gz", hash = "sha256:288d03addfccf0d1c67268358b6759b04392bf3bc35d26f3d9a45c82899c292d", size = 6242440, upload-time = "2026-04-09T12:08:19.482Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/37/0b/1a530b2db55c97cc0cf44116201f538f3033c04c1d2aca143979b412f4be/gevent-26.5.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:e80ad2a8a1e8bdaa5605e3bf4929e0cebf9ea7b8237c83362f7257698bb14280", size = 2929714, upload-time = "2026-05-20T20:13:24.656Z" },
{ url = "https://files.pythonhosted.org/packages/b9/df/32fe851ed5f68493f354e09b19bdebae0de1185be4db0b2988e71e737fd3/gevent-26.5.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:fe42c037253580a3386fce275f8a2a845e540f5a729916934a732f13d42e72cc", size = 1784838, upload-time = "2026-05-20T21:17:31.063Z" },
{ url = "https://files.pythonhosted.org/packages/e5/9a/21332674f9a10e8cdf13b41b52e9d663647a1c6e1dc3c62b07c0aeefd360/gevent-26.5.0-cp312-cp312-manylinux_2_28_ppc64le.whl", hash = "sha256:9f463c7d6f69d13b6fe8e3b832a6175a6e95328a940f38495d25496d1ae8ad88", size = 1880440, upload-time = "2026-05-20T21:16:00.881Z" },
{ url = "https://files.pythonhosted.org/packages/9f/b1/5f8a4196113cf7f3fdd987b483f7e6b10c28ea3930c4727e31ba8cce51b6/gevent-26.5.0-cp312-cp312-manylinux_2_28_s390x.whl", hash = "sha256:96d5e96b1b14a4c1023dcfcc114533217f13febc3b6169254f23fc18d19fee29", size = 1831592, upload-time = "2026-05-20T21:30:53.832Z" },
{ url = "https://files.pythonhosted.org/packages/4e/69/1559b1f6b5107a9118fccd300240879bd581b6d87b03d568d0d155ea702c/gevent-26.5.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:bccff69c462e3650a0fd1d4e9cfc8b6effe15f3e9b1cad20a7bb5ce14b057efd", size = 2114915, upload-time = "2026-05-20T20:35:25.041Z" },
{ url = "https://files.pythonhosted.org/packages/e4/32/602c499d54472f64e5cdf6013aeab5ce6aa6fed005387e8b4f2d22f5dc8d/gevent-26.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f519139354d5ca7625df9ddb1b2ffada885c14abc5b4dbae3682e967ddf79669", size = 1796906, upload-time = "2026-05-20T21:16:39.65Z" },
{ url = "https://files.pythonhosted.org/packages/f9/3c/2fe77ee6e3d381b3c50c0b7d6c4c08c08b8ff5e8c0d9dd51a3b426d61eec/gevent-26.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0bf57df54f1c66273bf3601c2a1e41b12138fe848933718369663bc54f177ca2", size = 2140806, upload-time = "2026-05-20T20:43:28.895Z" },
{ url = "https://files.pythonhosted.org/packages/22/d5/4620797bbd9c88f4541188efc138b0d615f9834db540da36a2249ee929c5/gevent-26.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:e49ce0de007dfd7412edbc2b5d41cce33b049bb1b7086f50be5a09e601bde603", size = 1699995, upload-time = "2026-05-20T20:15:39.311Z" },
{ url = "https://files.pythonhosted.org/packages/cb/83/ac3477dfc0f9fd80c88110102c73cefc35dcded2b248544f45a8fa5412df/gevent-26.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:5c5ff29495a2eed2a244de8150f21893d6c1b15d8b4b5719ab4bbfa06db1e28f", size = 1547433, upload-time = "2026-05-20T20:15:51.656Z" },
{ url = "https://files.pythonhosted.org/packages/3d/16/131d3874f50974b355c90a061a12d3fe2292cde0f875a1fa3d8b224f1251/gevent-26.4.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:318a0a73f664113e8d86d0cb0e328e7650e2d7d9c2e045418ab6fb1285831ad3", size = 2928699, upload-time = "2026-04-08T21:25:36.215Z" },
{ url = "https://files.pythonhosted.org/packages/ea/8b/199e59b303adaff7f7365def9ab569c7ecd863363c974548bce3ddc2c89d/gevent-26.4.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:ce7aa033a3f68beb6732d1450a80c1af29e63e0c2d01abad7918cf2507f72fa6", size = 1783821, upload-time = "2026-04-08T22:23:18.73Z" },
{ url = "https://files.pythonhosted.org/packages/e2/2d/b8249c9bd3f386191311c3a9bec4068e192a3f9df2fad92a71a15265ba15/gevent-26.4.0-cp312-cp312-manylinux_2_28_ppc64le.whl", hash = "sha256:a1b897c952baefd72232efaeb3bdb1ca2fa7ae94cbfe68ac21201b03e843190a", size = 1879424, upload-time = "2026-04-08T22:27:10.561Z" },
{ url = "https://files.pythonhosted.org/packages/ef/89/59216985c1f2c11f2f28bbc88e583588ad44cdde823c530ad4e307be6612/gevent-26.4.0-cp312-cp312-manylinux_2_28_s390x.whl", hash = "sha256:7eef2ea508ce41795e20587a5fc868ae4919543097c81a40fbdfd65bc479f54f", size = 1830575, upload-time = "2026-04-08T22:34:37.093Z" },
{ url = "https://files.pythonhosted.org/packages/ee/a9/2d67d2b0aa0ca9d7bb7fe73c3bbb97b3695cb15c338a6ea7734f58da9add/gevent-26.4.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:f7e12fdd28cc9f39a463d8df5172d698c64a8ed385a21d98e7092fd8308a139a", size = 2113898, upload-time = "2026-04-08T21:54:14.9Z" },
{ url = "https://files.pythonhosted.org/packages/95/a3/457d58d9b3e7da17c8456d841c37a32af8d231a1d71237ad201b19129317/gevent-26.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d48e3ee13d7678c24c22f19d441ad6bc220a79f23662d03ff36fae0d62efdb59", size = 1795890, upload-time = "2026-04-08T22:26:53.252Z" },
{ url = "https://files.pythonhosted.org/packages/a7/cc/cbe78f2626643b20275aaa41cd2cc45ba75056e3665bde36bc190af3cae0/gevent-26.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c58c8e034f94329be4dc0979fba3301005a433dbab42cea0b2c33fd736946872", size = 2139791, upload-time = "2026-04-08T22:00:02.375Z" },
{ url = "https://files.pythonhosted.org/packages/f6/df/7875e08b06a95f4577b71708ec470d029fadf873a66eb813a2861d79dfb5/gevent-26.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:1c737e6ac6ce1398df0e3f41c58d982e397c993cbe73ac05b7edbe39e128c9cb", size = 1680530, upload-time = "2026-04-08T23:15:38.714Z" },
]
[[package]]
@ -3551,16 +3550,16 @@ wheels = [
[[package]]
name = "json-repair"
version = "0.59.10"
version = "0.59.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d3/7c/e95bb03068572146eba37e8175c760f470ea0a6097310e16bbf2bc6e6457/json_repair-0.59.10.tar.gz", hash = "sha256:2e4b85537c752d8a513ea28fdad891e5ede32c83de745366b97f648b8c34ede7", size = 49133, upload-time = "2026-05-14T06:41:51.222Z" }
sdist = { url = "https://files.pythonhosted.org/packages/32/41/4ae9c6e711647a41b4e0c04bce815113ce9c0286eff6dc6fb86979b2fb9f/json_repair-0.59.4.tar.gz", hash = "sha256:559ca1828f6f566530663cd96d64bee29f8282b9d2ff0e661e05fa87b4171ab3", size = 47624, upload-time = "2026-04-15T06:48:40.776Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ee/87/49b20c6b81493d55c311f711ed87319d0fbad8bd0bbfbe36e52103af36bd/json_repair-0.59.10-py3-none-any.whl", hash = "sha256:5468fa3eaadcc9b4a5646776bc4176e2fe5f374b5848a15f468cce3b60e3db0e", size = 47742, upload-time = "2026-05-14T06:41:49.812Z" },
{ url = "https://files.pythonhosted.org/packages/74/c4/ec3068436d2275731539b7a43fbc947f502bc3fe149856a5d00368c7b087/json_repair-0.59.4-py3-none-any.whl", hash = "sha256:46052e646bc0b0c39db672ebbf732f774f3c1a5bde81a54f0b0e19d3af4f45cd", size = 46697, upload-time = "2026-04-15T06:48:39.61Z" },
]
[[package]]
name = "jsonschema"
version = "4.26.0"
version = "4.23.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "attrs" },
@ -3568,9 +3567,9 @@ dependencies = [
{ name = "referencing" },
{ name = "rpds-py" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" }
sdist = { url = "https://files.pythonhosted.org/packages/38/2e/03362ee4034a4c917f697890ccd4aec0800ccf9ded7f511971c75451deec/jsonschema-4.23.0.tar.gz", hash = "sha256:d71497fef26351a33265337fa77ffeb82423f3ea21283cd9467bb03999266bc4", size = 325778, upload-time = "2024-07-08T18:40:05.546Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" },
{ url = "https://files.pythonhosted.org/packages/69/4a/4f9dbeb84e8850557c02365a0eee0649abe5eb1d84af92a25731c6c0f922/jsonschema-4.23.0-py3-none-any.whl", hash = "sha256:fbadb6f8b144a8f8cf9f0b89ba94501d143e50411a1278633f56a7acf7fd5566", size = 88462, upload-time = "2024-07-08T18:40:00.165Z" },
]
[[package]]
@ -3711,7 +3710,7 @@ wheels = [
[[package]]
name = "litellm"
version = "1.86.0"
version = "1.83.14"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohttp" },
@ -3727,9 +3726,9 @@ dependencies = [
{ name = "tiktoken" },
{ name = "tokenizers" },
]
sdist = { url = "https://files.pythonhosted.org/packages/2f/a7/26b8b04e4fcff26b60200ffe7458a255552ae51014468188f5db45674eb2/litellm-1.86.0.tar.gz", hash = "sha256:eccab86e0820b60b3f9484b233fb8d818b97afb19d5b4fa08d0d045621350ba4", size = 15379195, upload-time = "2026-05-24T02:42:10.865Z" }
sdist = { url = "https://files.pythonhosted.org/packages/8d/7c/c095649380adc96c8630273c1768c2ad1e74aa2ee1dd8dd05d218a60569f/litellm-1.83.14.tar.gz", hash = "sha256:24aef9b47cdc424c833e32f3727f411741c690832cd1fe4405e0077144fe09c9", size = 14836599, upload-time = "2026-04-26T03:16:10.176Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ff/0b/9a044c061a69e801de042e962c34f5bc2e094810e28b49ce0b3bedee9327/litellm-1.86.0-py3-none-any.whl", hash = "sha256:9d8171ca1a17705b7c7a6fdce8cfc07bbf641284b46c1b6047f83a779159990c", size = 17011225, upload-time = "2026-05-24T02:42:00.629Z" },
{ url = "https://files.pythonhosted.org/packages/7f/5c/1b5691575420135e90578543b2bf219497caa33cfd0af64cb38f30288450/litellm-1.83.14-py3-none-any.whl", hash = "sha256:92b11ba2a32cf80707ddf388d18526696c7999a21b418c5e3b6eda1243d2cfdb", size = 16457054, upload-time = "2026-04-26T03:16:05.72Z" },
]
[[package]]
@ -5627,14 +5626,14 @@ wheels = [
[[package]]
name = "python-engineio"
version = "4.13.2"
version = "4.13.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "simple-websocket" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fa/6d/4384c2723adad93a3d6de4297e6d9c8b93be7f778a407f34f6ee0b2bea3e/python_engineio-4.13.2.tar.gz", hash = "sha256:a7732e99cfb7db6ed1aee31f18d7f73bbae086a92f31dee019bc646155d9684e", size = 79639, upload-time = "2026-05-21T21:45:07.578Z" }
sdist = { url = "https://files.pythonhosted.org/packages/34/12/bdef9dbeedbe2cdeba2a2056ad27b1fb081557d34b69a97f574843462cae/python_engineio-4.13.1.tar.gz", hash = "sha256:0a853fcef52f5b345425d8c2b921ac85023a04dfcf75d7b74696c61e940fd066", size = 92348, upload-time = "2026-02-06T23:38:06.12Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/28/180bfc5c95e83d40cb2abce512684ccad44e4819ec899fc36cb404a19061/python_engineio-4.13.2-py3-none-any.whl", hash = "sha256:8c101cd170e400dc4e970cd523325cde22df8fc25140953f379327055d701a6b", size = 59993, upload-time = "2026-05-21T21:45:06.162Z" },
{ url = "https://files.pythonhosted.org/packages/aa/54/0cce26da03a981f949bb8449c9778537f75f5917c172e1d2992ff25cb57d/python_engineio-4.13.1-py3-none-any.whl", hash = "sha256:f32ad10589859c11053ad7d9bb3c9695cdf862113bfb0d20bc4d890198287399", size = 59847, upload-time = "2026-02-06T23:38:04.861Z" },
]
[[package]]
@ -5695,15 +5694,15 @@ wheels = [
[[package]]
name = "python-socketio"
version = "5.16.2"
version = "5.16.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "bidict" },
{ name = "python-engineio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/07/dd/6fd4112b941f7d39b8171b6ba17902609bd8fa2059c3812a3c29dade13e7/python_socketio-5.16.2.tar.gz", hash = "sha256:ad88c228d921646efa436c0a0df217e364ef30ec072df4041484e54d49c15989", size = 128011, upload-time = "2026-05-21T22:03:44.418Z" }
sdist = { url = "https://files.pythonhosted.org/packages/59/81/cf8284f45e32efa18d3848ed82cdd4dcc1b657b082458fbe01ad3e1f2f8d/python_socketio-5.16.1.tar.gz", hash = "sha256:f863f98eacce81ceea2e742f6388e10ca3cdd0764be21d30d5196470edf5ea89", size = 128508, upload-time = "2026-02-06T23:42:07Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/72/dc/0decaf5da92a7a969374474025787102d811d42aed1d32191fa338620e15/python_socketio-5.16.2-py3-none-any.whl", hash = "sha256:bef2da3374fd533aed4297f57b4f6512b52aa51604cb0da2165f401291c5ca20", size = 82137, upload-time = "2026-05-21T22:03:42.616Z" },
{ url = "https://files.pythonhosted.org/packages/07/c7/deb8c5e604404dbf10a3808a858946ca3547692ff6316b698945bb72177e/python_socketio-5.16.1-py3-none-any.whl", hash = "sha256:a3eb1702e92aa2f2b5d3ba00261b61f062cce51f1cfb6900bf3ab4d1934d2d35", size = 82054, upload-time = "2026-02-06T23:42:05.772Z" },
]
[[package]]
@ -5910,15 +5909,15 @@ wheels = [
[[package]]
name = "resend"
version = "2.30.1"
version = "2.27.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "requests" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/80/c7/b4c5bee252a2690cadb41063f280f4c09aac663e050e4425df545d28403d/resend-2.30.1.tar.gz", hash = "sha256:a529a019e83be285d855ecf135475dc6a6b9fa1c24f9b710686988e021a535ec", size = 43216, upload-time = "2026-05-13T15:39:18.172Z" }
sdist = { url = "https://files.pythonhosted.org/packages/96/da/3d342cacbde7143e36782243caa3715d9e49cadb43e804419493c784869b/resend-2.27.0.tar.gz", hash = "sha256:abc183da7566c1fdba8221ec5acd9f954c2ff516a0c2615bee2a41bc9db3e277", size = 37177, upload-time = "2026-04-01T21:19:31.823Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a7/46/f2f5da31f7359dd39672a279f654e6eb86fb1a27aa9c90d0d9efbb962ebb/resend-2.30.1-py2.py3-none-any.whl", hash = "sha256:45da25b86940307e130f8a44d2bac889574362400843e9cd98b49b79a7b3db63", size = 68393, upload-time = "2026-05-13T15:39:17.114Z" },
{ url = "https://files.pythonhosted.org/packages/b4/95/783b09d24c8f40b900a2728b67fd3c1401d4a6afcdf1db1c8475c249559d/resend-2.27.0-py2.py3-none-any.whl", hash = "sha256:5bc8ddebb0418127fc3e47eb29ab72af727861481c4b051b96cb693df8f8dc40", size = 59831, upload-time = "2026-04-01T21:19:30.471Z" },
]
[[package]]
@ -7265,15 +7264,15 @@ wheels = [
[[package]]
name = "uvicorn"
version = "0.48.0"
version = "0.38.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e6/bf/f6544ba992ddb9a6077343a576f9844f7f8f06ab819aefd00206e9255f18/uvicorn-0.48.0.tar.gz", hash = "sha256:a5504207195d08c2511bf9125ede5ac4a4b71725d519e758d01dcf0bc2d31c37", size = 91074, upload-time = "2026-05-24T12:08:41.925Z" }
sdist = { url = "https://files.pythonhosted.org/packages/cb/ce/f06b84e2697fef4688ca63bdb2fdf113ca0a3be33f94488f2cadb690b0cf/uvicorn-0.38.0.tar.gz", hash = "sha256:fd97093bdd120a2609fc0d3afe931d4d4ad688b6e75f0f929fde1bc36fe0e91d", size = 80605, upload-time = "2025-10-18T13:46:44.63Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/01/be/72532be3da7acc5fdfbccdb95215cd04f995a0886532a5b423f929cda4cc/uvicorn-0.48.0-py3-none-any.whl", hash = "sha256:48097851328b87ec36117d3d575234519eb58c2b22d79666e9bbc6c49a761dad", size = 71410, upload-time = "2026-05-24T12:08:40.258Z" },
{ url = "https://files.pythonhosted.org/packages/ee/d9/d88e73ca598f4f6ff671fb5fde8a32925c2e08a637303a1d12883c7305fa/uvicorn-0.38.0-py3-none-any.whl", hash = "sha256:48c0afd214ceb59340075b4a052ea1ee91c16fbc2a9b1469cca0e54566977b02", size = 68109, upload-time = "2025-10-18T13:46:42.958Z" },
]
[package.optional-dependencies]

View File

@ -1571,19 +1571,6 @@
"count": 1
}
},
"web/app/components/base/pagination/index.tsx": {
"no-restricted-imports": {
"count": 1
},
"unicorn/prefer-number-properties": {
"count": 1
}
},
"web/app/components/base/pagination/type.ts": {
"ts/no-empty-object-type": {
"count": 1
}
},
"web/app/components/base/prompt-editor/index.stories.tsx": {
"no-console": {
"count": 1

View File

@ -40,16 +40,16 @@ Importing from `@langgenius/dify-ui` (no subpath) is intentionally not supported
## Primitives
| Category | Subpath | Notes |
| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- |
| Actions | `./button` | Design-system CTA primitive with `cva` variants. |
| Feedback | `./meter`, `./toast` | Meter is inline status; Toast owns the `z-60` layer. |
| Form | `./form`, `./field`, `./fieldset`, `./input`, `./checkbox`, `./checkbox-group`, `./radio`, `./radio-group`, `./number-field`, `./select`, `./slider`, `./switch` | Native form boundary, field semantics, and controls. |
| Layout | `./scroll-area` | Custom-styled scrollbar over the host viewport. |
| Media | `./avatar` | Avatar root, image, and fallback primitives. |
| Navigation | `./tabs`, `./toggle-group` | Tabs for panels; ToggleGroup for segmented modes. |
| Overlay / menu | `./alert-dialog`, `./context-menu`, `./dialog`, `./drawer`, `./dropdown-menu`, `./popover`, `./preview-card`, `./tooltip` | Portalled. See [Overlay & portal contract] below. |
| Search / pickers | `./autocomplete`, `./combobox`, `./select` | Search input, searchable picker, and closed picker. |
| Category | Subpath | Notes |
| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- |
| Actions | `./button` | Design-system CTA primitive with `cva` variants. |
| Feedback | `./meter`, `./toast` | Meter is inline status; Toast owns the `z-60` layer. |
| Form | `./form`, `./field`, `./fieldset`, `./input`, `./checkbox`, `./checkbox-group`, `./radio`, `./radio-group`, `./number-field`, `./select`, `./slider`, `./switch` | Native form boundary, field semantics, and controls. |
| Layout | `./scroll-area` | Custom-styled scrollbar over the host viewport. |
| Media | `./avatar` | Avatar root, image, and fallback primitives. |
| Navigation | `./pagination`, `./tabs`, `./toggle-group` | Pagination for page navigation; Tabs for panels; ToggleGroup for segmented modes. |
| Overlay / menu | `./alert-dialog`, `./context-menu`, `./dialog`, `./drawer`, `./dropdown-menu`, `./popover`, `./preview-card`, `./tooltip` | Portalled. See [Overlay & portal contract] below. |
| Search / pickers | `./autocomplete`, `./combobox`, `./select` | Search input, searchable picker, and closed picker. |
Utilities:

View File

@ -77,6 +77,10 @@
"types": "./src/number-field/index.tsx",
"import": "./src/number-field/index.tsx"
},
"./pagination": {
"types": "./src/pagination/index.tsx",
"import": "./src/pagination/index.tsx"
},
"./radio": {
"types": "./src/radio/index.tsx",
"import": "./src/radio/index.tsx"

View File

@ -0,0 +1,293 @@
import { render } from 'vitest-browser-react'
import {
Pagination,
PaginationContent,
PaginationNavigation,
PaginationNext,
PaginationPage,
PaginationPageJump,
PaginationPageList,
PaginationPageSize,
PaginationPrevious,
PaginationRoot,
PaginationSkeleton,
} from '../index'
const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement
async function renderPagination({
page = 2,
totalPages = 200,
onPageChange = vi.fn(),
pageSize = 25,
onPageSizeChange = vi.fn(),
}: {
page?: number
totalPages?: number
onPageChange?: (page: number) => void
pageSize?: number
onPageSizeChange?: (pageSize: number) => void
} = {}) {
const screen = await render(
<PaginationRoot
page={page}
totalPages={totalPages}
onPageChange={onPageChange}
data-testid="pagination"
>
<PaginationContent data-testid="content">
<PaginationNavigation data-testid="controls">
<PaginationPrevious />
<PaginationPageJump />
<PaginationNext />
</PaginationNavigation>
<PaginationPageList data-testid="pages" />
<PaginationPageSize
value={pageSize}
options={[10, 25, 50]}
onValueChange={onPageSizeChange}
/>
</PaginationContent>
</PaginationRoot>,
)
return {
screen,
onPageChange,
onPageSizeChange,
}
}
describe('Pagination primitive', () => {
it('renders the Figma-aligned pagination structure with semantic navigation', async () => {
const { screen } = await renderPagination()
await expect.element(screen.getByRole('navigation', { name: 'Pagination' })).toHaveAttribute('data-page', '2')
await expect.element(screen.getByTestId('content')).toHaveClass('grid', 'grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)]')
await expect.element(screen.getByTestId('controls')).toHaveClass('justify-self-start', 'rounded-[10px]', 'bg-background-section-burn')
await expect.element(screen.getByRole('list')).toHaveClass('col-start-2', 'justify-self-center')
expect(screen.getByRole('group', { name: 'Items per page' }).element().parentElement).toHaveClass('col-start-3', 'justify-self-end')
await expect.element(screen.getByRole('button', { name: 'Previous page' })).toBeInTheDocument()
await expect.element(screen.getByRole('button', { name: 'Next page' })).toBeInTheDocument()
await expect.element(screen.getByRole('button', { name: 'Edit page number, current page 2 of 200' })).toHaveTextContent('2/200')
await expect.element(screen.getByRole('button', { name: 'Edit page number, current page 2 of 200' })).toHaveClass('h-7', 'px-2')
expect(screen.getByRole('button', { name: 'Edit page number, current page 2 of 200' }).element()).not.toHaveClass('min-w-14')
await expect.element(screen.getByRole('button', { name: 'Page 2, current page' })).toHaveAttribute('aria-current', 'page')
await expect.element(screen.getByRole('button', { name: 'Page 2, current page' })).toHaveClass('bg-components-button-tertiary-bg')
await expect.element(screen.getByText('…')).toBeInTheDocument()
})
it('uses one-based page changes for previous, next, and page buttons', async () => {
const { screen, onPageChange } = await renderPagination({ page: 4 })
asHTMLElement(screen.getByRole('button', { name: 'Previous page' }).element()).click()
asHTMLElement(screen.getByRole('button', { name: 'Next page' }).element()).click()
asHTMLElement(screen.getByRole('button', { name: 'Go to page 6' }).element()).click()
expect(onPageChange).toHaveBeenNthCalledWith(1, 3)
expect(onPageChange).toHaveBeenNthCalledWith(2, 5)
expect(onPageChange).toHaveBeenNthCalledWith(3, 6)
})
it('disables previous at the first page', async () => {
const { screen } = await renderPagination({ page: 1, totalPages: 10 })
await expect.element(screen.getByRole('button', { name: 'Previous page' })).toBeDisabled()
})
it('disables next at the last page', async () => {
const { screen } = await renderPagination({ page: 10, totalPages: 10 })
await expect.element(screen.getByRole('button', { name: 'Next page' })).toBeDisabled()
})
it('clamps invalid root page values without exposing invalid state', async () => {
const { screen } = await renderPagination({ page: 999, totalPages: 10 })
await expect.element(screen.getByRole('navigation', { name: 'Pagination' })).toHaveAttribute('data-page', '10')
await expect.element(screen.getByRole('button', { name: 'Page 10, current page' })).toHaveAttribute('aria-current', 'page')
})
it('switches the page summary into a selected labelled number field', async () => {
const { screen } = await renderPagination()
asHTMLElement(screen.getByRole('button', { name: 'Edit page number, current page 2 of 200' }).element()).click()
await expect.element(screen.getByRole('textbox', { name: 'Page number' })).toBeInTheDocument()
const input = asHTMLElement(screen.getByRole('textbox', { name: 'Page number' }).element()) as HTMLInputElement
await expect.element(screen.getByRole('textbox', { name: 'Page number' })).toHaveValue('2')
await expect.element(screen.getByRole('textbox', { name: 'Page number' })).toHaveClass('text-center', 'tabular-nums')
expect(input.parentElement?.parentElement?.parentElement).toHaveAttribute('data-page-summary', '2/200')
await vi.waitFor(() => {
expect(input.selectionStart).toBe(0)
expect(input.selectionEnd).toBe(1)
})
})
it('returns to the summary button when the page input loses focus', async () => {
const { screen } = await renderPagination()
asHTMLElement(screen.getByRole('button', { name: 'Edit page number, current page 2 of 200' }).element()).click()
await expect.element(screen.getByRole('textbox', { name: 'Page number' })).toBeInTheDocument()
asHTMLElement(screen.getByRole('textbox', { name: 'Page number' }).element()).blur()
await expect.element(screen.getByRole('button', { name: 'Edit page number, current page 2 of 200' })).toBeInTheDocument()
})
it('commits the page input editing mode with Enter', async () => {
const { screen } = await renderPagination()
asHTMLElement(screen.getByRole('button', { name: 'Edit page number, current page 2 of 200' }).element()).click()
await expect.element(screen.getByRole('textbox', { name: 'Page number' })).toBeInTheDocument()
const input = asHTMLElement(screen.getByRole('textbox', { name: 'Page number' }).element()) as HTMLInputElement
await vi.waitFor(() => {
expect(document.activeElement).toBe(input)
})
input.dispatchEvent(new KeyboardEvent('keydown', {
key: 'Enter',
bubbles: true,
cancelable: true,
}))
await expect.element(screen.getByRole('button', { name: 'Edit page number, current page 2 of 200' })).toBeInTheDocument()
})
it('cancels the page input editing mode with Escape', async () => {
const { screen, onPageChange } = await renderPagination()
asHTMLElement(screen.getByRole('button', { name: 'Edit page number, current page 2 of 200' }).element()).click()
await expect.element(screen.getByRole('textbox', { name: 'Page number' })).toBeInTheDocument()
const input = asHTMLElement(screen.getByRole('textbox', { name: 'Page number' }).element()) as HTMLInputElement
await vi.waitFor(() => {
expect(document.activeElement).toBe(input)
})
input.dispatchEvent(new KeyboardEvent('keydown', {
key: 'Escape',
bubbles: true,
cancelable: true,
}))
const summaryButton = screen.getByRole('button', { name: 'Edit page number, current page 2 of 200' })
await expect.element(summaryButton).toBeInTheDocument()
await vi.waitFor(() => {
expect(document.activeElement).toBe(summaryButton.element())
})
expect(onPageChange).not.toHaveBeenCalled()
})
it('uses Base UI ToggleGroup semantics for page size', async () => {
const { screen, onPageSizeChange } = await renderPagination()
await expect.element(screen.getByRole('group', { name: 'Items per page' })).toHaveClass('bg-components-segmented-control-bg-normal')
await expect.element(screen.getByText('Items per page')).toHaveClass('opacity-0', 'group-hover/page-size:opacity-100', 'group-focus-within/page-size:opacity-100')
await expect.element(screen.getByRole('button', { name: '25' })).toHaveAttribute('aria-pressed', 'true')
await expect.element(screen.getByRole('button', { name: '25' })).toHaveClass('data-pressed:text-text-primary')
asHTMLElement(screen.getByRole('button', { name: '50' }).element()).click()
expect(onPageSizeChange).toHaveBeenCalledWith(50)
})
it('renders the complete pagination bar with optional page size controls', async () => {
const onPageSizeChange = vi.fn()
const screen = await render(
<Pagination
page={2}
totalPages={10}
onPageChange={vi.fn()}
pageSize={{
value: 25,
options: [10, 25, 50],
onValueChange: onPageSizeChange,
}}
/>,
)
await expect.element(screen.getByRole('button', { name: 'Edit page number, current page 2 of 10' })).toBeInTheDocument()
await expect.element(screen.getByRole('group', { name: 'Items per page' })).toBeInTheDocument()
})
it('uses a localized action label for editing the page number', async () => {
const screen = await render(
<Pagination
page={2}
totalPages={10}
onPageChange={vi.fn()}
labels={{
editPageNumber: (page, totalPages) => `Change page, current page ${page} of ${totalPages}`,
}}
/>,
)
await expect.element(screen.getByRole('button', { name: 'Change page, current page 2 of 10' })).toBeInTheDocument()
})
it('keeps facade page numbers centered when page size controls are omitted', async () => {
const screen = await render(
<Pagination
page={2}
totalPages={10}
onPageChange={vi.fn()}
/>,
)
await expect.element(screen.getByRole('navigation', { name: 'Pagination' })).toBeInTheDocument()
expect(screen.container.querySelector('nav[aria-label="Pagination"] > div')).toHaveClass('grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)]')
await expect.element(screen.getByRole('list')).toHaveClass('col-start-2', 'justify-self-center')
})
it('does not expose invalid page controls when there are no pages', async () => {
const screen = await render(
<Pagination
page={1}
totalPages={0}
onPageChange={vi.fn()}
/>,
)
expect(screen.container.querySelector('nav[aria-label="Pagination"]')).not.toBeInTheDocument()
expect(screen.container.querySelector('button[aria-label*="current page 1 of 0"]')).not.toBeInTheDocument()
})
it('omits compound page jump and page list content for empty pagination state', async () => {
const { screen } = await renderPagination({ page: 1, totalPages: 0 })
await expect.element(screen.getByRole('navigation', { name: 'Pagination' })).toHaveAttribute('data-page', '1')
expect(screen.container.querySelector('button[aria-label*="current page 1 of 0"]')).not.toBeInTheDocument()
expect(screen.container.querySelector('button[aria-label="Previous page"]')).not.toBeInTheDocument()
expect(screen.container.querySelector('button[aria-label="Next page"]')).not.toBeInTheDocument()
expect(screen.container.querySelector('ol')).not.toBeInTheDocument()
})
it('allows custom page rendering while keeping the shared context', async () => {
const onPageChange = vi.fn()
const screen = await render(
<PaginationRoot page={3} totalPages={5} onPageChange={onPageChange}>
<ol>
<li>
<PaginationPage page={4} className="custom-page">
Four
</PaginationPage>
</li>
</ol>
</PaginationRoot>,
)
asHTMLElement(screen.getByRole('button', { name: 'Go to page 4' }).element()).click()
await expect.element(screen.getByRole('button', { name: 'Go to page 4' })).toHaveClass('custom-page')
expect(onPageChange).toHaveBeenCalledWith(4)
})
it('renders a non-interactive loading skeleton', async () => {
const screen = await render(<PaginationSkeleton data-testid="skeleton" />)
await expect.element(screen.getByTestId('skeleton')).toHaveAttribute('aria-hidden', 'true')
await expect.element(screen.getByTestId('skeleton')).toHaveClass('select-none')
})
})

View File

@ -0,0 +1,93 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import type { ComponentProps } from 'react'
import { useState } from 'react'
import {
Pagination,
PaginationSkeleton,
} from '.'
function PaginationExample({
initialPage = 2,
initialPageSize = 25,
totalPages = 200,
}: {
initialPage?: number
initialPageSize?: number
totalPages?: number
}) {
const [page, setPage] = useState(initialPage)
const [pageSize, setPageSize] = useState(initialPageSize)
return (
<Pagination
page={page}
totalPages={totalPages}
onPageChange={setPage}
pageSize={{
value: pageSize,
options: [10, 25, 50],
onValueChange: setPageSize,
}}
/>
)
}
function PaginationDemo(props: ComponentProps<typeof PaginationExample>) {
return (
<div className="w-236 max-w-full bg-components-panel-bg px-16 py-10">
<PaginationExample {...props} />
</div>
)
}
function DesignSpecDemo() {
return (
<div className="flex w-236 max-w-full flex-col gap-6 bg-components-panel-bg px-16 py-10">
<PaginationExample />
<PaginationExample initialPage={2} initialPageSize={25} />
<PaginationExample initialPage={2} initialPageSize={25} />
<PaginationExample initialPage={2} initialPageSize={25} />
</div>
)
}
const meta = {
title: 'Base/UI/Pagination',
component: PaginationDemo,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Compound pagination primitive for list navigation. It combines semantic page buttons, a NumberField-backed page jump summary, and a ToggleGroup-backed page-size selector.',
},
},
},
args: {
initialPage: 2,
initialPageSize: 25,
totalPages: 200,
},
tags: ['autodocs'],
} satisfies Meta<typeof PaginationDemo>
export default meta
type Story = StoryObj<typeof meta>
export const Playground: Story = {
render: () => <PaginationDemo />,
}
export const DesignSpec: Story = {
render: () => <DesignSpecDemo />,
parameters: {
docs: {
description: {
story: 'Pagination rows with default, hover-like, focused, page-size, and skeleton examples.',
},
},
},
}
export const Loading: Story = {
render: () => <PaginationSkeleton />,
}

View File

@ -0,0 +1,655 @@
'use client'
import type { Button as BaseButtonNS } from '@base-ui/react/button'
import type { ReactNode } from 'react'
import { Button as BaseButton } from '@base-ui/react/button'
import { mergeProps } from '@base-ui/react/merge-props'
import { useRender } from '@base-ui/react/use-render'
import { createContext, useContext, useMemo, useRef, useState } from 'react'
import { cn } from '../cn'
import {
NumberField,
NumberFieldGroup,
NumberFieldInput,
} from '../number-field'
import {
ToggleGroup,
ToggleGroupItem,
} from '../toggle-group'
type PageItem = number | 'ellipsis-start' | 'ellipsis-end'
type PaginationContextValue = {
page: number
totalPages: number
hasPages: boolean
disabled: boolean
onPageChange: (page: number) => void
items: PageItem[]
}
const PaginationContext = createContext<PaginationContextValue | null>(null)
function usePaginationContext(component: string) {
const context = useContext(PaginationContext)
if (!context)
throw new Error(`${component} must be used inside PaginationRoot.`)
return context
}
function clampPage(page: number, totalPages: number) {
if (!Number.isFinite(page))
return 1
return Math.min(Math.max(Math.trunc(page), 1), Math.max(totalPages, 1))
}
function range(start: number, end: number) {
if (end < start)
return []
return Array.from({ length: end - start + 1 }, (_, index) => start + index)
}
type GetPageItemsOptions = {
page: number
totalPages: number
siblingCount: number
boundaryCount: number
visiblePageCount: number
}
function getPageItems({
page,
totalPages,
siblingCount,
boundaryCount,
visiblePageCount,
}: GetPageItemsOptions): PageItem[] {
if (totalPages <= 0)
return []
const normalizedPage = clampPage(page, totalPages)
const normalizedBoundaryCount = Math.max(Math.trunc(boundaryCount), 1)
const normalizedSiblingCount = Math.max(Math.trunc(siblingCount), 0)
const windowSize = Math.max(
Math.trunc(visiblePageCount),
normalizedSiblingCount * 2 + 1,
)
if (totalPages <= windowSize + normalizedBoundaryCount)
return range(1, totalPages)
const nearStartEnd = windowSize
const nearEndStart = totalPages - windowSize + 1
const middleStart = Math.max(
normalizedBoundaryCount + 1,
normalizedPage - normalizedSiblingCount,
)
const middleEnd = Math.min(
totalPages - normalizedBoundaryCount,
normalizedPage + normalizedSiblingCount,
)
const windowPages = normalizedPage <= nearStartEnd - normalizedSiblingCount
? range(1, nearStartEnd)
: normalizedPage >= nearEndStart + normalizedSiblingCount
? range(nearEndStart, totalPages)
: range(middleStart, middleEnd)
const pageSet = new Set([
...range(1, normalizedBoundaryCount),
...windowPages,
...range(totalPages - normalizedBoundaryCount + 1, totalPages),
])
const pages = Array.from(pageSet)
.filter(item => item >= 1 && item <= totalPages)
.sort((a, b) => a - b)
return pages.reduce<PageItem[]>((items, item, index) => {
const previous = pages[index - 1]
if (previous && item - previous === 2)
items.push(previous + 1)
else if (previous && item - previous > 2)
items.push(item < normalizedPage ? 'ellipsis-start' : 'ellipsis-end')
items.push(item)
return items
}, [])
}
type PaginationRootState = {
page: number
totalPages: number
hasPages: boolean
disabled: boolean
}
export type PaginationRootProps = Omit<
useRender.ComponentProps<'nav', PaginationRootState>,
'onChange'
> & {
page: number
totalPages: number
onPageChange: (page: number) => void
siblingCount?: number
boundaryCount?: number
visiblePageCount?: number
}
export function PaginationRoot({
page,
totalPages,
onPageChange,
siblingCount = 1,
boundaryCount = 1,
visiblePageCount = 8,
render,
children,
className,
...props
}: PaginationRootProps) {
const normalizedTotalPages = Math.max(Math.trunc(totalPages), 0)
const normalizedPage = clampPage(page, normalizedTotalPages)
const hasPages = normalizedTotalPages > 0
const disabled = normalizedTotalPages <= 1
const items = useMemo(() => getPageItems({
page: normalizedPage,
totalPages: normalizedTotalPages,
siblingCount,
boundaryCount,
visiblePageCount,
}), [
boundaryCount,
normalizedPage,
normalizedTotalPages,
siblingCount,
visiblePageCount,
])
const context = useMemo<PaginationContextValue>(() => ({
page: normalizedPage,
totalPages: normalizedTotalPages,
hasPages,
disabled,
onPageChange: nextPage => onPageChange(clampPage(nextPage, normalizedTotalPages)),
items,
}), [disabled, hasPages, items, normalizedPage, normalizedTotalPages, onPageChange])
const defaultProps: useRender.ElementProps<'nav'> = {
'aria-label': 'Pagination',
'className': cn('flex w-full min-w-0 items-center justify-between px-6 py-3 select-none', className),
'children': (
<PaginationContext.Provider value={context}>
{children}
</PaginationContext.Provider>
),
}
return useRender({
defaultTagName: 'nav',
render,
state: {
page: normalizedPage,
totalPages: normalizedTotalPages,
hasPages,
disabled,
},
props: mergeProps<'nav'>(defaultProps, props),
})
}
export type PaginationNavigationProps = useRender.ComponentProps<'div'>
export type PaginationContentProps = useRender.ComponentProps<'div'>
export function PaginationContent({
render,
className,
...props
}: PaginationContentProps) {
const defaultProps: useRender.ElementProps<'div'> = {
className: cn('grid w-full min-w-0 grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-2', className),
}
return useRender({
defaultTagName: 'div',
render,
props: mergeProps<'div'>(defaultProps, props),
})
}
export function PaginationNavigation({
render,
className,
...props
}: PaginationNavigationProps) {
const defaultProps: useRender.ElementProps<'div'> = {
className: cn('flex shrink-0 items-center justify-self-start gap-0.5 rounded-[10px] bg-background-section-burn p-0.5', className),
}
return useRender({
defaultTagName: 'div',
render,
props: mergeProps<'div'>(defaultProps, props),
})
}
type PaginationButtonProps = Omit<BaseButtonNS.Props, 'children'> & {
children?: ReactNode
}
const paginationArrowButtonClassName = [
'inline-flex size-7 shrink-0 touch-manipulation items-center justify-center rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg text-components-button-secondary-text shadow-xs outline-hidden backdrop-blur-[10px] transition-[background-color,border-color,color,box-shadow]',
'hover:border-components-button-secondary-border-hover hover:bg-components-button-secondary-bg-hover',
'focus-visible:ring-2 focus-visible:ring-components-input-border-hover',
'disabled:cursor-not-allowed disabled:border-components-button-secondary-border-disabled disabled:bg-components-button-secondary-bg-disabled disabled:text-components-button-secondary-text-disabled disabled:shadow-none',
'motion-reduce:transition-none',
]
export function PaginationPrevious({
className,
children,
'aria-label': ariaLabel,
...props
}: PaginationButtonProps) {
const pagination = usePaginationContext('PaginationPrevious')
if (!pagination.hasPages)
return null
const disabled = props.disabled || pagination.page <= 1 || pagination.disabled
return (
<BaseButton
{...props}
type="button"
aria-label={ariaLabel ?? 'Previous page'}
className={cn(paginationArrowButtonClassName, className)}
disabled={disabled}
onClick={(event) => {
props.onClick?.(event)
if (!event.defaultPrevented && !disabled)
pagination.onPageChange(pagination.page - 1)
}}
>
{children ?? <span className="i-ri-arrow-left-line size-4" aria-hidden="true" />}
</BaseButton>
)
}
export function PaginationNext({
className,
children,
'aria-label': ariaLabel,
...props
}: PaginationButtonProps) {
const pagination = usePaginationContext('PaginationNext')
if (!pagination.hasPages)
return null
const disabled = props.disabled || pagination.page >= pagination.totalPages || pagination.disabled
return (
<BaseButton
{...props}
type="button"
aria-label={ariaLabel ?? 'Next page'}
className={cn(paginationArrowButtonClassName, className)}
disabled={disabled}
onClick={(event) => {
props.onClick?.(event)
if (!event.defaultPrevented && !disabled)
pagination.onPageChange(pagination.page + 1)
}}
>
{children ?? <span className="i-ri-arrow-right-line size-4" aria-hidden="true" />}
</BaseButton>
)
}
export type PaginationPageJumpProps = Omit<BaseButtonNS.Props, 'children'> & {
inputLabel?: string
children?: ReactNode
}
export function PaginationPageJump({
className,
inputLabel = 'Page number',
children,
'aria-label': ariaLabel,
...props
}: PaginationPageJumpProps) {
const pagination = usePaginationContext('PaginationPageJump')
const [editing, setEditing] = useState(false)
const summaryButtonRef = useRef<HTMLButtonElement | null>(null)
if (!pagination.hasPages)
return null
if (editing) {
return (
<span
data-page-summary={`${pagination.page}/${pagination.totalPages}`}
className="inline-grid h-7 system-xs-medium tabular-nums after:invisible after:col-start-1 after:row-start-1 after:py-1.5 after:pr-3 after:pl-2 after:content-[attr(data-page-summary)]"
>
<NumberField
key={pagination.page}
className="col-start-1 row-start-1 w-full"
defaultValue={pagination.page}
min={1}
max={Math.max(pagination.totalPages, 1)}
onValueCommitted={(value) => {
if (value !== null)
pagination.onPageChange(value)
setEditing(false)
}}
>
<NumberFieldGroup
className="h-7 w-full min-w-0 rounded-lg border-[0.5px] border-components-input-border-active bg-components-input-bg-active shadow-xs"
>
<NumberFieldInput
aria-label={inputLabel}
autoFocus
className="px-2 py-1.5 text-center system-xs-medium tabular-nums"
onBlur={() => requestAnimationFrame(() => setEditing(false))}
onFocus={(event) => {
const input = event.currentTarget
requestAnimationFrame(() => input.select())
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault()
event.currentTarget.blur()
return
}
if (event.key === 'Escape') {
event.preventDefault()
setEditing(false)
requestAnimationFrame(() => summaryButtonRef.current?.focus())
}
}}
/>
</NumberFieldGroup>
</NumberField>
</span>
)
}
return (
<BaseButton
{...props}
ref={summaryButtonRef}
type="button"
aria-label={ariaLabel ?? `Edit page number, current page ${pagination.page} of ${pagination.totalPages}`}
className={cn(
'inline-flex h-7 touch-manipulation items-center justify-center gap-0.5 rounded-lg px-2 py-1.5 system-xs-medium tabular-nums text-text-secondary outline-hidden transition-colors hover:cursor-text hover:bg-state-base-hover-alt focus-visible:ring-2 focus-visible:ring-components-input-border-hover motion-reduce:transition-none',
className,
)}
onClick={(event) => {
props.onClick?.(event)
if (!event.defaultPrevented)
setEditing(true)
}}
>
{children ?? (
<>
<span>{pagination.page}</span>
<span className="text-text-quaternary">/</span>
<span>{pagination.totalPages}</span>
</>
)}
</BaseButton>
)
}
export type PaginationPageListProps = useRender.ComponentProps<'ol'>
export function PaginationPageList({
render,
className,
...props
}: PaginationPageListProps) {
const pagination = usePaginationContext('PaginationPageList')
if (!pagination.hasPages)
return null
const defaultProps: useRender.ElementProps<'ol'> = {
className: cn('col-start-2 flex min-w-0 list-none items-center justify-self-center gap-0.5', className),
children: pagination.items.map(item => (
<li key={item}>
{typeof item === 'number'
? <PaginationPage page={item} />
: <PaginationEllipsis />}
</li>
)),
}
return useRender({
defaultTagName: 'ol',
render,
props: mergeProps<'ol'>(defaultProps, props),
})
}
export type PaginationPageProps = Omit<BaseButtonNS.Props, 'children'> & {
page: number
children?: ReactNode
}
export function PaginationPage({
page,
className,
children,
'aria-label': ariaLabel,
...props
}: PaginationPageProps) {
const pagination = usePaginationContext('PaginationPage')
const current = page === pagination.page
return (
<BaseButton
{...props}
type="button"
aria-current={current ? 'page' : undefined}
aria-label={ariaLabel ?? (current ? `Page ${page}, current page` : `Go to page ${page}`)}
className={cn(
'inline-flex h-8 min-w-8 touch-manipulation items-center justify-center rounded-lg px-1 py-2 system-sm-medium tabular-nums text-text-tertiary outline-hidden transition-colors hover:bg-components-button-ghost-bg-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-components-input-border-hover',
current && 'bg-components-button-tertiary-bg text-components-button-tertiary-text hover:bg-components-button-ghost-bg-hover',
'motion-reduce:transition-none',
className,
)}
onClick={(event) => {
props.onClick?.(event)
if (!event.defaultPrevented)
pagination.onPageChange(page)
}}
>
{children ?? page}
</BaseButton>
)
}
export type PaginationEllipsisProps = useRender.ComponentProps<'span'>
export function PaginationEllipsis({
render,
className,
...props
}: PaginationEllipsisProps) {
const defaultProps: useRender.ElementProps<'span'> = {
'aria-hidden': true,
'className': cn('flex size-8 items-center justify-center px-1 py-2 system-sm-medium text-text-tertiary', className),
'children': '…',
}
return useRender({
defaultTagName: 'span',
render,
props: mergeProps<'span'>(defaultProps, props),
})
}
export type PaginationPageSizeProps<Value extends number = number> = {
'value': Value
'options': readonly Value[]
'onValueChange': (value: Value) => void
'label'?: ReactNode
'aria-label'?: string
'className'?: string
}
export function PaginationPageSize<Value extends number = number>({
value,
options,
onValueChange,
label = 'Items per page',
'aria-label': ariaLabel = 'Items per page',
className,
}: PaginationPageSizeProps<Value>) {
return (
<div className={cn('group/page-size col-start-3 flex shrink-0 items-center justify-end justify-self-end gap-2', className)}>
<div className="w-13 shrink-0 text-end system-2xs-regular-uppercase text-text-tertiary opacity-0 transition-opacity group-hover/page-size:opacity-100 group-focus-within/page-size:opacity-100 motion-reduce:transition-none">
{label}
</div>
<ToggleGroup
value={[String(value)]}
aria-label={ariaLabel}
onValueChange={(nextValue) => {
const [selectedValue] = nextValue
if (!selectedValue)
return
const selectedOption = options.find(option => String(option) === selectedValue)
if (selectedOption !== undefined)
onValueChange(selectedOption)
}}
>
{options.map(option => (
<ToggleGroupItem
key={option}
value={String(option)}
className="min-w-9 data-pressed:text-text-primary"
>
{option}
</ToggleGroupItem>
))}
</ToggleGroup>
</div>
)
}
export type PaginationLabels = {
previous?: string
next?: string
editPageNumber?: (page: number, totalPages: number) => string
pageNumberInput?: string
}
export type PaginationPageSizeConfig<Value extends number = number> = {
value: Value
options: readonly Value[]
onValueChange: (value: Value) => void
label?: ReactNode
ariaLabel?: string
}
export type PaginationProps<Value extends number = number> = Omit<PaginationRootProps, 'children'> & {
labels?: PaginationLabels
pageSize?: PaginationPageSizeConfig<Value>
}
export function Pagination<Value extends number = number>({
labels,
pageSize,
page,
totalPages,
onPageChange,
...props
}: PaginationProps<Value>) {
const normalizedTotalPages = Math.max(Math.trunc(totalPages), 0)
const normalizedPage = clampPage(page, normalizedTotalPages)
const editPageNumber = labels?.editPageNumber?.(normalizedPage, normalizedTotalPages)
if (normalizedTotalPages <= 0)
return null
return (
<PaginationRoot
page={page}
totalPages={totalPages}
onPageChange={onPageChange}
{...props}
>
<PaginationContent>
<PaginationNavigation>
<PaginationPrevious aria-label={labels?.previous} />
<PaginationPageJump
aria-label={editPageNumber}
inputLabel={labels?.pageNumberInput}
/>
<PaginationNext aria-label={labels?.next} />
</PaginationNavigation>
<PaginationPageList />
{pageSize && (
<PaginationPageSize
value={pageSize.value}
options={pageSize.options}
onValueChange={pageSize.onValueChange}
label={pageSize.label}
aria-label={pageSize.ariaLabel}
/>
)}
</PaginationContent>
</PaginationRoot>
)
}
export type PaginationSkeletonProps = useRender.ComponentProps<'div'>
export function PaginationSkeleton({
render,
className,
...props
}: PaginationSkeletonProps) {
const defaultProps: useRender.ElementProps<'div'> = {
'aria-hidden': true,
'className': cn('flex w-full min-w-0 items-center justify-between px-6 py-3 select-none', className),
'children': (
<div className="grid w-full min-w-0 grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-2">
<div className="flex shrink-0 items-center justify-self-start gap-0.5 rounded-[10px] bg-background-section-burn p-0.5">
<div className="size-7 animate-pulse rounded-lg bg-state-base-hover motion-reduce:animate-none" />
<div className="h-7 min-w-14 animate-pulse rounded-lg bg-state-base-hover motion-reduce:animate-none" />
<div className="size-7 animate-pulse rounded-lg bg-state-base-hover motion-reduce:animate-none" />
</div>
<div className="col-start-2 flex items-center justify-self-center gap-0.5">
{range(1, 8).map(item => (
<div key={item} className="h-8 min-w-8 animate-pulse rounded-lg bg-state-base-hover motion-reduce:animate-none" />
))}
</div>
<div className="col-start-3 flex shrink-0 items-center justify-self-end">
<div className="h-8 w-28 animate-pulse rounded-[10px] bg-state-base-hover motion-reduce:animate-none" />
</div>
</div>
),
}
return useRender({
defaultTagName: 'div',
render,
props: mergeProps<'div'>(defaultProps, props),
})
}

View File

@ -207,6 +207,16 @@ describe('Select wrappers', () => {
expect(screen.getByRole('combobox', { name: 'city select' }).element().className).toContain('data-popup-open:bg-state-base-hover-alt')
})
it('should include keyboard focus ring classes', async () => {
const screen = await renderOpenSelect()
await expect.element(screen.getByRole('combobox', { name: 'city select' })).toHaveClass(
'focus-visible:ring-1',
'focus-visible:ring-components-input-border-active',
'focus-visible:ring-inset',
)
})
})
describe('SelectContent', () => {

View File

@ -24,6 +24,7 @@ const selectTriggerVariants = cva(
[
'group flex w-full items-center border-0 bg-components-input-bg-normal text-left text-components-input-text-filled outline-hidden',
'hover:bg-state-base-hover-alt focus-visible:bg-state-base-hover-alt data-popup-open:bg-state-base-hover-alt',
'focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:ring-inset',
'data-placeholder:text-components-input-text-placeholder',
'data-readonly:cursor-default data-readonly:bg-transparent data-readonly:hover:bg-transparent',
'data-disabled:cursor-not-allowed data-disabled:bg-components-input-bg-disabled data-disabled:text-components-input-text-filled-disabled data-disabled:hover:bg-components-input-bg-disabled',

View File

@ -49,6 +49,19 @@ describe('Switch', () => {
await expect.element(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'true')
})
it('should work in uncontrolled mode with defaultChecked prop', async () => {
const onCheckedChange = vi.fn()
const screen = await render(<Switch defaultChecked={false} onCheckedChange={onCheckedChange} />)
const switchElement = screen.getByRole('switch')
await expect.element(switchElement).toHaveAttribute('aria-checked', 'false')
asHTMLElement(switchElement.element()).click()
expect(onCheckedChange).toHaveBeenCalledWith(true)
await expect.element(switchElement).toHaveAttribute('aria-checked', 'true')
})
it('should not call onCheckedChange when disabled', async () => {
const onCheckedChange = vi.fn()
const screen = await render(<Switch checked={false} disabled onCheckedChange={onCheckedChange} />)
@ -142,6 +155,24 @@ describe('Switch', () => {
expect(screen.container.querySelector('span[aria-hidden="true"] i')).toBeInTheDocument()
})
it('should use checked data attributes to position spinner', async () => {
const screen = await render(<Switch checked={false} loading size="md" />)
const spinner = screen.container.querySelector('span[aria-hidden="true"]')
expect(spinner).toHaveClass(
'left-[calc(50%+6px)]',
'group-data-checked:left-[calc(50%-6px)]',
)
await screen.rerender(<Switch checked={true} loading size="md" />)
await expect.element(screen.getByRole('switch')).toHaveAttribute('data-checked', '')
expect(screen.container.querySelector('span[aria-hidden="true"]')).toHaveClass(
'left-[calc(50%+6px)]',
'group-data-checked:left-[calc(50%-6px)]',
)
})
it('should not show spinner for xs and sm sizes', async () => {
const screen = await render(<Switch checked={false} loading size="xs" />)
expect(screen.container.querySelector('span[aria-hidden="true"] i')).not.toBeInTheDocument()

View File

@ -2,6 +2,11 @@ import type { Meta, StoryObj } from '@storybook/react-vite'
import type { ComponentProps } from 'react'
import { useState, useTransition } from 'react'
import { Switch, SwitchSkeleton } from '.'
import {
FieldDescription,
FieldLabel,
FieldRoot,
} from '../field'
const meta = {
title: 'Base/Form/Switch',
@ -10,7 +15,7 @@ const meta = {
layout: 'centered',
docs: {
description: {
component: 'Toggle switch built on Base UI with CVA variants, Figma-aligned design tokens, loading spinner, and skeleton placeholder. Import `Switch` and `SwitchSkeleton` from `@langgenius/dify-ui/switch`.',
component: 'Toggle switch primitive with controlled and uncontrolled state support, loading state, and skeleton placeholder.',
},
},
},
@ -42,20 +47,27 @@ const meta = {
export default meta
type Story = StoryObj<typeof meta>
const SwitchDemo = (args: Partial<ComponentProps<typeof Switch>>) => {
type SwitchDemoProps = Partial<Omit<ComponentProps<typeof Switch>, 'checked' | 'defaultChecked' | 'onCheckedChange'>> & {
checked?: boolean
}
const SwitchDemo = (args: SwitchDemoProps) => {
const [enabled, setEnabled] = useState(args.checked ?? false)
return (
<div className="flex items-center justify-center gap-3">
<Switch
{...args}
checked={enabled}
onCheckedChange={setEnabled}
/>
<span className="text-sm text-gray-700">
{enabled ? 'On' : 'Off'}
</span>
</div>
<FieldRoot name="autoRetry" className="w-72">
<FieldLabel className="flex items-center justify-between gap-3">
<span>Enable auto retry</span>
<Switch
{...args}
checked={enabled}
onCheckedChange={setEnabled}
/>
</FieldLabel>
<FieldDescription>
{enabled ? 'Failures will retry automatically.' : 'Failures require manual retry.'}
</FieldDescription>
</FieldRoot>
)
}
@ -116,24 +128,24 @@ const AllStatesDemo = () => {
<td className="py-3 font-medium text-gray-900">{size}</td>
<td className="py-3">
<div className="flex gap-2">
<Switch size={size} checked={false} onCheckedChange={() => {}} />
<Switch size={size} checked={true} onCheckedChange={() => {}} />
<Switch size={size} checked={false} onCheckedChange={() => {}} aria-label={`${size} unchecked switch`} />
<Switch size={size} checked={true} onCheckedChange={() => {}} aria-label={`${size} checked switch`} />
</div>
</td>
<td className="py-3">
<div className="flex gap-2">
<Switch size={size} checked={false} disabled />
<Switch size={size} checked={true} disabled />
<Switch size={size} checked={false} disabled aria-label={`${size} disabled unchecked switch`} />
<Switch size={size} checked={true} disabled aria-label={`${size} disabled checked switch`} />
</div>
</td>
<td className="py-3">
<div className="flex gap-2">
<Switch size={size} checked={false} loading />
<Switch size={size} checked={true} loading />
<Switch size={size} checked={false} loading aria-label={`${size} loading unchecked switch`} />
<Switch size={size} checked={true} loading aria-label={`${size} loading checked switch`} />
</div>
</td>
<td className="py-3">
<SwitchSkeleton size={size} />
<SwitchSkeleton size={size} aria-hidden="true" />
</td>
</tr>
))}
@ -148,7 +160,7 @@ export const AllStates: Story = {
parameters: {
docs: {
description: {
story: 'Complete variant matrix: all sizes × all states, matching Figma design spec (node 2144:1210).',
story: 'Variant matrix for switch sizes and states.',
},
},
},
@ -164,22 +176,30 @@ const SizeComparisonDemo = () => {
return (
<div className="flex flex-col items-center space-y-4">
<div className="flex items-center gap-3">
<Switch size="xs" checked={states.xs} onCheckedChange={v => setStates({ ...states, xs: v })} />
<span className="text-sm text-gray-700">Extra Small (xs) 14×10</span>
</div>
<div className="flex items-center gap-3">
<Switch size="sm" checked={states.sm} onCheckedChange={v => setStates({ ...states, sm: v })} />
<span className="text-sm text-gray-700">Small (sm) 20×12</span>
</div>
<div className="flex items-center gap-3">
<Switch size="md" checked={states.md} onCheckedChange={v => setStates({ ...states, md: v })} />
<span className="text-sm text-gray-700">Regular (md) 28×16</span>
</div>
<div className="flex items-center gap-3">
<Switch size="lg" checked={states.lg} onCheckedChange={v => setStates({ ...states, lg: v })} />
<span className="text-sm text-gray-700">Large (lg) 36×20</span>
</div>
<FieldRoot name="extraSmallSwitch">
<FieldLabel className="flex items-center gap-3">
<Switch size="xs" checked={states.xs} onCheckedChange={v => setStates({ ...states, xs: v })} />
Extra Small (xs) - 14x10
</FieldLabel>
</FieldRoot>
<FieldRoot name="smallSwitch">
<FieldLabel className="flex items-center gap-3">
<Switch size="sm" checked={states.sm} onCheckedChange={v => setStates({ ...states, sm: v })} />
Small (sm) - 20x12
</FieldLabel>
</FieldRoot>
<FieldRoot name="regularSwitch">
<FieldLabel className="flex items-center gap-3">
<Switch size="md" checked={states.md} onCheckedChange={v => setStates({ ...states, md: v })} />
Regular (md) - 28x16
</FieldLabel>
</FieldRoot>
<FieldRoot name="largeSwitch">
<FieldLabel className="flex items-center gap-3">
<Switch size="lg" checked={states.lg} onCheckedChange={v => setStates({ ...states, lg: v })} />
Large (lg) - 36x20
</FieldLabel>
</FieldRoot>
</div>
)
}
@ -200,30 +220,42 @@ const LoadingDemo = () => {
{loading ? 'Stop Loading' : 'Start Loading'}
</button>
<div className="space-y-3">
<div className="flex items-center gap-3">
<Switch size="lg" checked={false} loading={loading} />
<span className="text-sm text-gray-700">Large unchecked</span>
</div>
<div className="flex items-center gap-3">
<Switch size="lg" checked={true} loading={loading} />
<span className="text-sm text-gray-700">Large checked</span>
</div>
<div className="flex items-center gap-3">
<Switch size="md" checked={false} loading={loading} />
<span className="text-sm text-gray-700">Regular unchecked</span>
</div>
<div className="flex items-center gap-3">
<Switch size="md" checked={true} loading={loading} />
<span className="text-sm text-gray-700">Regular checked</span>
</div>
<div className="flex items-center gap-3">
<Switch size="sm" checked={false} loading={loading} />
<span className="text-sm text-gray-700">Small (no spinner)</span>
</div>
<div className="flex items-center gap-3">
<Switch size="xs" checked={false} loading={loading} />
<span className="text-sm text-gray-700">Extra Small (no spinner)</span>
</div>
<FieldRoot name="largeUncheckedLoading">
<FieldLabel className="flex items-center gap-3">
<Switch size="lg" checked={false} loading={loading} />
Large unchecked
</FieldLabel>
</FieldRoot>
<FieldRoot name="largeCheckedLoading">
<FieldLabel className="flex items-center gap-3">
<Switch size="lg" checked={true} loading={loading} />
Large checked
</FieldLabel>
</FieldRoot>
<FieldRoot name="regularUncheckedLoading">
<FieldLabel className="flex items-center gap-3">
<Switch size="md" checked={false} loading={loading} />
Regular unchecked
</FieldLabel>
</FieldRoot>
<FieldRoot name="regularCheckedLoading">
<FieldLabel className="flex items-center gap-3">
<Switch size="md" checked={true} loading={loading} />
Regular checked
</FieldLabel>
</FieldRoot>
<FieldRoot name="smallLoading">
<FieldLabel className="flex items-center gap-3">
<Switch size="sm" checked={false} loading={loading} />
Small
</FieldLabel>
</FieldRoot>
<FieldRoot name="extraSmallLoading">
<FieldLabel className="flex items-center gap-3">
<Switch size="xs" checked={false} loading={loading} />
Extra Small
</FieldLabel>
</FieldRoot>
</div>
</div>
)
@ -234,7 +266,7 @@ export const Loading: Story = {
parameters: {
docs: {
description: {
story: 'Loading state disables interaction and shows a spinning icon (i-ri-loader-2-line) for md/lg sizes. Spinner position mirrors the knob: appears on the opposite side of the checked state.',
story: 'Loading state disables interaction and shows a spinner for md and lg sizes.',
},
},
},
@ -242,61 +274,76 @@ export const Loading: Story = {
const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
const MutationLoadingDemo = () => {
function useMockAutoRetrySettingQuery() {
const [enabled, setEnabled] = useState(false)
return {
data: {
enabled,
},
setData: setEnabled,
}
}
function useMockUpdateAutoRetrySettingMutation({
onSuccess,
}: {
onSuccess: (enabled: boolean) => void
}) {
const [requestCount, setRequestCount] = useState(0)
const [isPending, startTransition] = useTransition()
const handleChange = (nextValue: boolean) => {
const mutate = (nextValue: boolean) => {
if (isPending)
return
startTransition(async () => {
setRequestCount(current => current + 1)
await wait(1200)
setEnabled(nextValue)
onSuccess(nextValue)
})
}
return {
requestCount,
isPending,
mutate,
}
}
const MutationLoadingDemo = () => {
const autoRetrySetting = useMockAutoRetrySettingQuery()
const updateAutoRetrySetting = useMockUpdateAutoRetrySettingMutation({
onSuccess: autoRetrySetting.setData,
})
const statusText = updateAutoRetrySetting.isPending
? 'Saving changes...'
: autoRetrySetting.data.enabled
? 'Auto retry is enabled.'
: 'Auto retry is disabled.'
return (
<div className="w-[340px] space-y-4 rounded-2xl border border-components-panel-border bg-components-panel-bg p-4 shadow-sm">
<div className="space-y-1">
<p className="text-sm font-medium text-text-primary">Mutation Loading Guard</p>
<p className="text-xs text-text-tertiary">
Click once to start a simulated mutate call. While the request is pending, the switch enters
{' '}
<code className="rounded-sm bg-state-base-hover px-1 py-0.5 text-[11px]">loading</code>
{' '}
and rejects duplicate clicks.
</p>
</div>
<div className="grid w-90 gap-3 rounded-lg border border-components-panel-border bg-components-panel-bg p-4 shadow-sm">
<FieldRoot name="autoRetry">
<FieldLabel className="flex items-center justify-between gap-4">
<span className="system-sm-medium text-text-secondary">Enable auto retry</span>
<Switch
size="lg"
checked={autoRetrySetting.data.enabled}
loading={updateAutoRetrySetting.isPending}
onCheckedChange={updateAutoRetrySetting.mutate}
/>
</FieldLabel>
<FieldDescription>Retry failed workflow runs without manual intervention.</FieldDescription>
</FieldRoot>
<div className="flex items-center justify-between rounded-xl border border-components-panel-border-subtle bg-background-default-dodge px-3 py-2 shadow-sm">
<div className="space-y-1">
<p className="text-sm font-medium text-text-primary">Enable Auto Retry</p>
<p className="text-xs text-text-tertiary">
{isPending ? 'Saving…' : enabled ? 'Saved as on' : 'Saved as off'}
</p>
</div>
<Switch
size="lg"
checked={enabled}
loading={isPending}
onCheckedChange={handleChange}
aria-label="Enable Auto Retry"
/>
</div>
<div className="grid grid-cols-2 gap-2 text-xs text-text-tertiary">
<div className="rounded-lg bg-state-base-hover px-3 py-2">
<div className="font-medium text-text-secondary">Committed Value</div>
<div>{enabled ? 'On' : 'Off'}</div>
</div>
<div className="rounded-lg bg-state-base-hover px-3 py-2">
<div className="font-medium text-text-secondary">Mutate Count</div>
<div>{requestCount}</div>
</div>
</div>
<span className="text-xs text-text-tertiary" aria-live="polite">
{statusText}
{' '}
Save attempts:
{' '}
{updateAutoRetrySetting.requestCount}
</span>
</div>
)
}
@ -306,7 +353,7 @@ export const MutationLoadingGuard: Story = {
parameters: {
docs: {
description: {
story: 'Simulates a controlled switch backed by an async mutate call. The component keeps its previous committed value, sets `loading` during the request, and blocks duplicate clicks until the mutation resolves.',
story: 'Controlled switch that enters loading while the change is saved.',
},
},
},
@ -315,19 +362,19 @@ export const MutationLoadingGuard: Story = {
const SkeletonDemo = () => (
<div className="flex flex-col items-center space-y-4">
<div className="flex items-center gap-3">
<SwitchSkeleton size="xs" />
<SwitchSkeleton size="xs" aria-hidden="true" />
<span className="text-sm text-gray-700">Extra Small skeleton</span>
</div>
<div className="flex items-center gap-3">
<SwitchSkeleton size="sm" />
<SwitchSkeleton size="sm" aria-hidden="true" />
<span className="text-sm text-gray-700">Small skeleton</span>
</div>
<div className="flex items-center gap-3">
<SwitchSkeleton size="md" />
<SwitchSkeleton size="md" aria-hidden="true" />
<span className="text-sm text-gray-700">Regular skeleton</span>
</div>
<div className="flex items-center gap-3">
<SwitchSkeleton size="lg" />
<SwitchSkeleton size="lg" aria-hidden="true" />
<span className="text-sm text-gray-700">Large skeleton</span>
</div>
</div>
@ -338,7 +385,7 @@ export const Skeleton: Story = {
parameters: {
docs: {
description: {
story: '`SwitchSkeleton` renders a non-interactive placeholder with `bg-text-quaternary opacity-20`. Exported from `@langgenius/dify-ui/switch` alongside `Switch`.',
story: 'Non-interactive placeholders for switch loading layouts.',
},
},
},

View File

@ -45,26 +45,34 @@ const switchThumbVariants = cva(
export type SwitchSize = NonNullable<VariantProps<typeof switchRootVariants>['size']>
const spinnerSizeConfig: Partial<Record<SwitchSize, {
icon: string
uncheckedPosition: string
checkedPosition: string
}>> = {
md: {
icon: 'size-2',
uncheckedPosition: 'left-[calc(50%+6px)]',
checkedPosition: 'left-[calc(50%-6px)]',
},
lg: {
icon: 'size-2.5',
uncheckedPosition: 'left-[calc(50%+8px)]',
checkedPosition: 'left-[calc(50%-8px)]',
const switchSpinnerVariants = cva(
'absolute top-1/2 -translate-x-1/2 -translate-y-1/2',
{
variants: {
size: {
md: 'size-2 left-[calc(50%+6px)] group-data-checked:left-[calc(50%-6px)]',
lg: 'size-2.5 left-[calc(50%+8px)] group-data-checked:left-[calc(50%-8px)]',
},
},
},
)
type ControlledSwitchProps = {
checked: boolean
defaultChecked?: never
}
type UncontrolledSwitchProps = {
checked?: never
defaultChecked?: boolean
}
type SwitchControlProps = ControlledSwitchProps | UncontrolledSwitchProps
export type SwitchProps
= Omit<BaseSwitchNS.Root.Props, 'className' | 'size' | 'onCheckedChange'>
= Omit<BaseSwitchNS.Root.Props, 'checked' | 'defaultChecked' | 'className' | 'size' | 'onCheckedChange'>
& VariantProps<typeof switchRootVariants>
& SwitchControlProps
& {
onCheckedChange?: (checked: boolean) => void
loading?: boolean
@ -81,7 +89,6 @@ export function Switch({
...props
}: SwitchProps) {
const isDisabled = disabled || loading
const spinner = loading && size ? spinnerSizeConfig[size] : undefined
return (
<BaseSwitch.Root
@ -95,14 +102,10 @@ export function Switch({
<BaseSwitch.Thumb
className={switchThumbVariants({ size })}
/>
{spinner
{loading && (size === 'md' || size === 'lg')
? (
<span
className={cn(
'absolute top-1/2 -translate-x-1/2 -translate-y-1/2',
spinner.icon,
checked ? spinner.checkedPosition : spinner.uncheckedPosition,
)}
className={switchSpinnerVariants({ size })}
aria-hidden="true"
>
<i className="i-ri-loader-2-line size-full animate-spin text-text-tertiary motion-reduce:animate-none" />
@ -131,11 +134,8 @@ const switchSkeletonVariants = cva(
)
export type SwitchSkeletonProps
= Omit<HTMLAttributes<HTMLDivElement>, 'className'>
= HTMLAttributes<HTMLDivElement>
& VariantProps<typeof switchSkeletonVariants>
& {
className?: string
}
export function SwitchSkeleton({
size = 'md',

View File

@ -41,6 +41,7 @@ describe('@langgenius/dify-ui/toast', () => {
await expect.element(screen.getByRole('region', { name: 'Notifications' })).toHaveAttribute('aria-live', 'polite')
await expect.element(screen.getByRole('region', { name: 'Notifications' })).toHaveClass('z-60')
expect(screen.getByRole('region', { name: 'Notifications' }).element().firstElementChild).toHaveClass('top-4')
expect(screen.getByText('Saved').element().closest('[class*="transition-opacity"]')).toHaveClass('motion-reduce:transition-none')
expect(screen.getByRole('dialog').element()).not.toHaveClass('outline-hidden')
expect(document.body.querySelector('[aria-hidden="true"].i-ri-checkbox-circle-fill')).toBeInTheDocument()
expect(document.body.querySelector('button[aria-label="Close notification"][aria-hidden="true"]')).toBeInTheDocument()

View File

@ -171,7 +171,7 @@ function ToastCard({
aria-hidden="true"
className={cn('absolute -inset-px bg-linear-to-r opacity-40', getToneGradientClasses(toastType))}
/>
<BaseToast.Content className="relative flex items-start gap-1 overflow-hidden p-3 transition-opacity duration-200 data-behind:opacity-0 data-expanded:opacity-100">
<BaseToast.Content className="relative flex items-start gap-1 overflow-hidden p-3 transition-opacity duration-200 data-behind:opacity-0 data-expanded:opacity-100 motion-reduce:transition-none">
<div className="flex shrink-0 items-center justify-center p-0.5">
<ToastIcon type={toastType} />
</div>

View File

@ -10,7 +10,11 @@ export default defineConfig({
tsconfigPaths: true,
},
optimizeDeps: {
include: ['@base-ui/react/form'],
include: [
'@base-ui/react/form',
'@base-ui/react/merge-props',
'@base-ui/react/use-render',
],
},
test: {
globals: true,

View File

@ -5,6 +5,7 @@ import { useSuspenseQuery } from '@tanstack/react-query'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import DifyLogo from '@/app/components/base/logo/dify-logo'
import Link from '@/next/link'
import { useRouter } from '@/next/navigation'
import { systemFeaturesQueryOptions } from '@/service/system-features'
import Avatar from './avatar'
@ -17,21 +18,26 @@ const Header = () => {
const goToStudio = useCallback(() => {
router.push('/apps')
}, [router])
const logoLabel = systemFeatures.branding.enabled && systemFeatures.branding.application_title ? systemFeatures.branding.application_title : 'Dify'
return (
<div className="flex flex-1 items-center justify-between px-4">
<div className="flex items-center gap-3">
<div className="flex cursor-pointer items-center" onClick={goToStudio}>
<Link
href="/apps"
className="flex items-center rounded-sm hover:opacity-80 focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
aria-label={logoLabel}
>
{systemFeatures.branding.enabled && systemFeatures.branding.login_page_logo
? (
<img
src={systemFeatures.branding.login_page_logo}
className="block h-[22px] w-auto object-contain"
alt="Dify logo"
alt=""
/>
)
: <DifyLogo />}
</div>
: <DifyLogo alt="" />}
</Link>
<div className="h-4 w-px origin-center rotate-[11.31deg] bg-divider-regular" />
<p className="relative mt-[-2px] title-3xl-semi-bold text-text-primary">{t('account.account', { ns: 'common' })}</p>
</div>

View File

@ -1,4 +1,4 @@
import type { ReactElement, ReactNode } from 'react'
import type { ReactElement } from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { runCreateAppAttributionBootstrap } from '@/utils/create-app-tracking'
@ -28,14 +28,7 @@ vi.mock('@/next/headers', () => ({
const loadComponent = async () => {
const mod = await import('../create-app-attribution-bootstrap')
const rawExport = mod.default as unknown
const renderer: (() => Promise<ReactNode>) | undefined
= typeof rawExport === 'function' ? rawExport as () => Promise<ReactNode> : (rawExport as { type?: () => Promise<ReactNode> }).type
if (!renderer)
throw new Error('CreateAppAttributionBootstrap component is not callable in tests')
return renderer
return mod.CreateAppAttributionBootstrap
}
const runBootstrapScript = () => {

View File

@ -5,6 +5,7 @@ import type { AnnotationItem, AnnotationItemBasic } from './type'
import type { AnnotationReplyConfig } from '@/models/debug'
import type { App } from '@/types/app'
import { cn } from '@langgenius/dify-ui/cn'
import { Pagination } from '@langgenius/dify-ui/pagination'
import { Switch } from '@langgenius/dify-ui/switch'
import { toast } from '@langgenius/dify-ui/toast'
import { RiEqualizer2Line } from '@remixicon/react'
@ -16,7 +17,6 @@ import ActionButton from '@/app/components/base/action-button'
import ConfigParamModal from '@/app/components/base/features/new-feature-panel/annotation-reply/config-param-modal'
import { MessageFast } from '@/app/components/base/icons/src/vender/solid/communication'
import Loading from '@/app/components/base/loading'
import Pagination from '@/app/components/base/pagination'
import AnnotationFullModal from '@/app/components/billing/annotation-full/modal'
import { APP_PAGE_LIMIT } from '@/config'
import { useProviderContext } from '@/context/provider-context'
@ -49,6 +49,7 @@ const Annotation: FC<Props> = (props) => {
const [limit, setLimit] = useState(APP_PAGE_LIMIT)
const [list, setList] = useState<AnnotationItem[]>([])
const [total, setTotal] = useState(0)
const totalPages = total ? Math.max(Math.ceil(total / limit), 1) : 1
const [isLoading, setIsLoading] = useState(false)
const [controlUpdateList, setControlUpdateList] = useState(() => Date.now())
const [currItem, setCurrItem] = useState<AnnotationItem | null>(null)
@ -217,11 +218,22 @@ const Annotation: FC<Props> = (props) => {
{(total && total > APP_PAGE_LIMIT)
? (
<Pagination
current={currPage}
onChange={setCurrPage}
total={total}
limit={limit}
onLimitChange={setLimit}
page={currPage + 1}
totalPages={totalPages}
onPageChange={page => setCurrPage(page - 1)}
labels={{
previous: t('pagination.previous', { ns: 'common' }),
next: t('pagination.next', { ns: 'common' }),
editPageNumber: (page, totalPages) => t('pagination.editPageNumber', { ns: 'common', page, totalPages }),
pageNumberInput: t('pagination.pageNumber', { ns: 'common' }),
}}
pageSize={{
value: limit,
options: [10, 25, 50],
onValueChange: setLimit,
label: t('pagination.perPage', { ns: 'common' }),
ariaLabel: t('pagination.perPage', { ns: 'common' }),
}}
/>
)
: null}

View File

@ -20,12 +20,12 @@ import {
DrawerTitle,
DrawerViewport,
} from '@langgenius/dify-ui/drawer'
import { Pagination } from '@langgenius/dify-ui/pagination'
import * as React from 'react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Badge from '@/app/components/base/badge'
import { MessageCheckRemove } from '@/app/components/base/icons/src/vender/line/communication'
import Pagination from '@/app/components/base/pagination'
import TabSlider from '@/app/components/base/tab-slider-plain'
import { APP_PAGE_LIMIT } from '@/config'
import useTimestamp from '@/hooks/use-timestamp'
@ -62,6 +62,7 @@ const ViewAnnotationModal: FC<Props> = ({
const { formatTime } = useTimestamp()
const [currPage, setCurrPage] = React.useState<number>(0)
const [total, setTotal] = useState(0)
const totalPages = total ? Math.max(Math.ceil(total / APP_PAGE_LIMIT), 1) : 1
const [hitHistoryList, setHitHistoryList] = useState<HitHistoryItem[]>([])
// Update local state when item prop changes (e.g., when modal is reopened with updated data)
@ -197,10 +198,15 @@ const ViewAnnotationModal: FC<Props> = ({
{(total && total > APP_PAGE_LIMIT)
? (
<Pagination
className="px-0"
current={currPage}
onChange={setCurrPage}
total={total}
page={currPage + 1}
totalPages={totalPages}
onPageChange={page => setCurrPage(page - 1)}
labels={{
previous: t('pagination.previous', { ns: 'common' }),
next: t('pagination.next', { ns: 'common' }),
editPageNumber: (page, totalPages) => t('pagination.editPageNumber', { ns: 'common', page, totalPages }),
pageNumberInput: t('pagination.pageNumber', { ns: 'common' }),
}}
/>
)
: null}

View File

@ -67,9 +67,11 @@ vi.mock('@/app/components/base/loading', () => ({
default: () => <div>loading-logs</div>,
}))
vi.mock('@/app/components/base/pagination', () => ({
default: ({ onChange }: { onChange: (page: number) => void }) => (
<button onClick={() => onChange(1)}>go-to-page-2</button>
vi.mock('@langgenius/dify-ui/pagination', () => ({
Pagination: ({ onPageChange }: { onPageChange: (page: number) => void }) => (
<div>
<button onClick={() => onPageChange(2)}>go-to-page-2</button>
</div>
),
}))

View File

@ -1,6 +1,7 @@
'use client'
import type { FC } from 'react'
import type { App } from '@/types/app'
import { Pagination } from '@langgenius/dify-ui/pagination'
import { useDebounce } from 'ahooks'
import dayjs from 'dayjs'
import { omit } from 'es-toolkit/object'
@ -8,7 +9,6 @@ import * as React from 'react'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Loading from '@/app/components/base/loading'
import Pagination from '@/app/components/base/pagination'
import { APP_PAGE_LIMIT } from '@/config'
import { usePathname, useRouter, useSearchParams } from '@/next/navigation'
import { useChatConversations, useCompletionConversations } from '@/service/use-log'
@ -98,6 +98,7 @@ const Logs: FC<ILogsProps> = ({ appDetail }) => {
})
const total = isChatMode ? chatConversations?.total : completionConversations?.total
const totalPages = total ? Math.max(Math.ceil(total / limit), 1) : 1
const handleQueryParamsChange = useCallback((next: QueryParam) => {
setCurrPage(0)
@ -130,11 +131,22 @@ const Logs: FC<ILogsProps> = ({ appDetail }) => {
{(total && total > APP_PAGE_LIMIT)
? (
<Pagination
current={currPage}
onChange={handlePageChange}
total={total}
limit={limit}
onLimitChange={setLimit}
page={currPage + 1}
totalPages={totalPages}
onPageChange={page => handlePageChange(page - 1)}
labels={{
previous: t('pagination.previous', { ns: 'common' }),
next: t('pagination.next', { ns: 'common' }),
editPageNumber: (page, totalPages) => t('pagination.editPageNumber', { ns: 'common', page, totalPages }),
pageNumberInput: t('pagination.pageNumber', { ns: 'common' }),
}}
pageSize={{
value: limit,
options: [10, 25, 50],
onValueChange: setLimit,
label: t('pagination.perPage', { ns: 'common' }),
ariaLabel: t('pagination.perPage', { ns: 'common' }),
}}
/>
)
: null}

View File

@ -1,6 +1,7 @@
'use client'
import type { FC } from 'react'
import type { App } from '@/types/app'
import { Pagination } from '@langgenius/dify-ui/pagination'
import { useDebounce } from 'ahooks'
import dayjs from 'dayjs'
import timezone from 'dayjs/plugin/timezone'
@ -11,7 +12,6 @@ import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import EmptyElement from '@/app/components/app/log/empty-element'
import Loading from '@/app/components/base/loading'
import Pagination from '@/app/components/base/pagination'
import { APP_PAGE_LIMIT } from '@/config'
import { useAppContext } from '@/context/app-context'
import { useWorkflowLogs } from '@/service/use-log'
@ -59,6 +59,7 @@ const Logs: FC<ILogsProps> = ({ appDetail }) => {
params: query,
})
const total = workflowLogs?.total
const totalPages = total ? Math.max(Math.ceil(total / limit), 1) : 1
return (
<div className="flex h-full flex-col">
@ -76,11 +77,22 @@ const Logs: FC<ILogsProps> = ({ appDetail }) => {
{(total && total > APP_PAGE_LIMIT)
? (
<Pagination
current={currPage}
onChange={setCurrPage}
total={total}
limit={limit}
onLimitChange={setLimit}
page={currPage + 1}
totalPages={totalPages}
onPageChange={page => setCurrPage(page - 1)}
labels={{
previous: t('pagination.previous', { ns: 'common' }),
next: t('pagination.next', { ns: 'common' }),
editPageNumber: (page, totalPages) => t('pagination.editPageNumber', { ns: 'common', page, totalPages }),
pageNumberInput: t('pagination.pageNumber', { ns: 'common' }),
}}
pageSize={{
value: limit,
options: [10, 25, 50],
onValueChange: setLimit,
label: t('pagination.perPage', { ns: 'common' }),
ariaLabel: t('pagination.perPage', { ns: 'common' }),
}}
/>
)
: null}

View File

@ -58,6 +58,12 @@ describe('DifyLogo', () => {
const img = screen.getByRole('img', { name: /dify logo/i })
expect(img).toHaveClass('custom-test-class')
})
it('applies custom alt text', () => {
const { container } = render(<DifyLogo alt="" />)
const img = container.querySelector('img')
expect(img).toHaveAttribute('alt', '')
})
})
describe('Theme behavior', () => {

View File

@ -23,12 +23,14 @@ type DifyLogoProps = {
style?: LogoStyle
size?: LogoSize
className?: string
alt?: string
}
const DifyLogo: FC<DifyLogoProps> = ({
style = 'default',
size = 'medium',
className,
alt = 'Dify logo',
}) => {
const { theme } = useTheme()
const themedStyle = (theme === 'dark' && style === 'default') ? 'monochromeWhite' : style
@ -37,7 +39,7 @@ const DifyLogo: FC<DifyLogoProps> = ({
<img
src={`${basePath}${logoPathMap[themedStyle]}`}
className={cn('block object-contain', logoSizeMap[size], className)}
alt="Dify logo"
alt={alt}
/>
)
}

View File

@ -1,155 +0,0 @@
import { renderHook } from '@testing-library/react'
import usePagination from '../hook'
const defaultProps = {
currentPage: 0,
setCurrentPage: vi.fn(),
totalPages: 10,
edgePageCount: 2,
middlePagesSiblingCount: 1,
truncableText: '...',
truncableClassName: 'truncable',
}
describe('usePagination', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('pages', () => {
it('should generate correct pages array', () => {
const { result } = renderHook(() => usePagination(defaultProps))
expect(result.current.pages).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
})
it('should generate empty pages for totalPages 0', () => {
const { result } = renderHook(() => usePagination({ ...defaultProps, totalPages: 0 }))
expect(result.current.pages).toEqual([])
})
it('should generate single page', () => {
const { result } = renderHook(() => usePagination({ ...defaultProps, totalPages: 1 }))
expect(result.current.pages).toEqual([1])
})
})
describe('hasPreviousPage / hasNextPage', () => {
it('should have no previous page on first page', () => {
const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 1 }))
expect(result.current.hasPreviousPage).toBe(false)
})
it('should have previous page when not on first page', () => {
const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 3 }))
expect(result.current.hasPreviousPage).toBe(true)
})
it('should have next page when not on last page', () => {
const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 1 }))
expect(result.current.hasNextPage).toBe(true)
})
it('should have no next page on last page', () => {
const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 10 }))
expect(result.current.hasNextPage).toBe(false)
})
})
describe('middlePages', () => {
it('should return correct middle pages when at start', () => {
const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 0 }))
// isReachedToFirst: currentPage(0) <= middlePagesSiblingCount(1), so slice(0, 3)
expect(result.current.middlePages).toEqual([1, 2, 3])
})
it('should return correct middle pages when in the middle', () => {
const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 5 }))
// Not at start or end, slice(5-1, 5+1+1) = slice(4, 7) = [5, 6, 7]
expect(result.current.middlePages).toEqual([5, 6, 7])
})
it('should return correct middle pages when at end', () => {
const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 9 }))
// isReachedToLast: currentPage(9) + middlePagesSiblingCount(1) >= totalPages(10), so slice(-3)
expect(result.current.middlePages).toEqual([8, 9, 10])
})
})
describe('previousPages and nextPages', () => {
it('should return empty previousPages when at start', () => {
const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 0 }))
expect(result.current.previousPages).toEqual([])
})
it('should return previousPages when in the middle', () => {
const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 5 }))
// edgePageCount=2, so first 2 pages filtered by not in middlePages
expect(result.current.previousPages).toEqual([1, 2])
})
it('should return empty nextPages when at end', () => {
const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 9 }))
expect(result.current.nextPages).toEqual([])
})
it('should return nextPages when in the middle', () => {
const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 5 }))
// Last 2 pages: [9, 10], filtered by not in middlePages [5,6,7]
expect(result.current.nextPages).toEqual([9, 10])
})
})
describe('truncation', () => {
it('should be previous truncable when middle pages are far from edge', () => {
const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 5 }))
// previousPages=[1,2], middlePages=[5,6,7], 5 > 2+1 = true
expect(result.current.isPreviousTruncable).toBe(true)
})
it('should not be previous truncable when pages are contiguous', () => {
const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 2 }))
expect(result.current.isPreviousTruncable).toBe(false)
})
it('should be next truncable when middle pages are far from end edge', () => {
const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 5 }))
// middlePages=[5,6,7], nextPages=[9,10], 7+1 < 9 = true
expect(result.current.isNextTruncable).toBe(true)
})
it('should not be next truncable when pages are contiguous', () => {
const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 7 }))
expect(result.current.isNextTruncable).toBe(false)
})
})
describe('passthrough values', () => {
it('should pass through currentPage', () => {
const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 5 }))
expect(result.current.currentPage).toBe(5)
})
it('should pass through setCurrentPage', () => {
const setCurrentPage = vi.fn()
const { result } = renderHook(() => usePagination({ ...defaultProps, setCurrentPage }))
result.current.setCurrentPage(3)
expect(setCurrentPage).toHaveBeenCalledWith(3)
})
it('should pass through truncableText', () => {
const { result } = renderHook(() => usePagination({ ...defaultProps, truncableText: '…' }))
expect(result.current.truncableText).toBe('…')
})
it('should pass through truncableClassName', () => {
const { result } = renderHook(() => usePagination({ ...defaultProps, truncableClassName: 'custom-trunc' }))
expect(result.current.truncableClassName).toBe('custom-trunc')
})
it('should use default truncableText', () => {
const { currentPage, setCurrentPage, totalPages, edgePageCount, middlePagesSiblingCount } = defaultProps
const { result } = renderHook(() => usePagination({ currentPage, setCurrentPage, totalPages, edgePageCount, middlePagesSiblingCount }))
expect(result.current.truncableText).toBe('...')
})
})
})

View File

@ -1,444 +0,0 @@
import { act, fireEvent, render, screen } from '@testing-library/react'
import CustomizedPagination from '../index'
describe('CustomizedPagination', () => {
const defaultProps = {
current: 0,
onChange: vi.fn(),
total: 100,
}
beforeEach(() => {
vi.clearAllMocks()
vi.useRealTimers()
})
describe('Rendering', () => {
it('should render without crashing', () => {
const { container } = render(<CustomizedPagination {...defaultProps} />)
expect(container)!.toBeInTheDocument()
})
it('should display current page and total pages', () => {
render(<CustomizedPagination {...defaultProps} current={0} total={100} limit={10} />)
// current + 1 = 1, totalPages = 10
// The page info display shows "1 / 10" and page buttons also show numbers
// current + 1 = 1, totalPages = 10
// The page info display shows "1 / 10" and page buttons also show numbers
expect(screen.getByText('/'))!.toBeInTheDocument()
expect(screen.getAllByText('1').length).toBeGreaterThanOrEqual(1)
})
it('should render prev and next buttons', () => {
render(<CustomizedPagination {...defaultProps} />)
const buttons = screen.getAllByRole('button')
expect(buttons.length).toBeGreaterThanOrEqual(2)
})
it('should render page number buttons', () => {
render(<CustomizedPagination {...defaultProps} total={50} limit={10} />)
// 5 pages total, should see page numbers
// 5 pages total, should see page numbers
expect(screen.getByText('2'))!.toBeInTheDocument()
expect(screen.getByText('3'))!.toBeInTheDocument()
})
it('should display slash separator between current page and total', () => {
render(<CustomizedPagination {...defaultProps} />)
expect(screen.getByText('/'))!.toBeInTheDocument()
})
})
describe('Props', () => {
it('should apply custom className', () => {
const { container } = render(<CustomizedPagination {...defaultProps} className="my-custom" />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper)!.toHaveClass('my-custom')
})
it('should default limit to 10', () => {
render(<CustomizedPagination {...defaultProps} total={100} />)
// totalPages = 100 / 10 = 10, displayed in the page info area
expect(screen.getAllByText('10').length).toBeGreaterThanOrEqual(1)
})
it('should calculate total pages based on custom limit', () => {
render(<CustomizedPagination {...defaultProps} total={100} limit={25} />)
// totalPages = 100 / 25 = 4, displayed in the page info area
expect(screen.getAllByText('4').length).toBeGreaterThanOrEqual(1)
})
it('should disable prev button on first page', () => {
render(<CustomizedPagination {...defaultProps} current={0} />)
const buttons = screen.getAllByRole('button')
// First button is prev
// First button is prev
expect(buttons[0])!.toBeDisabled()
})
it('should disable next button on last page', () => {
render(<CustomizedPagination {...defaultProps} current={9} total={100} limit={10} />)
const buttons = screen.getAllByRole('button')
// Last button is next
// Last button is next
expect(buttons[buttons.length - 1])!.toBeDisabled()
})
it('should not render limit selector when onLimitChange is not provided', () => {
render(<CustomizedPagination {...defaultProps} />)
expect(screen.queryByText(/common\.pagination\.perPage/i)).not.toBeInTheDocument()
})
it('should render limit selector when onLimitChange is provided', () => {
const onLimitChange = vi.fn()
render(<CustomizedPagination {...defaultProps} onLimitChange={onLimitChange} />)
// Should show limit options 10, 25, 50
// Should show limit options 10, 25, 50
expect(screen.getByText('25'))!.toBeInTheDocument()
expect(screen.getByText('50'))!.toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call onChange when next button is clicked', () => {
const onChange = vi.fn()
render(<CustomizedPagination {...defaultProps} current={0} onChange={onChange} />)
const buttons = screen.getAllByRole('button')
const nextButton = buttons[buttons.length - 1]
fireEvent.click(nextButton!)
expect(onChange).toHaveBeenCalledWith(1)
})
it('should call onChange when prev button is clicked', () => {
const onChange = vi.fn()
render(<CustomizedPagination {...defaultProps} current={5} onChange={onChange} />)
const buttons = screen.getAllByRole('button')
fireEvent.click(buttons[0]!)
expect(onChange).toHaveBeenCalledWith(4)
})
it('should show input when page display is clicked', () => {
render(<CustomizedPagination {...defaultProps} />)
// Click the current page display (the div containing "1 / 10")
fireEvent.click(screen.getByText('/'))
// Input should appear
// Input should appear
expect(screen.getByRole('textbox'))!.toBeInTheDocument()
})
it('should navigate to entered page on Enter key', () => {
vi.useFakeTimers()
const onChange = vi.fn()
render(<CustomizedPagination {...defaultProps} current={0} onChange={onChange} />)
fireEvent.click(screen.getByText('/'))
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: '5' } })
fireEvent.keyDown(input, { key: 'Enter' })
act(() => {
vi.advanceTimersByTime(500)
})
expect(onChange).toHaveBeenCalledWith(4) // 0-indexed
})
it('should cancel input on Escape key', () => {
render(<CustomizedPagination {...defaultProps} current={0} />)
fireEvent.click(screen.getByText('/'))
const input = screen.getByRole('textbox')
fireEvent.keyDown(input, { key: 'Escape' })
// Input should be hidden and page display should return
// Input should be hidden and page display should return
// Input should be hidden and page display should return
// Input should be hidden and page display should return
// Input should be hidden and page display should return
// Input should be hidden and page display should return
// Input should be hidden and page display should return
// Input should be hidden and page display should return
// Input should be hidden and page display should return
// Input should be hidden and page display should return
// Input should be hidden and page display should return
// Input should be hidden and page display should return
// Input should be hidden and page display should return
// Input should be hidden and page display should return
// Input should be hidden and page display should return
// Input should be hidden and page display should return
// Input should be hidden and page display should return
// Input should be hidden and page display should return
// Input should be hidden and page display should return
// Input should be hidden and page display should return
// Input should be hidden and page display should return
// Input should be hidden and page display should return
// Input should be hidden and page display should return
// Input should be hidden and page display should return
// Input should be hidden and page display should return
// Input should be hidden and page display should return
// Input should be hidden and page display should return
// Input should be hidden and page display should return
// Input should be hidden and page display should return
// Input should be hidden and page display should return
// Input should be hidden and page display should return
// Input should be hidden and page display should return
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
expect(screen.getByText('/'))!.toBeInTheDocument()
})
it('should confirm input on blur-sm', () => {
vi.useFakeTimers()
const onChange = vi.fn()
render(<CustomizedPagination {...defaultProps} current={0} onChange={onChange} />)
fireEvent.click(screen.getByText('/'))
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: '3' } })
fireEvent.blur(input)
act(() => {
vi.advanceTimersByTime(500)
})
expect(onChange).toHaveBeenCalledWith(2) // 0-indexed
})
it('should clamp page to max when input exceeds total pages', () => {
vi.useFakeTimers()
const onChange = vi.fn()
render(<CustomizedPagination {...defaultProps} current={0} total={100} limit={10} onChange={onChange} />)
fireEvent.click(screen.getByText('/'))
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: '999' } })
fireEvent.keyDown(input, { key: 'Enter' })
act(() => {
vi.advanceTimersByTime(500)
})
expect(onChange).toHaveBeenCalledWith(9) // last page (0-indexed)
})
it('should clamp page to min when input is less than 1', () => {
vi.useFakeTimers()
const onChange = vi.fn()
render(<CustomizedPagination {...defaultProps} current={5} onChange={onChange} />)
fireEvent.click(screen.getByText('/'))
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: '0' } })
fireEvent.keyDown(input, { key: 'Enter' })
act(() => {
vi.advanceTimersByTime(500)
})
expect(onChange).toHaveBeenCalledWith(0)
})
it('should ignore non-numeric input and empty input', () => {
render(<CustomizedPagination {...defaultProps} />)
fireEvent.click(screen.getByText('/'))
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'abc' } })
expect(input)!.toHaveValue('')
fireEvent.change(input, { target: { value: '' } })
expect(input)!.toHaveValue('')
})
it('should show per page tip on hover and hide on leave', () => {
const onLimitChange = vi.fn()
render(<CustomizedPagination {...defaultProps} onLimitChange={onLimitChange} />)
const container = screen.getByText('25').closest('.bg-components-segmented-control-bg-normal')!
fireEvent.mouseEnter(container)
// I18n mock returns ns.key
// I18n mock returns ns.key
expect(screen.getByText('common.pagination.perPage'))!.toBeInTheDocument()
fireEvent.mouseLeave(container)
expect(screen.queryByText('common.pagination.perPage')).not.toBeInTheDocument()
})
it('should call onLimitChange when limit option is clicked', () => {
const onLimitChange = vi.fn()
render(<CustomizedPagination {...defaultProps} onLimitChange={onLimitChange} />)
fireEvent.click(screen.getByText('25'))
expect(onLimitChange).toHaveBeenCalledWith(25)
})
it('should call onLimitChange with 10 when 10 option is clicked', () => {
const onLimitChange = vi.fn()
render(<CustomizedPagination {...defaultProps} onLimitChange={onLimitChange} />)
const container = screen.getByText('25').closest('.bg-components-segmented-control-bg-normal')!
const option10 = Array.from(container.children).find(el => el.textContent === '10')!
fireEvent.click(option10)
expect(onLimitChange).toHaveBeenCalledWith(10)
})
it('should call onLimitChange with 50 when 50 option is clicked', () => {
const onLimitChange = vi.fn()
render(<CustomizedPagination {...defaultProps} onLimitChange={onLimitChange} />)
fireEvent.click(screen.getByText('50'))
expect(onLimitChange).toHaveBeenCalledWith(50)
})
it('should call onChange when a page button is clicked', () => {
const onChange = vi.fn()
render(<CustomizedPagination {...defaultProps} current={0} total={50} limit={10} onChange={onChange} />)
fireEvent.click(screen.getByText('3'))
expect(onChange).toHaveBeenCalledWith(2) // 0-indexed
})
it('should correctly select active limit style for 25 and 50', () => {
// Test limit 25
const { container: containerA } = render(<CustomizedPagination current={0} total={100} limit={25} onChange={vi.fn()} onLimitChange={vi.fn()} />)
const wrapper25 = Array.from(containerA.querySelectorAll('div.system-sm-medium')).find(el => el.textContent === '25')!
expect(wrapper25)!.toHaveClass('bg-components-segmented-control-item-active-bg')
// Test limit 50
const { container: containerB } = render(<CustomizedPagination current={0} total={100} limit={50} onChange={vi.fn()} onLimitChange={vi.fn()} />)
const wrapper50 = Array.from(containerB.querySelectorAll('div.system-sm-medium')).find(el => el.textContent === '50')!
expect(wrapper50)!.toHaveClass('bg-components-segmented-control-item-active-bg')
})
})
describe('Edge Cases', () => {
it('should handle total of 0', () => {
const { container } = render(<CustomizedPagination {...defaultProps} total={0} />)
expect(container)!.toBeInTheDocument()
})
it('should handle confirm when input value is unchanged (covers false branch of empty string check)', () => {
vi.useFakeTimers()
const onChange = vi.fn()
render(<CustomizedPagination {...defaultProps} current={4} onChange={onChange} />)
fireEvent.click(screen.getByText('/'))
const input = screen.getByRole('textbox')
// Blur without changing anything
fireEvent.blur(input)
act(() => {
vi.advanceTimersByTime(500)
})
// onChange should NOT be called
expect(onChange).not.toHaveBeenCalled()
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
})
it('should ignore other keys in handleInputKeyDown (covers false branch of Escape check)', () => {
render(<CustomizedPagination {...defaultProps} current={4} />)
fireEvent.click(screen.getByText('/'))
const input = screen.getByRole('textbox')
fireEvent.keyDown(input, { key: 'a' })
expect(screen.getByRole('textbox'))!.toBeInTheDocument()
})
it('should trigger handleInputConfirm with empty string specifically on keydown Enter', async () => {
const { userEvent } = await import('@testing-library/user-event')
const user = userEvent.setup()
render(<CustomizedPagination {...defaultProps} current={4} />)
fireEvent.click(screen.getByText('/'))
const input = screen.getByRole('textbox')
await user.clear(input)
await user.type(input, '{Enter}')
// Wait for debounce 500ms
await new Promise(r => setTimeout(r, 600))
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
})
it('should explicitly trigger Escape key logic in handleInputKeyDown', async () => {
const { userEvent } = await import('@testing-library/user-event')
const user = userEvent.setup()
render(<CustomizedPagination {...defaultProps} current={4} />)
fireEvent.click(screen.getByText('/'))
const input = screen.getByRole('textbox')
await user.type(input, '{Escape}')
// Wait for debounce 500ms
await new Promise(r => setTimeout(r, 600))
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
})
it('should handle single page', () => {
render(<CustomizedPagination {...defaultProps} total={5} limit={10} />)
// totalPages = 1, both buttons should be disabled
const buttons = screen.getAllByRole('button')
expect(buttons[0])!.toBeDisabled()
expect(buttons[buttons.length - 1])!.toBeDisabled()
})
it('should restore input value when blurred with empty value', () => {
render(<CustomizedPagination {...defaultProps} current={4} />)
fireEvent.click(screen.getByText('/'))
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: '' } })
fireEvent.blur(input)
// Should close input without calling onChange, restoring to current + 1
// Should close input without calling onChange, restoring to current + 1
// Should close input without calling onChange, restoring to current + 1
// Should close input without calling onChange, restoring to current + 1
// Should close input without calling onChange, restoring to current + 1
// Should close input without calling onChange, restoring to current + 1
// Should close input without calling onChange, restoring to current + 1
// Should close input without calling onChange, restoring to current + 1
// Should close input without calling onChange, restoring to current + 1
// Should close input without calling onChange, restoring to current + 1
// Should close input without calling onChange, restoring to current + 1
// Should close input without calling onChange, restoring to current + 1
// Should close input without calling onChange, restoring to current + 1
// Should close input without calling onChange, restoring to current + 1
// Should close input without calling onChange, restoring to current + 1
// Should close input without calling onChange, restoring to current + 1
// Should close input without calling onChange, restoring to current + 1
// Should close input without calling onChange, restoring to current + 1
// Should close input without calling onChange, restoring to current + 1
// Should close input without calling onChange, restoring to current + 1
// Should close input without calling onChange, restoring to current + 1
// Should close input without calling onChange, restoring to current + 1
// Should close input without calling onChange, restoring to current + 1
// Should close input without calling onChange, restoring to current + 1
// Should close input without calling onChange, restoring to current + 1
// Should close input without calling onChange, restoring to current + 1
// Should close input without calling onChange, restoring to current + 1
// Should close input without calling onChange, restoring to current + 1
// Should close input without calling onChange, restoring to current + 1
// Should close input without calling onChange, restoring to current + 1
// Should close input without calling onChange, restoring to current + 1
// Should close input without calling onChange, restoring to current + 1
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
})
})
})

View File

@ -1,549 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { Pagination } from '../pagination'
// Helper to render Pagination with common defaults
function renderPagination({
currentPage = 0,
totalPages = 10,
setCurrentPage = vi.fn(),
edgePageCount = 2,
middlePagesSiblingCount = 1,
truncableText = '...',
truncableClassName = 'truncable',
children,
}: {
currentPage?: number
totalPages?: number
setCurrentPage?: (page: number) => void
edgePageCount?: number
middlePagesSiblingCount?: number
truncableText?: string
truncableClassName?: string
children?: React.ReactNode
} = {}) {
return render(
<Pagination
currentPage={currentPage}
totalPages={totalPages}
setCurrentPage={setCurrentPage}
edgePageCount={edgePageCount}
middlePagesSiblingCount={middlePagesSiblingCount}
truncableText={truncableText}
truncableClassName={truncableClassName}
>
{children}
</Pagination>,
)
}
describe('Pagination', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
const { container } = renderPagination()
expect(container).toBeInTheDocument()
})
it('should render children', () => {
renderPagination({ children: <span>child content</span> })
expect(screen.getByText(/child content/i)).toBeInTheDocument()
})
it('should apply className to wrapper div', () => {
const { container } = render(
<Pagination
currentPage={0}
totalPages={5}
setCurrentPage={vi.fn()}
edgePageCount={2}
middlePagesSiblingCount={1}
className="my-pagination"
>
<span>test</span>
</Pagination>,
)
expect(container.firstChild).toHaveClass('my-pagination')
})
it('should apply data-testid when provided', () => {
render(
<Pagination
currentPage={0}
totalPages={5}
setCurrentPage={vi.fn()}
edgePageCount={2}
middlePagesSiblingCount={1}
dataTestId="my-pagination"
>
<span>test</span>
</Pagination>,
)
expect(screen.getByTestId('my-pagination')).toBeInTheDocument()
})
})
describe('PrevButton', () => {
it('should render prev button', () => {
renderPagination({
currentPage: 3,
children: <Pagination.PrevButton>Prev</Pagination.PrevButton>,
})
expect(screen.getByText(/prev/i)).toBeInTheDocument()
})
it('should call setCurrentPage with previous page when clicked', () => {
const setCurrentPage = vi.fn()
renderPagination({
currentPage: 3,
setCurrentPage,
children: <Pagination.PrevButton>Prev</Pagination.PrevButton>,
})
fireEvent.click(screen.getByText(/prev/i))
expect(setCurrentPage).toHaveBeenCalledWith(2)
})
it('should not navigate below page 0', () => {
const setCurrentPage = vi.fn()
renderPagination({
currentPage: 0,
setCurrentPage,
children: <Pagination.PrevButton>Prev</Pagination.PrevButton>,
})
fireEvent.click(screen.getByText(/prev/i))
expect(setCurrentPage).not.toHaveBeenCalled()
})
it('should be disabled on first page', () => {
renderPagination({
currentPage: 0,
children: <Pagination.PrevButton>Prev</Pagination.PrevButton>,
})
expect(screen.getByText(/prev/i).closest('button')).toBeDisabled()
})
it('should navigate on Enter key press', () => {
const setCurrentPage = vi.fn()
renderPagination({
currentPage: 3,
setCurrentPage,
children: <Pagination.PrevButton>Prev</Pagination.PrevButton>,
})
fireEvent.keyDown(screen.getByText(/prev/i).closest('button')!, { key: 'Enter', code: 'Enter', keyCode: 13, which: 13 })
expect(setCurrentPage).toHaveBeenCalledWith(2)
})
it('should not navigate on Enter when disabled', () => {
const setCurrentPage = vi.fn()
renderPagination({
currentPage: 0,
setCurrentPage,
children: <Pagination.PrevButton>Prev</Pagination.PrevButton>,
})
fireEvent.keyDown(screen.getByText(/prev/i).closest('button')!, { key: 'Enter', code: 'Enter', keyCode: 13, which: 13 })
expect(setCurrentPage).not.toHaveBeenCalled()
})
it('should render with custom as element', () => {
renderPagination({
currentPage: 3,
children: <Pagination.PrevButton as={<div />}>Prev</Pagination.PrevButton>,
})
expect(screen.getByText(/prev/i)).toBeInTheDocument()
})
it('should apply dataTestId', () => {
renderPagination({
currentPage: 3,
children: <Pagination.PrevButton dataTestId="prev-btn">Prev</Pagination.PrevButton>,
})
expect(screen.getByTestId('prev-btn')).toBeInTheDocument()
})
})
describe('NextButton', () => {
it('should render next button', () => {
renderPagination({
currentPage: 0,
children: <Pagination.NextButton>Next</Pagination.NextButton>,
})
expect(screen.getByText(/next/i)).toBeInTheDocument()
})
it('should call setCurrentPage with next page when clicked', () => {
const setCurrentPage = vi.fn()
renderPagination({
currentPage: 0,
totalPages: 10,
setCurrentPage,
children: <Pagination.NextButton>Next</Pagination.NextButton>,
})
fireEvent.click(screen.getByText(/next/i))
expect(setCurrentPage).toHaveBeenCalledWith(1)
})
it('should not navigate beyond last page', () => {
const setCurrentPage = vi.fn()
renderPagination({
currentPage: 9,
totalPages: 10,
setCurrentPage,
children: <Pagination.NextButton>Next</Pagination.NextButton>,
})
fireEvent.click(screen.getByText(/next/i))
expect(setCurrentPage).not.toHaveBeenCalled()
})
it('should be disabled on last page', () => {
renderPagination({
currentPage: 9,
totalPages: 10,
children: <Pagination.NextButton>Next</Pagination.NextButton>,
})
expect(screen.getByText(/next/i).closest('button')).toBeDisabled()
})
it('should navigate on Enter key press', () => {
const setCurrentPage = vi.fn()
renderPagination({
currentPage: 0,
totalPages: 10,
setCurrentPage,
children: <Pagination.NextButton>Next</Pagination.NextButton>,
})
fireEvent.keyDown(screen.getByText(/next/i).closest('button')!, { key: 'Enter', code: 'Enter', keyCode: 13, which: 13 })
expect(setCurrentPage).toHaveBeenCalledWith(1)
})
it('should not navigate on Enter when disabled', () => {
const setCurrentPage = vi.fn()
renderPagination({
currentPage: 9,
totalPages: 10,
setCurrentPage,
children: <Pagination.NextButton>Next</Pagination.NextButton>,
})
fireEvent.keyDown(screen.getByText(/next/i).closest('button')!, { key: 'Enter', code: 'Enter', keyCode: 13, which: 13 })
expect(setCurrentPage).not.toHaveBeenCalled()
})
it('should apply dataTestId', () => {
renderPagination({
currentPage: 0,
children: <Pagination.NextButton dataTestId="next-btn">Next</Pagination.NextButton>,
})
expect(screen.getByTestId('next-btn')).toBeInTheDocument()
})
})
describe('PageButton', () => {
it('should render page number buttons', () => {
renderPagination({
currentPage: 0,
totalPages: 5,
children: (
<Pagination.PageButton
className="page-btn"
activeClassName="active"
inactiveClassName="inactive"
/>
),
})
expect(screen.getByText('1')).toBeInTheDocument()
expect(screen.getByText('5')).toBeInTheDocument()
})
it('should apply activeClassName to current page', () => {
renderPagination({
currentPage: 2,
totalPages: 5,
children: (
<Pagination.PageButton
className="page-btn"
activeClassName="active"
inactiveClassName="inactive"
/>
),
})
// current page is 2, so page 3 (1-indexed) should be active
expect(screen.getByText('3').closest('a')).toHaveClass('active')
})
it('should apply inactiveClassName to non-current pages', () => {
renderPagination({
currentPage: 2,
totalPages: 5,
children: (
<Pagination.PageButton
className="page-btn"
activeClassName="active"
inactiveClassName="inactive"
/>
),
})
expect(screen.getByText('1').closest('a')).toHaveClass('inactive')
})
it('should call setCurrentPage when a page button is clicked', () => {
const setCurrentPage = vi.fn()
renderPagination({
currentPage: 0,
totalPages: 5,
setCurrentPage,
children: (
<Pagination.PageButton
className="page-btn"
activeClassName="active"
inactiveClassName="inactive"
/>
),
})
fireEvent.click(screen.getByText('3'))
expect(setCurrentPage).toHaveBeenCalledWith(2) // 0-indexed
})
it('should navigate on Enter key press on a page button', () => {
const setCurrentPage = vi.fn()
renderPagination({
currentPage: 0,
totalPages: 5,
setCurrentPage,
children: (
<Pagination.PageButton
className="page-btn"
activeClassName="active"
inactiveClassName="inactive"
/>
),
})
fireEvent.keyDown(screen.getByText('4').closest('a')!, { key: 'Enter', code: 'Enter', keyCode: 13, which: 13 })
expect(setCurrentPage).toHaveBeenCalledWith(3) // 0-indexed
})
it('should render truncable text when pages are truncated', () => {
renderPagination({
currentPage: 5,
totalPages: 20,
edgePageCount: 2,
middlePagesSiblingCount: 1,
truncableText: '...',
children: (
<Pagination.PageButton
className="page-btn"
activeClassName="active"
inactiveClassName="inactive"
/>
),
})
// With 20 pages and current at 5, there should be truncation
expect(screen.getAllByText('...').length).toBeGreaterThanOrEqual(1)
})
})
describe('Edge Cases', () => {
it('should handle single page', () => {
const setCurrentPage = vi.fn()
renderPagination({
currentPage: 0,
totalPages: 1,
setCurrentPage,
children: (
<>
<Pagination.PrevButton>Prev</Pagination.PrevButton>
<Pagination.PageButton className="page-btn" activeClassName="active" inactiveClassName="inactive" />
<Pagination.NextButton>Next</Pagination.NextButton>
</>
),
})
expect(screen.getByText(/prev/i).closest('button')).toBeDisabled()
expect(screen.getByText(/next/i).closest('button')).toBeDisabled()
expect(screen.getByText('1')).toBeInTheDocument()
})
it('should handle zero total pages', () => {
const { container } = renderPagination({
currentPage: 0,
totalPages: 0,
children: (
<Pagination.PageButton className="page-btn" activeClassName="active" inactiveClassName="inactive" />
),
})
expect(container).toBeInTheDocument()
})
it('should cover undefined active/inactive dataTestIds', () => {
// Re-render PageButton without active/inactive data test ids to hit the undefined branch in cn() fallback
renderPagination({
currentPage: 1,
totalPages: 5,
children: (
<Pagination.PageButton
className="page-btn"
activeClassName="active"
inactiveClassName="inactive"
renderExtraProps={page => ({ 'aria-label': `Page ${page}` })}
/>
),
})
expect(screen.getByText('2')).toHaveAttribute('aria-label', 'Page 2')
})
it('should cover nextPages when edge pages fall perfectly into middle Pages', () => {
renderPagination({
currentPage: 5,
totalPages: 10,
edgePageCount: 8, // Very large edge page count to hit the filter(!middlePages.includes) branches
middlePagesSiblingCount: 1,
children: (
<Pagination.PageButton className="page-btn" activeClassName="active" inactiveClassName="inactive" />
),
})
expect(screen.getByText('1')).toBeInTheDocument()
expect(screen.getByText('10')).toBeInTheDocument()
})
it('should hide truncation element if truncable is false', () => {
renderPagination({
currentPage: 2,
totalPages: 5,
edgePageCount: 1,
middlePagesSiblingCount: 1,
// When we are at page 2, middle pages are [2, 3, 4] (if 0-indexed, wait, currentPage is 0-indexed in hook?)
// Let's just render the component which calls the internal TruncableElement, when previous/next are NOT truncable
children: (
<Pagination.PageButton className="page-btn" activeClassName="active" inactiveClassName="inactive" />
),
})
// Truncation only happens if middlePages > previousPages.last + 1
expect(screen.queryByText('...')).not.toBeInTheDocument()
})
it('should hit getAllPreviousPages with less than 1 element', () => {
renderPagination({
currentPage: 0,
totalPages: 10,
edgePageCount: 1,
middlePagesSiblingCount: 0,
children: <Pagination.PageButton className="btn" activeClassName="act" inactiveClassName="inact" />,
})
// With currentPage = 0, middlePages = [1], getAllPreviousPages() -> slice(0, 0) -> []
expect(screen.getByText('1')).toBeInTheDocument()
})
it('should fire previous() keyboard event even if it does nothing without crashing', () => {
// Line 38: pagination.currentPage + 1 > 1 check is usually guarded by disabled, but we can verify it explicitly.
const setCurrentPage = vi.fn()
// Use a span so that 'disabled' attribute doesn't prevent fireEvent.click from firing
renderPagination({
currentPage: 0,
setCurrentPage,
children: <Pagination.PrevButton as={<span />}>Prev</Pagination.PrevButton>,
})
fireEvent.click(screen.getByText('Prev'))
expect(setCurrentPage).not.toHaveBeenCalled()
})
it('should fire next() even if it does nothing without crashing', () => {
// Line 73: pagination.currentPage + 1 < pages.length verify
const setCurrentPage = vi.fn()
renderPagination({
currentPage: 10,
totalPages: 10,
setCurrentPage,
children: <Pagination.NextButton as={<span />}>Next</Pagination.NextButton>,
})
fireEvent.click(screen.getByText('Next'))
expect(setCurrentPage).not.toHaveBeenCalled()
})
it('should fall back to undefined when truncableClassName is empty', () => {
// Line 115: `<li className={truncableClassName || undefined}>{truncableText}</li>`
renderPagination({
currentPage: 5,
totalPages: 10,
truncableClassName: '',
children: (
<Pagination.PageButton className="page-btn" activeClassName="active" inactiveClassName="inactive" />
),
})
// Should not have a class attribute
const truncableElements = screen.getAllByText('...')
expect(truncableElements[0]).not.toHaveAttribute('class')
})
it('should handle dataTestIdActive and dataTestIdInactive completely', () => {
// Lines 137-144
renderPagination({
currentPage: 1, // 0-indexed, so page 2 is active
totalPages: 5,
children: (
<Pagination.PageButton
className="page-btn"
activeClassName="active"
inactiveClassName="inactive"
dataTestIdActive="active-test-id"
dataTestIdInactive="inactive-test-id"
/>
),
})
const activeBtn = screen.getByTestId('active-test-id')
expect(activeBtn).toHaveTextContent('2')
const inactiveBtn = screen.getByTestId('inactive-test-id-1') // page 1
expect(inactiveBtn).toHaveTextContent('1')
})
it('should hit getAllNextPages.length < 1 in hook', () => {
renderPagination({
currentPage: 2,
totalPages: 3,
edgePageCount: 1,
middlePagesSiblingCount: 0,
children: (
<Pagination.PageButton className="page-btn" activeClassName="active" inactiveClassName="inactive" />
),
})
// Current is 3 (index 2). middlePages = [3]. getAllNextPages = slice(3, 3) = []
// This will trigger the `getAllNextPages.length < 1` branch
expect(screen.getByText('3')).toBeInTheDocument()
})
it('should handle only dataTestIdInactive without dataTestIdActive', () => {
renderPagination({
currentPage: 1,
totalPages: 3,
children: (
<Pagination.PageButton
className="page-btn"
activeClassName="active"
inactiveClassName="inactive"
dataTestIdInactive="inactive-test-id"
/>
),
})
// Missing dataTestIdActive branch coverage on line 144
expect(screen.getByText('1')).toBeInTheDocument()
})
it('should handle only dataTestIdActive without dataTestIdInactive', () => {
renderPagination({
currentPage: 1, // page 2 is active
totalPages: 3,
children: (
<Pagination.PageButton
className="page-btn"
activeClassName="active"
inactiveClassName="inactive"
dataTestIdActive="active-test-id"
/>
),
})
// This hits the branch where dataTestIdActive exists but not dataTestIdInactive
expect(screen.getByTestId('active-test-id')).toHaveTextContent('2')
expect(screen.queryByTestId('inactive-test-id-1')).not.toBeInTheDocument()
})
})
})

View File

@ -1,94 +0,0 @@
import type { IPaginationProps, IUsePagination } from './type'
import * as React from 'react'
import { useCallback } from 'react'
const usePagination = ({
currentPage,
setCurrentPage,
truncableText = '...',
truncableClassName = '',
totalPages,
edgePageCount,
middlePagesSiblingCount,
}: IPaginationProps): IUsePagination => {
const pages = React.useMemo(() => Array.from({ length: totalPages }, (_, i) => i + 1), [totalPages])
const hasPreviousPage = currentPage > 1
const hasNextPage = currentPage < totalPages
const isReachedToFirst = currentPage <= middlePagesSiblingCount
const isReachedToLast = currentPage + middlePagesSiblingCount >= totalPages
const middlePages = React.useMemo(() => {
const middlePageCount = middlePagesSiblingCount * 2 + 1
if (isReachedToFirst)
return pages.slice(0, middlePageCount)
if (isReachedToLast)
return pages.slice(-middlePageCount)
return pages.slice(
currentPage - middlePagesSiblingCount,
currentPage + middlePagesSiblingCount + 1,
)
}, [currentPage, isReachedToFirst, isReachedToLast, middlePagesSiblingCount, pages])
const getAllPreviousPages = useCallback(() => {
return pages.slice(0, middlePages[0]! - 1)
}, [middlePages, pages])
const previousPages = React.useMemo(() => {
if (isReachedToFirst || getAllPreviousPages().length < 1)
return []
return pages
.slice(0, edgePageCount)
.filter(p => !middlePages.includes(p))
}, [edgePageCount, getAllPreviousPages, isReachedToFirst, middlePages, pages])
const getAllNextPages = React.useMemo(() => {
return pages.slice(
middlePages[middlePages.length - 1],
pages[pages.length],
)
}, [pages, middlePages])
const nextPages = React.useMemo(() => {
if (isReachedToLast)
return []
if (getAllNextPages.length < 1)
return []
return pages
.slice(pages.length - edgePageCount, pages.length)
.filter(p => !middlePages.includes(p))
}, [edgePageCount, getAllNextPages.length, isReachedToLast, middlePages, pages])
const isPreviousTruncable = React.useMemo(() => {
// Is truncable if first value of middlePage is larger than last value of previousPages
return middlePages[0]! > previousPages[previousPages.length - 1]! + 1
}, [previousPages, middlePages])
const isNextTruncable = React.useMemo(() => {
// Is truncable if last value of middlePage is larger than first value of previousPages
return middlePages[middlePages.length - 1]! + 1 < nextPages[0]!
}, [nextPages, middlePages])
return {
currentPage,
setCurrentPage,
truncableText,
truncableClassName,
pages,
hasPreviousPage,
hasNextPage,
previousPages,
isPreviousTruncable,
middlePages,
isNextTruncable,
nextPages,
}
}
export default usePagination

View File

@ -1,81 +0,0 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
import { useMemo, useState } from 'react'
import Pagination from '.'
const TOTAL_ITEMS = 120
const PaginationDemo = ({
initialPage = 0,
initialLimit = 10,
}: {
initialPage?: number
initialLimit?: number
}) => {
const [current, setCurrent] = useState(initialPage)
const [limit, setLimit] = useState(initialLimit)
const pageSummary = useMemo(() => {
const start = current * limit + 1
const end = Math.min((current + 1) * limit, TOTAL_ITEMS)
return `${start}-${end} of ${TOTAL_ITEMS}`
}, [current, limit])
return (
<div className="flex w-full max-w-3xl flex-col gap-4 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6">
<div className="flex items-center justify-between text-xs tracking-[0.18em] text-text-tertiary uppercase">
<span>Log pagination</span>
<span className="rounded-md border border-divider-subtle bg-background-default px-2 py-1 font-medium text-text-secondary">
{pageSummary}
</span>
</div>
<Pagination
current={current}
total={TOTAL_ITEMS}
limit={limit}
onChange={setCurrent}
onLimitChange={(nextLimit) => {
setCurrent(0)
setLimit(nextLimit)
}}
/>
</div>
)
}
const meta = {
title: 'Base/Navigation/Pagination',
component: PaginationDemo,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Paginate long lists with optional per-page selector. Demonstrates the inline page jump input and quick limit toggles.',
},
},
},
args: {
initialPage: 0,
initialLimit: 10,
},
argTypes: {
initialPage: {
control: { type: 'number', min: 0, max: 9, step: 1 },
},
initialLimit: {
control: { type: 'radio' },
options: [10, 25, 50],
},
},
tags: ['autodocs'],
} satisfies Meta<typeof PaginationDemo>
export default meta
type Story = StoryObj<typeof meta>
export const Playground: Story = {}
export const StartAtMiddle: Story = {
args: {
initialPage: 4,
},
}

View File

@ -1,201 +0,0 @@
import type { FC } from 'react'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { RiArrowLeftLine, RiArrowRightLine } from '@remixicon/react'
import { useDebounceFn } from 'ahooks'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
import { Pagination } from './pagination'
export type Props = {
className?: string
current: number
onChange: (cur: number) => void
total: number
limit?: number
onLimitChange?: (limit: number) => void
}
const CustomizedPagination: FC<Props> = ({
className,
current,
onChange,
total,
limit = 10,
onLimitChange,
}) => {
const { t } = useTranslation()
const totalPages = Math.ceil(total / limit)
const inputRef = React.useRef<HTMLDivElement>(null)
const [showInput, setShowInput] = React.useState(false)
const [inputValue, setInputValue] = React.useState<string | number>(current + 1)
const [showPerPageTip, setShowPerPageTip] = React.useState(false)
const { run: handlePaging } = useDebounceFn((value: string) => {
if (Number.parseInt(value) > totalPages) {
setInputValue(totalPages)
onChange(totalPages - 1)
setShowInput(false)
return
}
if (Number.parseInt(value) < 1) {
setInputValue(1)
onChange(0)
setShowInput(false)
return
}
onChange(Number.parseInt(value) - 1)
setInputValue(Number.parseInt(value))
setShowInput(false)
}, { wait: 500 })
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value
if (!value)
return setInputValue('')
if (isNaN(Number.parseInt(value)))
return setInputValue('')
setInputValue(Number.parseInt(value))
}
const handleInputConfirm = () => {
if (inputValue !== '' && String(inputValue) !== String(current + 1)) {
handlePaging(String(inputValue))
return
}
if (inputValue === '')
setInputValue(current + 1)
setShowInput(false)
}
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault()
handleInputConfirm()
}
else if (e.key === 'Escape') {
e.preventDefault()
setInputValue(current + 1)
setShowInput(false)
}
}
const handleInputBlur = () => {
handleInputConfirm()
}
return (
<Pagination
className={cn('flex w-full items-center px-6 py-3 select-none', className)}
currentPage={current}
edgePageCount={2}
middlePagesSiblingCount={1}
setCurrentPage={onChange}
totalPages={totalPages}
truncableClassName="flex items-center justify-center w-8 px-1 py-2 system-sm-medium text-text-tertiary"
truncableText="..."
>
<div className="flex items-center gap-0.5 rounded-[10px] bg-background-section-burn p-0.5">
<Pagination.PrevButton
as={<div></div>}
disabled={current === 0}
>
<Button
variant="secondary"
className="size-7 px-1.5"
disabled={current === 0}
>
<RiArrowLeftLine className="size-4" />
</Button>
</Pagination.PrevButton>
{!showInput && (
<div
ref={inputRef}
className="flex items-center gap-0.5 rounded-lg px-2 py-1.5 hover:cursor-text hover:bg-state-base-hover-alt"
onClick={() => setShowInput(true)}
>
<div className="system-xs-medium text-text-secondary">{current + 1}</div>
<div className="system-xs-medium text-text-quaternary">/</div>
<div className="system-xs-medium text-text-secondary">{totalPages}</div>
</div>
)}
{showInput && (
<Input
styleCss={{
height: '28px',
width: `${inputRef.current?.clientWidth}px`,
}}
placeholder=""
autoFocus
value={inputValue}
onChange={handleInputChange}
onKeyDown={handleInputKeyDown}
onBlur={handleInputBlur}
/>
)}
<Pagination.NextButton
as={<div></div>}
disabled={current === totalPages - 1}
>
<Button
variant="secondary"
className="size-7 px-1.5"
disabled={current === totalPages - 1}
>
<RiArrowRightLine className="size-4" />
</Button>
</Pagination.NextButton>
</div>
<div className={cn('flex grow list-none items-center justify-center gap-1')}>
<Pagination.PageButton
className="flex min-w-8 cursor-pointer items-center justify-center rounded-lg px-1 py-2 system-sm-medium hover:bg-components-button-ghost-bg-hover"
activeClassName="bg-components-button-tertiary-bg text-components-button-tertiary-text hover:bg-components-button-ghost-bg-hover"
inactiveClassName="text-text-tertiary"
/>
</div>
{onLimitChange && (
<div className="flex shrink-0 items-center gap-2">
<div className="w-[51px] shrink-0 text-end system-2xs-regular-uppercase text-text-tertiary">{showPerPageTip ? t('pagination.perPage', { ns: 'common' }) : ''}</div>
<div
className="flex items-center gap-px rounded-[10px] bg-components-segmented-control-bg-normal p-0.5"
onMouseEnter={() => setShowPerPageTip(true)}
onMouseLeave={() => setShowPerPageTip(false)}
>
<div
className={cn(
'cursor-pointer rounded-lg border-[0.5px] border-transparent px-2.5 py-1.5 system-sm-medium text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
limit === 10 && 'border-components-segmented-control-item-active-border bg-components-segmented-control-item-active-bg text-text-secondary shadow-xs hover:bg-components-segmented-control-item-active-bg',
)}
onClick={() => onLimitChange?.(10)}
>
10
</div>
<div
className={cn(
'cursor-pointer rounded-lg border-[0.5px] border-transparent px-2.5 py-1.5 system-sm-medium text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
limit === 25 && 'border-components-segmented-control-item-active-border bg-components-segmented-control-item-active-bg text-text-secondary shadow-xs hover:bg-components-segmented-control-item-active-bg',
)}
onClick={() => onLimitChange?.(25)}
>
25
</div>
<div
className={cn(
'cursor-pointer rounded-lg border-[0.5px] border-transparent px-2.5 py-1.5 system-sm-medium text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
limit === 50 && 'border-components-segmented-control-item-active-border bg-components-segmented-control-item-active-bg text-text-secondary shadow-xs hover:bg-components-segmented-control-item-active-bg',
)}
onClick={() => onLimitChange?.(50)}
>
50
</div>
</div>
</div>
)}
</Pagination>
)
}
export default CustomizedPagination

View File

@ -1,190 +0,0 @@
import type {
ButtonProps,
IPagination,
IPaginationProps,
PageButtonProps,
} from './type'
import { cn } from '@langgenius/dify-ui/cn'
import { noop } from 'es-toolkit/function'
import * as React from 'react'
import usePagination from './hook'
const defaultState: IPagination = {
currentPage: 0,
setCurrentPage: noop,
truncableText: '...',
truncableClassName: '',
pages: [],
hasPreviousPage: false,
hasNextPage: false,
previousPages: [],
isPreviousTruncable: false,
middlePages: [],
isNextTruncable: false,
nextPages: [],
}
const PaginationContext: React.Context<IPagination> = React.createContext<IPagination>(defaultState)
const PrevButton = ({
className,
children,
dataTestId,
as = <button type="button" />,
...buttonProps
}: ButtonProps) => {
const pagination = React.useContext(PaginationContext)
const previous = () => {
if (pagination.currentPage + 1 > 1)
pagination.setCurrentPage(pagination.currentPage - 1)
}
const disabled = pagination.currentPage === 0
return (
<as.type
{...buttonProps}
{...as.props}
className={cn(className, as.props.className)}
onClick={() => previous()}
tabIndex={disabled ? '-1' : 0}
disabled={disabled}
data-testid={dataTestId}
onKeyDown={(event: React.KeyboardEvent) => {
event.preventDefault()
if (event.key === 'Enter' && !disabled)
previous()
}}
>
{as.props.children ?? children}
</as.type>
)
}
const NextButton = ({
className,
children,
dataTestId,
as = <button type="button" />,
...buttonProps
}: ButtonProps) => {
const pagination = React.useContext(PaginationContext)
const next = () => {
if (pagination.currentPage + 1 < pagination.pages.length)
pagination.setCurrentPage(pagination.currentPage + 1)
}
const disabled = pagination.currentPage === pagination.pages.length - 1
return (
<as.type
{...buttonProps}
{...as.props}
className={cn(className, as.props.className)}
onClick={() => next()}
tabIndex={disabled ? '-1' : 0}
disabled={disabled}
data-testid={dataTestId}
onKeyDown={(event: React.KeyboardEvent) => {
event.preventDefault()
if (event.key === 'Enter' && !disabled)
next()
}}
>
{as.props.children ?? children}
</as.type>
)
}
type ITruncableElementProps = {
prev?: boolean
}
const TruncableElement = ({ prev }: ITruncableElementProps) => {
const pagination: IPagination = React.useContext(PaginationContext)
const {
isPreviousTruncable,
isNextTruncable,
truncableText,
truncableClassName,
} = pagination
return ((isPreviousTruncable && prev === true) || (isNextTruncable && !prev))
? (
<li className={truncableClassName || undefined}>{truncableText}</li>
)
: null
}
const PageButton = ({
as = <a />,
className,
dataTestIdActive,
dataTestIdInactive,
activeClassName,
inactiveClassName,
renderExtraProps,
}: PageButtonProps) => {
const pagination: IPagination = React.useContext(PaginationContext)
const renderPageButton = (page: number) => (
<li key={page}>
<as.type
data-testid={
cn({
[`${dataTestIdActive}`]:
dataTestIdActive && pagination.currentPage + 1 === page,
[`${dataTestIdInactive}-${page}`]:
dataTestIdActive && pagination.currentPage + 1 !== page,
}) || undefined
}
tabIndex={0}
onKeyDown={(event: React.KeyboardEvent) => {
if (event.key === 'Enter')
pagination.setCurrentPage(page - 1)
}}
onClick={() => pagination.setCurrentPage(page - 1)}
className={cn(
className,
pagination.currentPage + 1 === page
? activeClassName
: inactiveClassName,
)}
{...as.props}
{...(renderExtraProps ? renderExtraProps(page) : {})}
>
{page}
</as.type>
</li>
)
return (
<>
{pagination.previousPages.map(renderPageButton)}
<TruncableElement prev />
{pagination.middlePages.map(renderPageButton)}
<TruncableElement />
{pagination.nextPages.map(renderPageButton)}
</>
)
}
export const Pagination = ({
dataTestId,
...paginationProps
}: IPaginationProps & { dataTestId?: string }) => {
const pagination = usePagination(paginationProps)
return (
<PaginationContext.Provider value={pagination}>
<div className={paginationProps.className} data-testid={dataTestId}>
{paginationProps.children}
</div>
</PaginationContext.Provider>
)
}
Pagination.PrevButton = PrevButton
Pagination.NextButton = NextButton
Pagination.PageButton = PageButton

View File

@ -1,64 +0,0 @@
import type { ButtonHTMLAttributes } from 'react'
type ElementProps = {
className?: string
children?: React.ReactNode
[key: string]: unknown
}
type IBasePaginationProps = {
currentPage: number
setCurrentPage: (page: number) => void
truncableText?: string
truncableClassName?: string
}
type IPaginationProps = IBasePaginationProps & {
totalPages: number
edgePageCount: number
middlePagesSiblingCount: number
className?: string
children?: React.ReactNode
}
type IUsePagination = IBasePaginationProps & {
pages: number[]
hasPreviousPage: boolean
hasNextPage: boolean
previousPages: number[]
isPreviousTruncable: boolean
middlePages: number[]
isNextTruncable: boolean
nextPages: number[]
}
type IPagination = IUsePagination & {
setCurrentPage: (page: number) => void
}
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
as?: React.ReactElement<ElementProps>
children?: string | React.ReactNode
className?: string
dataTestId?: string
}
type PageButtonProps = ButtonProps & {
/**
* Provide a custom ReactElement (e.g. Next/Link)
*/
as?: React.ReactElement<ElementProps>
activeClassName?: string
inactiveClassName?: string
dataTestIdActive?: string
dataTestIdInactive?: string
renderExtraProps?: (pageNum: number) => {}
}
export type {
ButtonProps,
IPagination,
IPaginationProps,
IUsePagination,
PageButtonProps,
}

View File

@ -1,23 +1,17 @@
import { memo } from 'react'
import { IS_PROD } from '@/config'
import { headers } from '@/next/headers'
import Script from '@/next/script'
import { buildCreateAppAttributionBootstrapScript } from '@/utils/create-app-tracking'
const CreateAppAttributionBootstrap = async () => {
const nonce = IS_PROD ? (await headers()).get('x-nonce') ?? '' : ''
/* v8 ignore next -- `nonce` is always a string (`''` or header value), so nullish fallback is unreachable in runtime. @preserve */
const scriptNonce = nonce ?? undefined
export async function CreateAppAttributionBootstrap() {
const nonce = IS_PROD ? (await headers()).get('x-nonce') ?? undefined : undefined
return (
<Script
id="create-app-attribution-bootstrap"
strategy="beforeInteractive"
nonce={scriptNonce}
nonce={nonce}
>
{buildCreateAppAttributionBootstrapScript()}
</Script>
)
}
export default memo(CreateAppAttributionBootstrap)

View File

@ -1,5 +1,4 @@
import type { ReactNode } from 'react'
import type { Props as PaginationProps } from '@/app/components/base/pagination'
import type { SimpleDocumentDetail } from '@/models/datasets'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, fireEvent, render, screen } from '@testing-library/react'
@ -9,6 +8,14 @@ import DocumentList from '../../list'
const mockPush = vi.fn()
type PaginationProps = {
current: number
onChange: (page: number) => void
total: number
limit?: number
onLimitChange?: (limit: number) => void
}
vi.mock('@/next/navigation', () => ({
useRouter: () => ({
push: mockPush,

View File

@ -1,12 +1,11 @@
'use client'
import type { Props as PaginationProps } from '@/app/components/base/pagination'
import type { SimpleDocumentDetail } from '@/models/datasets'
import { Checkbox } from '@langgenius/dify-ui/checkbox'
import { CheckboxGroup } from '@langgenius/dify-ui/checkbox-group'
import { Pagination } from '@langgenius/dify-ui/pagination'
import { useBoolean } from 'ahooks'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Pagination from '@/app/components/base/pagination'
import EditMetadataBatchModal from '@/app/components/datasets/metadata/edit-metadata-batch/modal'
import useBatchEditDocumentMetadata from '@/app/components/datasets/metadata/hooks/use-batch-edit-document-metadata'
import { useDatasetDetailContextWithSelector as useDatasetDetailContext } from '@/context/dataset-detail'
@ -19,6 +18,15 @@ import RenameModal from './rename-modal'
type LocalDoc = SimpleDocumentDetail & { percent?: number }
type PaginationProps = {
className?: string
current: number
total: number
limit?: number
onChange: (page: number) => void
onLimitChange?: (limit: number) => void
}
type DocumentListProps = {
embeddingAvailable: boolean
documents: LocalDoc[]
@ -48,6 +56,8 @@ const DocumentList = ({
onSortChange,
}: DocumentListProps) => {
const { t } = useTranslation()
const pageSize = pagination.limit ?? 10
const totalPages = Math.max(Math.ceil(pagination.total / pageSize), 1)
const datasetConfig = useDatasetDetailContext(s => s.dataset)
const chunkingMode = datasetConfig?.doc_form
const isGeneralMode = chunkingMode !== ChunkingMode.parentChild
@ -198,8 +208,25 @@ const DocumentList = ({
{!!pagination.total && (
<Pagination
{...pagination}
className="w-full shrink-0"
className="shrink-0"
page={pagination.current + 1}
totalPages={totalPages}
onPageChange={page => pagination.onChange(page - 1)}
labels={{
previous: t('pagination.previous', { ns: 'common' }),
next: t('pagination.next', { ns: 'common' }),
editPageNumber: (page, totalPages) => t('pagination.editPageNumber', { ns: 'common', page, totalPages }),
pageNumberInput: t('pagination.pageNumber', { ns: 'common' }),
}}
pageSize={pagination.onLimitChange
? {
value: pageSize,
options: [10, 25, 50],
onValueChange: pagination.onLimitChange,
label: t('pagination.perPage', { ns: 'common' }),
ariaLabel: t('pagination.perPage', { ns: 'common' }),
}
: undefined}
/>
)}

View File

@ -203,18 +203,22 @@ vi.mock('@/app/components/base/divider', () => ({
default: () => <hr data-testid="divider" />,
}))
vi.mock('@/app/components/base/pagination', () => ({
default: ({ current, total, onChange, onLimitChange }: {
current: number
total: number
onChange: (page: number) => void
onLimitChange: (limit: number) => void
vi.mock('@langgenius/dify-ui/pagination', () => ({
Pagination: ({ page, totalPages, onPageChange, pageSize }: {
page: number
totalPages: number
onPageChange: (page: number) => void
pageSize?: {
onValueChange: (limit: number) => void
}
}) => (
<div data-testid="pagination">
<span data-testid="current-page">{current}</span>
<span data-testid="total-items">{total}</span>
<button data-testid="next-page" onClick={() => onChange(current + 1)}>Next</button>
<button data-testid="change-limit" onClick={() => onLimitChange(20)}>Change Limit</button>
<span data-testid="current-page">{page - 1}</span>
<span data-testid="total-pages">{totalPages}</span>
<button data-testid="next-page" onClick={() => onPageChange(page + 1)}>Next</button>
{pageSize && (
<button data-testid="change-limit" onClick={() => pageSize.onValueChange(20)}>Change Limit</button>
)}
</div>
),
}))
@ -1180,15 +1184,14 @@ describe('Inline callback and hook initialization coverage', () => {
})
})
// Covers paginationTotal in full-doc mode
it('should compute pagination total from child chunk data in full-doc mode', () => {
it('should compute pagination pages from child chunk data in full-doc mode', () => {
mockDocForm.current = ChunkingModeEnum.parentChild
mockParentMode.current = 'full-doc'
mockChildSegmentListData.total = 42
render(<Completed {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByTestId('total-items'))!.toHaveTextContent('42')
expect(screen.getByTestId('total-pages'))!.toHaveTextContent('5')
})
// Covers search input change

View File

@ -3,10 +3,10 @@ import type { FC } from 'react'
import type { SegmentListContextValue } from './segment-list-context'
import type { SegmentImportStatus } from '@/types/dataset'
import { CheckboxGroup } from '@langgenius/dify-ui/checkbox-group'
import { Pagination } from '@langgenius/dify-ui/pagination'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Divider from '@/app/components/base/divider'
import Pagination from '@/app/components/base/pagination'
import {
useChunkListAllKey,
useChunkListDisabledKey,
@ -142,10 +142,11 @@ const Completed: FC<ICompletedProps> = ({
return childSegmentDataHook.childChunkListData?.total || 0
return segmentListDataHook.segmentListData?.total || 0
}, [segmentListDataHook.isFullDocMode, childSegmentDataHook.childChunkListData, segmentListDataHook.segmentListData])
const totalPages = Math.max(Math.ceil(paginationTotal / limit), 1)
// Handle page change
const handlePageChange = useCallback((page: number) => {
setCurrentPage(page + 1)
setCurrentPage(page)
}, [])
// Context value
@ -225,12 +226,22 @@ const Completed: FC<ICompletedProps> = ({
{/* Pagination */}
<Divider type="horizontal" className="mx-6 my-0 h-px w-auto bg-divider-subtle" />
<Pagination
current={currentPage - 1}
onChange={handlePageChange}
total={paginationTotal}
limit={limit}
onLimitChange={setLimit}
className={segmentListDataHook.isFullDocMode ? 'px-3' : ''}
page={currentPage}
totalPages={totalPages}
onPageChange={handlePageChange}
labels={{
previous: t('pagination.previous', { ns: 'common' }),
next: t('pagination.next', { ns: 'common' }),
editPageNumber: (page, totalPages) => t('pagination.editPageNumber', { ns: 'common', page, totalPages }),
pageNumberInput: t('pagination.pageNumber', { ns: 'common' }),
}}
pageSize={{
value: limit,
options: [10, 25, 50],
onValueChange: setLimit,
label: t('pagination.perPage', { ns: 'common' }),
ariaLabel: t('pagination.perPage', { ns: 'common' }),
}}
/>
{/* Drawer Group - only render when docForm is available */}

View File

@ -18,6 +18,7 @@ import {
DrawerPortal,
DrawerViewport,
} from '@langgenius/dify-ui/drawer'
import { Pagination } from '@langgenius/dify-ui/pagination'
import { useBoolean } from 'ahooks'
import * as React from 'react'
import { useCallback, useEffect, useState } from 'react'
@ -25,7 +26,6 @@ import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import FloatRightContainer from '@/app/components/base/float-right-container'
import Loading from '@/app/components/base/loading'
import Pagination from '@/app/components/base/pagination'
import docStyle from '@/app/components/datasets/documents/detail/completed/style.module.css'
import DatasetDetailContext from '@/context/dataset-detail'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
@ -63,6 +63,7 @@ const HitTestingPage: FC<Props> = ({ datasetId }: Props) => {
const { data: recordsRes, refetch: recordsRefetch, isLoading: isRecordsLoading } = useDatasetTestingRecords(datasetId, { limit, page: currPage + 1 })
const total = recordsRes?.total || 0
const totalPages = total ? Math.max(Math.ceil(total / limit), 1) : 1
const { dataset: currentDataset } = useContext(DatasetDetailContext)
const isExternal = currentDataset?.provider === 'external'
@ -151,7 +152,19 @@ const HitTestingPage: FC<Props> = ({ datasetId }: Props) => {
<>
<Records records={recordsRes?.data} onClickRecord={handleClickRecord} />
{(total && total > limit)
? <Pagination current={currPage} onChange={setCurrPage} total={total} limit={limit} />
? (
<Pagination
page={currPage + 1}
totalPages={totalPages}
onPageChange={page => setCurrPage(page - 1)}
labels={{
previous: t('pagination.previous', { ns: 'common' }),
next: t('pagination.next', { ns: 'common' }),
editPageNumber: (page, totalPages) => t('pagination.editPageNumber', { ns: 'common', page, totalPages }),
pageNumberInput: t('pagination.pageNumber', { ns: 'common' }),
}}
/>
)
: null}
</>
)}

View File

@ -1,4 +1,4 @@
import type { ReactElement } from 'react'
import type { AnchorHTMLAttributes, ReactElement } from 'react'
import { fireEvent, screen } from '@testing-library/react'
import { vi } from 'vitest'
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
@ -55,7 +55,7 @@ vi.mock('@/context/workspace-context-provider', () => ({
}))
vi.mock('@/next/link', () => ({
default: ({ children, href }: { children?: React.ReactNode, href?: string }) => <a href={href}>{children}</a>,
default: ({ children, href, ...props }: AnchorHTMLAttributes<HTMLAnchorElement> & { href?: string }) => <a href={href} {...props}>{children}</a>,
}))
let mockIsWorkspaceEditor = false
@ -122,7 +122,9 @@ describe('Header', () => {
it('should render header with main nav components', () => {
renderHeader()
expect(screen.getByRole('img', { name: /dify logo/i })).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'Dify' })).toHaveAttribute('href', '/apps')
expect(screen.queryByRole('heading', { level: 1 })).not.toBeInTheDocument()
expect(screen.queryByRole('img', { name: /dify logo/i })).not.toBeInTheDocument()
expect(screen.getByTestId('workplace-selector')).toBeInTheDocument()
expect(screen.getByTestId('app-nav')).toBeInTheDocument()
expect(screen.getByTestId('account-dropdown')).toBeInTheDocument()
@ -166,7 +168,7 @@ describe('Header', () => {
mockMedia = 'mobile'
renderHeader()
expect(screen.getByRole('img', { name: /dify logo/i })).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'Dify' })).toHaveAttribute('href', '/apps')
expect(screen.queryByTestId('env-nav')).not.toBeInTheDocument()
})
@ -177,8 +179,8 @@ describe('Header', () => {
renderHeader()
expect(screen.getByText('Acme Workspace')).toBeInTheDocument()
expect(screen.getByRole('img', { name: /logo/i })).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'Acme Workspace' })).toHaveAttribute('href', '/apps')
expect(screen.queryByRole('img', { name: /logo/i })).not.toBeInTheDocument()
expect(screen.queryByRole('img', { name: /dify logo/i })).not.toBeInTheDocument()
})
@ -189,18 +191,18 @@ describe('Header', () => {
renderHeader()
expect(screen.getByText('Custom Title')).toBeInTheDocument()
expect(screen.getByRole('img', { name: /dify logo/i })).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'Custom Title' })).toHaveAttribute('href', '/apps')
expect(screen.queryByRole('img', { name: /dify logo/i })).not.toBeInTheDocument()
})
it('should show default Dify text when branding enabled but no application_title', () => {
it('should use default Dify link label when branding enabled but no application_title', () => {
mockBrandingEnabled = true
mockBrandingTitle = null
mockBrandingLogo = null
renderHeader()
expect(screen.getByText('Dify')).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'Dify' })).toHaveAttribute('href', '/apps')
})
it('should show dataset nav for editor who is not dataset operator', () => {

View File

@ -44,21 +44,23 @@ const Header = () => {
setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.BILLING })
}, [isFreePlan, setShowAccountSettingModal, setShowPricingModal])
const logoLabel = isBrandingEnabled && systemFeatures.branding.application_title ? systemFeatures.branding.application_title : 'Dify'
const renderLogo = () => (
<h1>
<Link href="/apps" className="flex h-8 shrink-0 items-center justify-center overflow-hidden px-0.5 indent-[-9999px] whitespace-nowrap">
{isBrandingEnabled && systemFeatures.branding.application_title ? systemFeatures.branding.application_title : 'Dify'}
{systemFeatures.branding.enabled && systemFeatures.branding.workspace_logo
? (
<img
src={systemFeatures.branding.workspace_logo}
className="block h-[22px] w-auto object-contain"
alt="logo"
/>
)
: <DifyLogo />}
</Link>
</h1>
<Link
href="/apps"
className="flex h-8 shrink-0 items-center justify-center overflow-hidden rounded-sm px-0.5 hover:opacity-80 focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
aria-label={logoLabel}
>
{systemFeatures.branding.enabled && systemFeatures.branding.workspace_logo
? (
<img
src={systemFeatures.branding.workspace_logo}
className="block h-[22px] w-auto object-contain"
alt=""
/>
)
: <DifyLogo alt="" />}
</Link>
)
if (isMobile) {

View File

@ -10,7 +10,7 @@ import { getDatasetMap } from '@/env'
import { getLocaleOnServer } from '@/i18n-config/server'
import { headers } from '@/next/headers'
import PartnerStackCookieRecorder from './components/billing/partner-stack/cookie-recorder'
import CreateAppAttributionBootstrap from './components/create-app-attribution-bootstrap'
import { CreateAppAttributionBootstrap } from './components/create-app-attribution-bootstrap'
import { AgentationLoader } from './components/devtools/agentation-loader'
import { ReactScanLoader } from './components/devtools/react-scan/loader'
import { I18nServerProvider } from './components/provider/i18n-server'
@ -51,11 +51,10 @@ const LocaleLayout = async ({
<meta name="msapplication-config" content="/browserconfig.xml" />
<CreateAppAttributionBootstrap />
{/* <ReactGrabLoader /> */}
<ReactScanLoader />
</head>
<body
className="h-full select-auto"
className="h-full"
{...datasetMap}
>
<div className="isolate h-full">

View File

@ -21,6 +21,15 @@ describe('Trigger', () => {
expect(screen.getByText('Backend')).toBeInTheDocument()
expect(screen.queryByText('common.tag.addTag')).not.toBeInTheDocument()
})
it('should preserve tag label casing for mixed-case tags', () => {
render(<TagTrigger tags={['Prod', 'prod']} />)
expect(screen.getByText('Prod')).toHaveClass('system-2xs-medium')
expect(screen.getByText('Prod')).not.toHaveClass('system-2xs-medium-uppercase')
expect(screen.getByText('prod')).toHaveClass('system-2xs-medium')
expect(screen.getByText('prod')).not.toHaveClass('system-2xs-medium-uppercase')
})
})
// Prop-driven rendering updates.

View File

@ -34,7 +34,7 @@ export const TagTrigger = ({
className="flex max-w-30 min-w-0 shrink-0 items-center gap-x-0.5 rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-1.25 py-0.75"
>
<span aria-hidden="true" className="i-ri-price-tag-3-line size-3 shrink-0 text-text-quaternary" />
<div className="truncate system-2xs-medium-uppercase text-text-tertiary">
<div className="truncate system-2xs-medium text-text-tertiary">
{content}
</div>
</div>

View File

@ -194,6 +194,7 @@
"imageInput.browse": "تصفح",
"imageInput.dropImageHere": "أسقط صورتك هنا، أو",
"imageInput.supportedFormats": "يدعم PNG و JPG و JPEG و WEBP و GIF",
"imageUploader.imageList": "قائمة الصور",
"imageUploader.imageUpload": "تحميل الصورة",
"imageUploader.pasteImageLink": "لصق رابط الصورة",
"imageUploader.pasteImageLinkInputPlaceholder": "لصق رابط الصورة هنا",
@ -512,6 +513,8 @@
"operation.ok": "موافق",
"operation.openInNewTab": "فتح في علامة تبويب جديدة",
"operation.params": "معلمات",
"operation.pause": "إيقاف مؤقت",
"operation.play": "تشغيل",
"operation.refresh": "إعادة تشغيل",
"operation.regenerate": "إعادة إنشاء",
"operation.reload": "إعادة تحميل",
@ -519,6 +522,7 @@
"operation.rename": "إعادة تسمية",
"operation.reset": "إعادة تعيين",
"operation.resetKeywords": "إعادة تعيين الكلمات الرئيسية",
"operation.retry": "إعادة المحاولة",
"operation.save": "حفظ",
"operation.saveAndEnable": "حفظ وتمكين",
"operation.saveAndRegenerate": "حفظ وإعادة إنشاء القطع الفرعية",
@ -533,13 +537,19 @@
"operation.skip": "تخطي",
"operation.submit": "إرسال",
"operation.sure": "أنا متأكد",
"operation.toggleFullscreen": "تبديل ملء الشاشة",
"operation.toggleMute": "تبديل كتم الصوت",
"operation.view": "عرض",
"operation.viewDetails": "عرض التفاصيل",
"operation.viewMore": "عرض المزيد",
"operation.yes": "نعم",
"operation.zoomIn": "تكبير",
"operation.zoomOut": "تصغير",
"pagination.editPageNumber": "تعديل رقم الصفحة، الصفحة الحالية {{page}} من {{totalPages}}",
"pagination.next": "Next page",
"pagination.pageNumber": "Page number",
"pagination.perPage": "عناصر لكل صفحة",
"pagination.previous": "Previous page",
"placeholder.input": "يرجى الإدخال",
"placeholder.search": "بحث...",
"placeholder.select": "يرجى التحديد",
@ -677,5 +687,6 @@
"voiceInput.converting": "التحويل إلى نص...",
"voiceInput.notAllow": "الميكروفون غير مصرح به",
"voiceInput.speaking": "تحدث الآن...",
"voiceInput.start": "إدخال صوتي",
"you": "أنت"
}

View File

@ -194,6 +194,7 @@
"imageInput.browse": "blättern",
"imageInput.dropImageHere": "Laden Sie Ihr Bild hierher hoch oder",
"imageInput.supportedFormats": "Unterstützt PNG, JPG, JPEG, WEBP und GIF",
"imageUploader.imageList": "Bilderliste",
"imageUploader.imageUpload": "Bild-Upload",
"imageUploader.pasteImageLink": "Bildlink einfügen",
"imageUploader.pasteImageLinkInputPlaceholder": "Bildlink hier einfügen",
@ -512,6 +513,8 @@
"operation.ok": "OK",
"operation.openInNewTab": "In neuem Tab öffnen",
"operation.params": "Parameter",
"operation.pause": "Pausieren",
"operation.play": "Abspielen",
"operation.refresh": "Neustart",
"operation.regenerate": "Erneuern",
"operation.reload": "Neu laden",
@ -519,6 +522,7 @@
"operation.rename": "Umbenennen",
"operation.reset": "Zurücksetzen",
"operation.resetKeywords": "Schlüsselwörter zurücksetzen",
"operation.retry": "Erneut versuchen",
"operation.save": "Speichern",
"operation.saveAndEnable": "Speichern und Aktivieren",
"operation.saveAndRegenerate": "Speichern und Regenerieren von untergeordneten Chunks",
@ -533,13 +537,19 @@
"operation.skip": "Schiff",
"operation.submit": "Senden",
"operation.sure": "Ich bin sicher",
"operation.toggleFullscreen": "Vollbild umschalten",
"operation.toggleMute": "Stummschaltung umschalten",
"operation.view": "Ansehen",
"operation.viewDetails": "Details anzeigen",
"operation.viewMore": "MEHR SEHEN",
"operation.yes": "Ja",
"operation.zoomIn": "Vergrößern",
"operation.zoomOut": "Verkleinern",
"pagination.editPageNumber": "Seitennummer bearbeiten, aktuelle Seite {{page}} von {{totalPages}}",
"pagination.next": "Next page",
"pagination.pageNumber": "Page number",
"pagination.perPage": "Artikel pro Seite",
"pagination.previous": "Previous page",
"placeholder.input": "Bitte eingeben",
"placeholder.search": "Suchen...",
"placeholder.select": "Bitte auswählen",
@ -677,5 +687,6 @@
"voiceInput.converting": "Umwandlung in Text...",
"voiceInput.notAllow": "Mikrofon nicht autorisiert",
"voiceInput.speaking": "Sprechen Sie jetzt...",
"voiceInput.start": "Spracheingabe",
"you": "Du"
}

View File

@ -545,7 +545,11 @@
"operation.yes": "Yes",
"operation.zoomIn": "Zoom In",
"operation.zoomOut": "Zoom Out",
"pagination.editPageNumber": "Edit page number, current page {{page}} of {{totalPages}}",
"pagination.next": "Next page",
"pagination.pageNumber": "Page number",
"pagination.perPage": "Items per page",
"pagination.previous": "Previous page",
"placeholder.input": "Please enter",
"placeholder.search": "Search...",
"placeholder.select": "Please select",

View File

@ -194,6 +194,7 @@
"imageInput.browse": "navegar",
"imageInput.dropImageHere": "Deja tu imagen aquí, o",
"imageInput.supportedFormats": "Soporta PNG, JPG, JPEG, WEBP y GIF",
"imageUploader.imageList": "Lista de imágenes",
"imageUploader.imageUpload": "Carga de Imagen",
"imageUploader.pasteImageLink": "Pegar enlace de imagen",
"imageUploader.pasteImageLinkInputPlaceholder": "Pega el enlace de imagen aquí",
@ -512,6 +513,8 @@
"operation.ok": "OK",
"operation.openInNewTab": "Abrir en una nueva pestaña",
"operation.params": "Parámetros",
"operation.pause": "Pausar",
"operation.play": "Reproducir",
"operation.refresh": "Reiniciar",
"operation.regenerate": "Regenerar",
"operation.reload": "Recargar",
@ -519,6 +522,7 @@
"operation.rename": "Renombrar",
"operation.reset": "Restablecer",
"operation.resetKeywords": "Restablecer palabras clave",
"operation.retry": "Reintentar",
"operation.save": "Guardar",
"operation.saveAndEnable": "Guardar y habilitar",
"operation.saveAndRegenerate": "Guardar y regenerar fragmentos secundarios",
@ -533,13 +537,19 @@
"operation.skip": "Navío",
"operation.submit": "Enviar",
"operation.sure": "Estoy seguro",
"operation.toggleFullscreen": "Alternar pantalla completa",
"operation.toggleMute": "Alternar silencio",
"operation.view": "Vista",
"operation.viewDetails": "Ver detalles",
"operation.viewMore": "VER MÁS",
"operation.yes": "Sí",
"operation.zoomIn": "Acercar",
"operation.zoomOut": "Alejar",
"pagination.editPageNumber": "Editar número de página, página actual {{page}} de {{totalPages}}",
"pagination.next": "Next page",
"pagination.pageNumber": "Page number",
"pagination.perPage": "Elementos por página",
"pagination.previous": "Previous page",
"placeholder.input": "Por favor ingresa",
"placeholder.search": "Buscar...",
"placeholder.select": "Por favor selecciona",
@ -677,5 +687,6 @@
"voiceInput.converting": "Convirtiendo a texto...",
"voiceInput.notAllow": "micrófono no autorizado",
"voiceInput.speaking": "Habla ahora...",
"voiceInput.start": "Entrada de voz",
"you": "Tú"
}

View File

@ -194,6 +194,7 @@
"imageInput.browse": "مرورگر",
"imageInput.dropImageHere": "عکس خود را اینجا رها کنید، یا",
"imageInput.supportedFormats": "از فرمت‌های PNG، JPG، JPEG، WEBP و GIF پشتیبانی می‌کند",
"imageUploader.imageList": "فهرست تصاویر",
"imageUploader.imageUpload": "بارگذاری تصویر",
"imageUploader.pasteImageLink": "پیوند تصویر را بچسبانید",
"imageUploader.pasteImageLinkInputPlaceholder": "پیوند تصویر را اینجا بچسبانید",
@ -512,6 +513,8 @@
"operation.ok": "تایید",
"operation.openInNewTab": "باز کردن در برگه جدید",
"operation.params": "پارامترها",
"operation.pause": "مکث",
"operation.play": "پخش",
"operation.refresh": "شروع مجدد",
"operation.regenerate": "بازسازی",
"operation.reload": "بارگذاری مجدد",
@ -519,6 +522,7 @@
"operation.rename": "تغییر نام",
"operation.reset": "بازنشانی",
"operation.resetKeywords": "بازنشانی کلمات کلیدی",
"operation.retry": "تلاش دوباره",
"operation.save": "ذخیره",
"operation.saveAndEnable": "ذخیره و فعال سازی",
"operation.saveAndRegenerate": "ذخیره و بازسازی تکه های فرزند",
@ -533,13 +537,19 @@
"operation.skip": "کشتی",
"operation.submit": "ارسال",
"operation.sure": "مطمئن هستم",
"operation.toggleFullscreen": "تغییر حالت تمام‌صفحه",
"operation.toggleMute": "تغییر حالت بی‌صدا",
"operation.view": "مشاهده",
"operation.viewDetails": "دیدن جزئیات",
"operation.viewMore": "بیشتر ببینید",
"operation.yes": "بله",
"operation.zoomIn": "بزرگنمایی",
"operation.zoomOut": "کوچک نمایی",
"pagination.editPageNumber": "ویرایش شماره صفحه، صفحه فعلی {{page}} از {{totalPages}}",
"pagination.next": "Next page",
"pagination.pageNumber": "Page number",
"pagination.perPage": "موارد در هر صفحه",
"pagination.previous": "Previous page",
"placeholder.input": "لطفا وارد کنید",
"placeholder.search": "جستجو...",
"placeholder.select": "لطفا انتخاب کنید",
@ -677,5 +687,6 @@
"voiceInput.converting": "در حال تبدیل به متن...",
"voiceInput.notAllow": "میکروفون مجاز نیست",
"voiceInput.speaking": "اکنون صحبت کنید...",
"voiceInput.start": "ورودی صوتی",
"you": "تو"
}

View File

@ -194,6 +194,7 @@
"imageInput.browse": "naviguer",
"imageInput.dropImageHere": "Déposez votre image ici, ou",
"imageInput.supportedFormats": "Prend en charge PNG, JPG, JPEG, WEBP et GIF",
"imageUploader.imageList": "Liste des images",
"imageUploader.imageUpload": "Téléchargement d'image",
"imageUploader.pasteImageLink": "Collez le lien de l'image",
"imageUploader.pasteImageLinkInputPlaceholder": "Collez le lien de l'image ici",
@ -512,6 +513,8 @@
"operation.ok": "D'accord",
"operation.openInNewTab": "Ouvrir dans un nouvel onglet",
"operation.params": "Paramètres",
"operation.pause": "Pause",
"operation.play": "Lire",
"operation.refresh": "Redémarrer",
"operation.regenerate": "Régénérer",
"operation.reload": "Recharger",
@ -519,6 +522,7 @@
"operation.rename": "Renommer",
"operation.reset": "Réinitialiser",
"operation.resetKeywords": "Réinitialiser les mots-clés",
"operation.retry": "Réessayer",
"operation.save": "Enregistrer",
"operation.saveAndEnable": "Enregistrer et Activer",
"operation.saveAndRegenerate": "Enregistrer et régénérer des morceaux enfants",
@ -533,13 +537,19 @@
"operation.skip": "Bateau",
"operation.submit": "Envoyer",
"operation.sure": "Je suis sûr",
"operation.toggleFullscreen": "Basculer en plein écran",
"operation.toggleMute": "Activer/désactiver le son",
"operation.view": "Vue",
"operation.viewDetails": "Voir les détails",
"operation.viewMore": "VOIR PLUS",
"operation.yes": "Oui",
"operation.zoomIn": "Zoom avant",
"operation.zoomOut": "Zoom arrière",
"pagination.editPageNumber": "Modifier le numéro de page, page actuelle {{page}} sur {{totalPages}}",
"pagination.next": "Next page",
"pagination.pageNumber": "Page number",
"pagination.perPage": "Articles par page",
"pagination.previous": "Previous page",
"placeholder.input": "Veuillez entrer",
"placeholder.search": "Rechercher...",
"placeholder.select": "Veuillez sélectionner",
@ -677,5 +687,6 @@
"voiceInput.converting": "Conversion en texte...",
"voiceInput.notAllow": "microphone non autorisé",
"voiceInput.speaking": "Parle maintenant...",
"voiceInput.start": "Saisie vocale",
"you": "Vous"
}

View File

@ -194,6 +194,7 @@
"imageInput.browse": "ब्राउज़ करें",
"imageInput.dropImageHere": "अपनी छवि यहाँ छोड़ें, या",
"imageInput.supportedFormats": "PNG, JPG, JPEG, WEBP और GIF का समर्थन करता है",
"imageUploader.imageList": "छवि सूची",
"imageUploader.imageUpload": "छवि अपलोड",
"imageUploader.pasteImageLink": "छवि लिंक पेस्ट करें",
"imageUploader.pasteImageLinkInputPlaceholder": "छवि लिंक यहाँ पेस्ट करें",
@ -512,6 +513,8 @@
"operation.ok": "ठीक है",
"operation.openInNewTab": "नए टैब में खोलें",
"operation.params": "पैरामीटर",
"operation.pause": "रोकें",
"operation.play": "चलाएं",
"operation.refresh": "पुनः प्रारंभ करें",
"operation.regenerate": "पुनर्जन्म",
"operation.reload": "पुनः लोड करें",
@ -519,6 +522,7 @@
"operation.rename": "नाम बदलें",
"operation.reset": "रीसेट करें",
"operation.resetKeywords": "कीवर्ड रीसेट करें",
"operation.retry": "पुनः प्रयास करें",
"operation.save": "सहेजें",
"operation.saveAndEnable": "सहेजें और सक्षम करें",
"operation.saveAndRegenerate": "सहेजें और पुन: उत्पन्न करें बाल विखंडू",
@ -533,13 +537,19 @@
"operation.skip": "जहाज़",
"operation.submit": "जमा करें",
"operation.sure": "मुझे यकीन है",
"operation.toggleFullscreen": "फ़ुलस्क्रीन टॉगल करें",
"operation.toggleMute": "म्यूट टॉगल करें",
"operation.view": "देखना",
"operation.viewDetails": "विवरण देखें",
"operation.viewMore": "और देखें",
"operation.yes": "हाँ",
"operation.zoomIn": "ज़ूम इन करें",
"operation.zoomOut": "ज़ूम आउट करें",
"pagination.editPageNumber": "पृष्ठ संख्या संपादित करें, वर्तमान पृष्ठ {{page}} / {{totalPages}}",
"pagination.next": "Next page",
"pagination.pageNumber": "Page number",
"pagination.perPage": "प्रति पृष्ठ आइटम",
"pagination.previous": "Previous page",
"placeholder.input": "कृपया दर्ज करें",
"placeholder.search": "खोजें...",
"placeholder.select": "कृपया चयन करें",
@ -677,5 +687,6 @@
"voiceInput.converting": "पाठ में परिवर्तित हो रहा है...",
"voiceInput.notAllow": "माइक्रोफोन अधिकृत नहीं है",
"voiceInput.speaking": "अब बोलें...",
"voiceInput.start": "वॉइस इनपुट",
"you": "आप"
}

View File

@ -194,6 +194,7 @@
"imageInput.browse": "Telusuri",
"imageInput.dropImageHere": "Letakkan gambar Anda di sini, atau",
"imageInput.supportedFormats": "Mendukung PNG, JPG, JPEG, WEBP dan GIF",
"imageUploader.imageList": "Daftar gambar",
"imageUploader.imageUpload": "Unggah Gambar",
"imageUploader.pasteImageLink": "Tempel tautan gambar",
"imageUploader.pasteImageLinkInputPlaceholder": "Tempel tautan gambar di sini",
@ -512,6 +513,8 @@
"operation.ok": "OKE",
"operation.openInNewTab": "Buka di tab baru",
"operation.params": "Parameter",
"operation.pause": "Jeda",
"operation.play": "Putar",
"operation.refresh": "Segarkan",
"operation.regenerate": "Regenerasi",
"operation.reload": "Muat Ulang",
@ -519,6 +522,7 @@
"operation.rename": "Ubah nama",
"operation.reset": "Reset",
"operation.resetKeywords": "Atur ulang kata kunci",
"operation.retry": "Coba lagi",
"operation.save": "Simpan",
"operation.saveAndEnable": "Simpan & Aktifkan",
"operation.saveAndRegenerate": "Simpan & Buat Ulang Potongan Anak",
@ -533,13 +537,19 @@
"operation.skip": "Lewat",
"operation.submit": "Kirim",
"operation.sure": "Saya yakin",
"operation.toggleFullscreen": "Alihkan layar penuh",
"operation.toggleMute": "Alihkan bisu",
"operation.view": "Lihat",
"operation.viewDetails": "Lihat Detail",
"operation.viewMore": "LIHAT LEBIH BANYAK",
"operation.yes": "Ya",
"operation.zoomIn": "Perbesar",
"operation.zoomOut": "Perkecil",
"pagination.editPageNumber": "Edit nomor halaman, halaman saat ini {{page}} dari {{totalPages}}",
"pagination.next": "Next page",
"pagination.pageNumber": "Page number",
"pagination.perPage": "Item per halaman",
"pagination.previous": "Previous page",
"placeholder.input": "Silakan masuk",
"placeholder.search": "Cari...",
"placeholder.select": "Silakan pilih",
@ -677,5 +687,6 @@
"voiceInput.converting": "Mengonversi ke teks...",
"voiceInput.notAllow": "mikrofon tidak diizinkan",
"voiceInput.speaking": "Bicaralah sekarang...",
"voiceInput.start": "Input suara",
"you": "Kamu"
}

View File

@ -194,6 +194,7 @@
"imageInput.browse": "sfogliare",
"imageInput.dropImageHere": "Trascina la tua immagine qui, oppure",
"imageInput.supportedFormats": "Supporta PNG, JPG, JPEG, WEBP e GIF",
"imageUploader.imageList": "Elenco immagini",
"imageUploader.imageUpload": "Caricamento Immagine",
"imageUploader.pasteImageLink": "Incolla link immagine",
"imageUploader.pasteImageLinkInputPlaceholder": "Incolla qui il link immagine",
@ -512,6 +513,8 @@
"operation.ok": "OK",
"operation.openInNewTab": "Apri in una nuova scheda",
"operation.params": "Parametri",
"operation.pause": "Pausa",
"operation.play": "Riproduci",
"operation.refresh": "Riavvia",
"operation.regenerate": "Rigenerare",
"operation.reload": "Ricarica",
@ -519,6 +522,7 @@
"operation.rename": "Rinomina",
"operation.reset": "Reimposta",
"operation.resetKeywords": "Reimposta parole chiave",
"operation.retry": "Riprova",
"operation.save": "Salva",
"operation.saveAndEnable": "Salva & Abilita",
"operation.saveAndRegenerate": "Salva e rigenera i blocchi figlio",
@ -533,13 +537,19 @@
"operation.skip": "Nave",
"operation.submit": "Invia",
"operation.sure": "Sono sicuro",
"operation.toggleFullscreen": "Attiva/disattiva schermo intero",
"operation.toggleMute": "Attiva/disattiva muto",
"operation.view": "Vista",
"operation.viewDetails": "Visualizza dettagli",
"operation.viewMore": "SCOPRI DI PIÙ",
"operation.yes": "Sì",
"operation.zoomIn": "Ingrandisci",
"operation.zoomOut": "Zoom indietro",
"pagination.editPageNumber": "Modifica numero pagina, pagina corrente {{page}} di {{totalPages}}",
"pagination.next": "Next page",
"pagination.pageNumber": "Page number",
"pagination.perPage": "Articoli per pagina",
"pagination.previous": "Previous page",
"placeholder.input": "Per favore inserisci",
"placeholder.search": "Cerca...",
"placeholder.select": "Per favore seleziona",
@ -677,5 +687,6 @@
"voiceInput.converting": "Conversione in testo...",
"voiceInput.notAllow": "microfono non autorizzato",
"voiceInput.speaking": "Parla ora...",
"voiceInput.start": "Input vocale",
"you": "Tu"
}

View File

@ -194,6 +194,7 @@
"imageInput.browse": "ブラウズする",
"imageInput.dropImageHere": "ここに画像をドロップするか、",
"imageInput.supportedFormats": "PNG、JPG、JPEG、WEBP、および GIF をサポートしています。",
"imageUploader.imageList": "画像リスト",
"imageUploader.imageUpload": "画像アップロード",
"imageUploader.pasteImageLink": "画像リンクを貼り付ける",
"imageUploader.pasteImageLinkInputPlaceholder": "ここに画像リンクを貼り付けてください",
@ -512,6 +513,8 @@
"operation.ok": "OK",
"operation.openInNewTab": "新しいタブで開く",
"operation.params": "パラメータ",
"operation.pause": "一時停止",
"operation.play": "再生",
"operation.refresh": "リフレッシュ",
"operation.regenerate": "再生成",
"operation.reload": "再読み込み",
@ -519,6 +522,7 @@
"operation.rename": "名前の変更",
"operation.reset": "リセット",
"operation.resetKeywords": "キーワードをリセット",
"operation.retry": "再試行",
"operation.save": "保存",
"operation.saveAndEnable": "保存 & 有効に",
"operation.saveAndRegenerate": "保存して子チャンクを再生成",
@ -533,13 +537,19 @@
"operation.skip": "スキップ",
"operation.submit": "送信",
"operation.sure": "確認済み",
"operation.toggleFullscreen": "全画面表示を切り替え",
"operation.toggleMute": "ミュートを切り替え",
"operation.view": "表示",
"operation.viewDetails": "詳細を見る",
"operation.viewMore": "さらに表示",
"operation.yes": "はい",
"operation.zoomIn": "ズームインする",
"operation.zoomOut": "ズームアウト",
"pagination.editPageNumber": "ページ番号を編集、現在のページ {{page}} / {{totalPages}}",
"pagination.next": "Next page",
"pagination.pageNumber": "Page number",
"pagination.perPage": "ページあたりのアイテム数",
"pagination.previous": "Previous page",
"placeholder.input": "入力してください",
"placeholder.search": "検索...",
"placeholder.select": "選択してください",
@ -677,5 +687,6 @@
"voiceInput.converting": "テキストに変換中...",
"voiceInput.notAllow": "マイクが許可されていません",
"voiceInput.speaking": "今話しています...",
"voiceInput.start": "音声入力",
"you": "あなた"
}

View File

@ -194,6 +194,7 @@
"imageInput.browse": "찾아보기",
"imageInput.dropImageHere": "여기에 이미지를 드롭하거나",
"imageInput.supportedFormats": "PNG, JPG, JPEG, WEBP 및 GIF 를 지원합니다.",
"imageUploader.imageList": "이미지 목록",
"imageUploader.imageUpload": "이미지 업로드",
"imageUploader.pasteImageLink": "이미지 링크 붙여넣기",
"imageUploader.pasteImageLinkInputPlaceholder": "여기에 이미지 링크를 붙여넣으세요",
@ -512,6 +513,8 @@
"operation.ok": "확인",
"operation.openInNewTab": "새 탭에서 열기",
"operation.params": "매개변수",
"operation.pause": "일시 중지",
"operation.play": "재생",
"operation.refresh": "새로 고침",
"operation.regenerate": "재생성",
"operation.reload": "다시 불러오기",
@ -519,6 +522,7 @@
"operation.rename": "이름 바꾸기",
"operation.reset": "초기화",
"operation.resetKeywords": "키워드 재설정",
"operation.retry": "다시 시도",
"operation.save": "저장",
"operation.saveAndEnable": "저장 및 활성화",
"operation.saveAndRegenerate": "저장 및 자식 청크 재생성",
@ -533,13 +537,19 @@
"operation.skip": "건너뛰기",
"operation.submit": "전송",
"operation.sure": "확인",
"operation.toggleFullscreen": "전체 화면 전환",
"operation.toggleMute": "음소거 전환",
"operation.view": "보기",
"operation.viewDetails": "세부 정보보기",
"operation.viewMore": "더보기",
"operation.yes": "네",
"operation.zoomIn": "확대",
"operation.zoomOut": "축소",
"pagination.editPageNumber": "페이지 번호 편집, 현재 {{page}} / {{totalPages}} 페이지",
"pagination.next": "Next page",
"pagination.pageNumber": "Page number",
"pagination.perPage": "페이지당 항목 수",
"pagination.previous": "Previous page",
"placeholder.input": "입력해주세요",
"placeholder.search": "검색...",
"placeholder.select": "선택해주세요",
@ -677,5 +687,6 @@
"voiceInput.converting": "텍스트로 변환 중...",
"voiceInput.notAllow": "마이크가 허용되지 않았습니다",
"voiceInput.speaking": "지금 말하고 있습니다...",
"voiceInput.start": "음성 입력",
"you": "나"
}

View File

@ -194,6 +194,7 @@
"imageInput.browse": "browse",
"imageInput.dropImageHere": "Drop your image here, or",
"imageInput.supportedFormats": "Supports PNG, JPG, JPEG, WEBP and GIF",
"imageUploader.imageList": "Afbeeldingenlijst",
"imageUploader.imageUpload": "Image Upload",
"imageUploader.pasteImageLink": "Paste image link",
"imageUploader.pasteImageLinkInputPlaceholder": "Paste image link here",
@ -512,6 +513,8 @@
"operation.ok": "OK",
"operation.openInNewTab": "Open in new tab",
"operation.params": "Params",
"operation.pause": "Pauzeren",
"operation.play": "Afspelen",
"operation.refresh": "Restart",
"operation.regenerate": "Regenerate",
"operation.reload": "Reload",
@ -519,6 +522,7 @@
"operation.rename": "Rename",
"operation.reset": "Reset",
"operation.resetKeywords": "Reset keywords",
"operation.retry": "Opnieuw proberen",
"operation.save": "Save",
"operation.saveAndEnable": "Save & Enable",
"operation.saveAndRegenerate": "Save & Regenerate Child Chunks",
@ -533,13 +537,19 @@
"operation.skip": "Skip",
"operation.submit": "Submit",
"operation.sure": "I'm sure",
"operation.toggleFullscreen": "Volledig scherm schakelen",
"operation.toggleMute": "Dempen schakelen",
"operation.view": "View",
"operation.viewDetails": "View Details",
"operation.viewMore": "VIEW MORE",
"operation.yes": "Yes",
"operation.zoomIn": "Zoom In",
"operation.zoomOut": "Zoom Out",
"pagination.editPageNumber": "Paginanummer bewerken, huidige pagina {{page}} van {{totalPages}}",
"pagination.next": "Next page",
"pagination.pageNumber": "Page number",
"pagination.perPage": "Items per page",
"pagination.previous": "Previous page",
"placeholder.input": "Please enter",
"placeholder.search": "Search...",
"placeholder.select": "Please select",
@ -677,5 +687,6 @@
"voiceInput.converting": "Converting to text...",
"voiceInput.notAllow": "microphone not authorized",
"voiceInput.speaking": "Speak now...",
"voiceInput.start": "Spraakinvoer",
"you": "You"
}

View File

@ -194,6 +194,7 @@
"imageInput.browse": "przeglądaj",
"imageInput.dropImageHere": "Upuść swój obraz tutaj, lub",
"imageInput.supportedFormats": "Obsługuje PNG, JPG, JPEG, WEBP i GIF",
"imageUploader.imageList": "Lista obrazów",
"imageUploader.imageUpload": "Przesyłanie obrazu",
"imageUploader.pasteImageLink": "Wklej link do obrazu",
"imageUploader.pasteImageLinkInputPlaceholder": "Wklej tutaj link do obrazu",
@ -512,6 +513,8 @@
"operation.ok": "OK",
"operation.openInNewTab": "Otwórz w nowej karcie",
"operation.params": "Parametry",
"operation.pause": "Pauza",
"operation.play": "Odtwórz",
"operation.refresh": "Odśwież",
"operation.regenerate": "Ponownie wygenerować",
"operation.reload": "Przeładuj",
@ -519,6 +522,7 @@
"operation.rename": "Zmień nazwę",
"operation.reset": "Resetuj",
"operation.resetKeywords": "Resetuj słowa kluczowe",
"operation.retry": "Spróbuj ponownie",
"operation.save": "Zapisz",
"operation.saveAndEnable": "Zapisz i Włącz",
"operation.saveAndRegenerate": "Zapisywanie i regeneracja fragmentów podrzędnych",
@ -533,13 +537,19 @@
"operation.skip": "Statek",
"operation.submit": "Prześlij",
"operation.sure": "Jestem pewien",
"operation.toggleFullscreen": "Przełącz pełny ekran",
"operation.toggleMute": "Przełącz wyciszenie",
"operation.view": "Widok",
"operation.viewDetails": "Wyświetl szczegóły",
"operation.viewMore": "ZOBACZ WIĘCEJ",
"operation.yes": "Tak",
"operation.zoomIn": "Powiększenie",
"operation.zoomOut": "Pomniejszanie",
"pagination.editPageNumber": "Edytuj numer strony, bieżąca strona {{page}} z {{totalPages}}",
"pagination.next": "Next page",
"pagination.pageNumber": "Page number",
"pagination.perPage": "Ilość elementów na stronie",
"pagination.previous": "Previous page",
"placeholder.input": "Proszę wprowadzić",
"placeholder.search": "Szukaj...",
"placeholder.select": "Proszę wybrać",
@ -677,5 +687,6 @@
"voiceInput.converting": "Konwertowanie na tekst...",
"voiceInput.notAllow": "mikrofon nieautoryzowany",
"voiceInput.speaking": "Mów teraz...",
"voiceInput.start": "Wprowadzanie głosowe",
"you": "Ty"
}

View File

@ -194,6 +194,7 @@
"imageInput.browse": "navegar",
"imageInput.dropImageHere": "Arraste sua imagem aqui, ou",
"imageInput.supportedFormats": "Suporta PNG, JPG, JPEG, WEBP e GIF",
"imageUploader.imageList": "Lista de imagens",
"imageUploader.imageUpload": "Enviar Imagem",
"imageUploader.pasteImageLink": "Colar link da imagem",
"imageUploader.pasteImageLinkInputPlaceholder": "Cole o link da imagem aqui",
@ -512,6 +513,8 @@
"operation.ok": "OK",
"operation.openInNewTab": "Abrir em nova guia",
"operation.params": "Parâmetros",
"operation.pause": "Pausar",
"operation.play": "Reproduzir",
"operation.refresh": "Reiniciar",
"operation.regenerate": "Regenerar",
"operation.reload": "Recarregar",
@ -519,6 +522,7 @@
"operation.rename": "Renomear",
"operation.reset": "Redefinir",
"operation.resetKeywords": "Redefinir palavras-chave",
"operation.retry": "Tentar novamente",
"operation.save": "Salvar",
"operation.saveAndEnable": "Salvar e Ativar",
"operation.saveAndRegenerate": "Salvar e regenerar pedaços filhos",
@ -533,13 +537,19 @@
"operation.skip": "Navio",
"operation.submit": "Enviar",
"operation.sure": "Tenho certeza",
"operation.toggleFullscreen": "Alternar tela cheia",
"operation.toggleMute": "Alternar mudo",
"operation.view": "Vista",
"operation.viewDetails": "Ver detalhes",
"operation.viewMore": "VER MAIS",
"operation.yes": "Sim",
"operation.zoomIn": "Ampliar",
"operation.zoomOut": "Diminuir o zoom",
"pagination.editPageNumber": "Editar número da página, página atual {{page}} de {{totalPages}}",
"pagination.next": "Next page",
"pagination.pageNumber": "Page number",
"pagination.perPage": "Itens por página",
"pagination.previous": "Previous page",
"placeholder.input": "Por favor, insira",
"placeholder.search": "Pesquisar...",
"placeholder.select": "Por favor, selecione",
@ -677,5 +687,6 @@
"voiceInput.converting": "Convertendo para texto...",
"voiceInput.notAllow": "microfone não autorizado",
"voiceInput.speaking": "Fale agora...",
"voiceInput.start": "Entrada por voz",
"you": "Você"
}

View File

@ -194,6 +194,7 @@
"imageInput.browse": "naviga",
"imageInput.dropImageHere": "Trageți imaginea aici sau",
"imageInput.supportedFormats": "Suportă PNG, JPG, JPEG, WEBP și GIF",
"imageUploader.imageList": "Listă de imagini",
"imageUploader.imageUpload": "Încărcare imagine",
"imageUploader.pasteImageLink": "Inserați link-ul imaginii",
"imageUploader.pasteImageLinkInputPlaceholder": "Inserați link-ul imaginii aici",
@ -512,6 +513,8 @@
"operation.ok": "OK",
"operation.openInNewTab": "Deschide într-o filă nouă",
"operation.params": "Parametri",
"operation.pause": "Pauză",
"operation.play": "Redare",
"operation.refresh": "Reîncarcă",
"operation.regenerate": "Regenera",
"operation.reload": "Reîncarcă",
@ -519,6 +522,7 @@
"operation.rename": "Redenumește",
"operation.reset": "Resetează",
"operation.resetKeywords": "Resetează cuvintele cheie",
"operation.retry": "Reîncercați",
"operation.save": "Salvează",
"operation.saveAndEnable": "Salvează și Activează",
"operation.saveAndRegenerate": "Salvați și regenerați bucățile secundare",
@ -533,13 +537,19 @@
"operation.skip": "Navă",
"operation.submit": "Prezinte",
"operation.sure": "Sunt sigur",
"operation.toggleFullscreen": "Comută ecran complet",
"operation.toggleMute": "Comută sunetul",
"operation.view": "Vedere",
"operation.viewDetails": "Vezi detalii",
"operation.viewMore": "VEZI MAI MULT",
"operation.yes": "Da",
"operation.zoomIn": "Măriți",
"operation.zoomOut": "Micșorare",
"pagination.editPageNumber": "Editați numărul paginii, pagina curentă {{page}} din {{totalPages}}",
"pagination.next": "Next page",
"pagination.pageNumber": "Page number",
"pagination.perPage": "Articole pe pagină",
"pagination.previous": "Previous page",
"placeholder.input": "Vă rugăm să introduceți",
"placeholder.search": "Caută...",
"placeholder.select": "Vă rugăm să selectați",
@ -677,5 +687,6 @@
"voiceInput.converting": "Se convertește la text...",
"voiceInput.notAllow": "microfonul nu este autorizat",
"voiceInput.speaking": "Vorbiți acum...",
"voiceInput.start": "Introducere vocală",
"you": "Tu"
}

View File

@ -194,6 +194,7 @@
"imageInput.browse": "просмотр",
"imageInput.dropImageHere": "Перетащите ваше изображение сюда или",
"imageInput.supportedFormats": "Поддерживает PNG, JPG, JPEG, WEBP и GIF",
"imageUploader.imageList": "Список изображений",
"imageUploader.imageUpload": "Загрузка изображения",
"imageUploader.pasteImageLink": "Вставить ссылку на изображение",
"imageUploader.pasteImageLinkInputPlaceholder": "Вставьте ссылку на изображение здесь",
@ -512,6 +513,8 @@
"operation.ok": "ОК",
"operation.openInNewTab": "Открыть в новой вкладке",
"operation.params": "Параметры",
"operation.pause": "Пауза",
"operation.play": "Воспроизвести",
"operation.refresh": "Перезапустить",
"operation.regenerate": "Регенерировать",
"operation.reload": "Перезагрузить",
@ -519,6 +522,7 @@
"operation.rename": "Переименовать",
"operation.reset": "Сбросить",
"operation.resetKeywords": "Сбросить ключевые слова",
"operation.retry": "Повторить",
"operation.save": "Сохранить",
"operation.saveAndEnable": "Сохранить и включить",
"operation.saveAndRegenerate": "Сохранение и повторное создание дочерних блоков",
@ -533,13 +537,19 @@
"operation.skip": "Корабль",
"operation.submit": "Отправить",
"operation.sure": "Я уверен",
"operation.toggleFullscreen": "Переключить полноэкранный режим",
"operation.toggleMute": "Переключить звук",
"operation.view": "Вид",
"operation.viewDetails": "Подробнее",
"operation.viewMore": "ПОДРОБНЕЕ",
"operation.yes": "Да",
"operation.zoomIn": "Увеличить",
"operation.zoomOut": "Уменьшение масштаба",
"pagination.editPageNumber": "Изменить номер страницы, текущая страница {{page}} из {{totalPages}}",
"pagination.next": "Next page",
"pagination.pageNumber": "Page number",
"pagination.perPage": "Элементов на странице",
"pagination.previous": "Previous page",
"placeholder.input": "Пожалуйста, введите",
"placeholder.search": "Поиск...",
"placeholder.select": "Пожалуйста, выберите",
@ -677,5 +687,6 @@
"voiceInput.converting": "Преобразование в текст...",
"voiceInput.notAllow": "микрофон не авторизован",
"voiceInput.speaking": "Говорите сейчас...",
"voiceInput.start": "Голосовой ввод",
"you": "Ты"
}

View File

@ -194,6 +194,7 @@
"imageInput.browse": "brskati",
"imageInput.dropImageHere": "Tukaj spustite svojo sliko ali",
"imageInput.supportedFormats": "Podpira PNG, JPG, JPEG, WEBP in GIF",
"imageUploader.imageList": "Seznam slik",
"imageUploader.imageUpload": "Nalaganje slik",
"imageUploader.pasteImageLink": "Prilepi povezavo do slike",
"imageUploader.pasteImageLinkInputPlaceholder": "Tukaj prilepi povezavo do slike",
@ -512,6 +513,8 @@
"operation.ok": "V redu",
"operation.openInNewTab": "Odpri v novem zavihku",
"operation.params": "Parametri",
"operation.pause": "Premor",
"operation.play": "Predvajaj",
"operation.refresh": "Osveži",
"operation.regenerate": "Regeneracijo",
"operation.reload": "Ponovno naloži",
@ -519,6 +522,7 @@
"operation.rename": "Preimenuj",
"operation.reset": "Ponastavi",
"operation.resetKeywords": "Ponastavi ključne besede",
"operation.retry": "Poskusi znova",
"operation.save": "Shrani",
"operation.saveAndEnable": "Shrani in omogoči",
"operation.saveAndRegenerate": "Shranite in regenerirajte otroške koščke",
@ -533,13 +537,19 @@
"operation.skip": "Ladja",
"operation.submit": "Predložiti",
"operation.sure": "Prepričan sem",
"operation.toggleFullscreen": "Preklopi celozaslonski način",
"operation.toggleMute": "Preklopi utišanje",
"operation.view": "Pogled",
"operation.viewDetails": "Poglej podrobnosti",
"operation.viewMore": "POGLEJ VEČ",
"operation.yes": "Da",
"operation.zoomIn": "Povečava",
"operation.zoomOut": "Pomanjšanje",
"pagination.editPageNumber": "Uredi številko strani, trenutna stran {{page}} od {{totalPages}}",
"pagination.next": "Next page",
"pagination.pageNumber": "Page number",
"pagination.perPage": "Elementi na stran",
"pagination.previous": "Previous page",
"placeholder.input": "Vnesite prosim",
"placeholder.search": "Išči...",
"placeholder.select": "Izberite prosim",
@ -677,5 +687,6 @@
"voiceInput.converting": "Pretvorba v besedilo ...",
"voiceInput.notAllow": "Mikrofon ni pooblaščen",
"voiceInput.speaking": "Spregovorite zdaj ...",
"voiceInput.start": "Glasovni vnos",
"you": "Ti"
}

View File

@ -194,6 +194,7 @@
"imageInput.browse": "ท่องเว็บ",
"imageInput.dropImageHere": "วางภาพของคุณที่นี่ หรือ",
"imageInput.supportedFormats": "รองรับ PNG, JPG, JPEG, WEBP และ GIF",
"imageUploader.imageList": "รายการรูปภาพ",
"imageUploader.imageUpload": "อัปโหลดรูปภาพ",
"imageUploader.pasteImageLink": "วางลิงก์รูปภาพ",
"imageUploader.pasteImageLinkInputPlaceholder": "วางลิงค์รูปภาพที่นี่",
@ -512,6 +513,8 @@
"operation.ok": "ตกลง, ได้",
"operation.openInNewTab": "เปิดในแท็บใหม่",
"operation.params": "พารามิเตอร์",
"operation.pause": "หยุดชั่วคราว",
"operation.play": "เล่น",
"operation.refresh": "เริ่มใหม่",
"operation.regenerate": "สร้างใหม่",
"operation.reload": "โหลด",
@ -519,6 +522,7 @@
"operation.rename": "ตั้งชื่อใหม่",
"operation.reset": "รี เซ็ต",
"operation.resetKeywords": "รีเซ็ตคำสำคัญ",
"operation.retry": "ลองอีกครั้ง",
"operation.save": "ประหยัด",
"operation.saveAndEnable": "บันทึกและเปิดใช้งาน",
"operation.saveAndRegenerate": "บันทึกและสร้างก้อนย่อยใหม่",
@ -533,13 +537,19 @@
"operation.skip": "เรือ",
"operation.submit": "ส่ง",
"operation.sure": "ฉันแน่ใจ",
"operation.toggleFullscreen": "สลับเต็มหน้าจอ",
"operation.toggleMute": "สลับปิดเสียง",
"operation.view": "ทิวทัศน์",
"operation.viewDetails": "ดูรายละเอียด",
"operation.viewMore": "ดูเพิ่มเติม",
"operation.yes": "ใช่",
"operation.zoomIn": "ซูมเข้า",
"operation.zoomOut": "ซูมออก",
"pagination.editPageNumber": "แก้ไขหมายเลขหน้า หน้าปัจจุบัน {{page}} จาก {{totalPages}}",
"pagination.next": "Next page",
"pagination.pageNumber": "Page number",
"pagination.perPage": "รายการต่อหน้า",
"pagination.previous": "Previous page",
"placeholder.input": "กรุณากรอก",
"placeholder.search": "ค้นหา...",
"placeholder.select": "กรุณาเลือก",
@ -677,5 +687,6 @@
"voiceInput.converting": "กําลังแปลงเป็นข้อความ...",
"voiceInput.notAllow": "ไม่ได้รับอนุญาตไมโครโฟน",
"voiceInput.speaking": "พูดเดี๋ยวนี้...",
"voiceInput.start": "ป้อนข้อมูลด้วยเสียง",
"you": "คุณ"
}

View File

@ -194,6 +194,7 @@
"imageInput.browse": "göz atın",
"imageInput.dropImageHere": "Görüntünüzü buraya bırakın veya",
"imageInput.supportedFormats": "PNG, JPG, JPEG, WEBP ve GIF'i destekler",
"imageUploader.imageList": "Görsel listesi",
"imageUploader.imageUpload": "Görüntü Yükleme",
"imageUploader.pasteImageLink": "Görüntü bağlantısını yapıştır",
"imageUploader.pasteImageLinkInputPlaceholder": "Görüntü bağlantısını buraya yapıştırın",
@ -512,6 +513,8 @@
"operation.ok": "Tamam",
"operation.openInNewTab": "Yeni sekmede aç",
"operation.params": "Parametreler",
"operation.pause": "Duraklat",
"operation.play": "Oynat",
"operation.refresh": "Yeniden Başlat",
"operation.regenerate": "Yeniden Oluştur",
"operation.reload": "Yeniden Yükle",
@ -519,6 +522,7 @@
"operation.rename": "Yeniden Adlandır",
"operation.reset": "Sıfırla",
"operation.resetKeywords": "Anahtar kelimeleri sıfırla",
"operation.retry": "Tekrar dene",
"operation.save": "Kaydet",
"operation.saveAndEnable": "Kaydet ve Etkinleştir",
"operation.saveAndRegenerate": "Alt Parçaları Kaydetme ve Yeniden Oluşturma",
@ -533,13 +537,19 @@
"operation.skip": "Atla",
"operation.submit": "Gönder",
"operation.sure": "Eminim",
"operation.toggleFullscreen": "Tam ekranı aç/kapat",
"operation.toggleMute": "Sessize al/aç",
"operation.view": "Görüntüle",
"operation.viewDetails": "Detayları Görüntüle",
"operation.viewMore": "DAHA FAZLA GÖSTER",
"operation.yes": "Evet",
"operation.zoomIn": "Yakınlaştırma",
"operation.zoomOut": "Uzaklaştırma",
"pagination.editPageNumber": "Sayfa numarasını düzenle, geçerli sayfa {{page}} / {{totalPages}}",
"pagination.next": "Next page",
"pagination.pageNumber": "Page number",
"pagination.perPage": "Sayfa başına öğe sayısı",
"pagination.previous": "Previous page",
"placeholder.input": "Lütfen girin",
"placeholder.search": "Ara...",
"placeholder.select": "Lütfen seçin",
@ -677,5 +687,6 @@
"voiceInput.converting": "Metne dönüştürülüyor...",
"voiceInput.notAllow": "mikrofon yetkilendirilmedi",
"voiceInput.speaking": "Şimdi konuş...",
"voiceInput.start": "Sesli giriş",
"you": "Sen"
}

View File

@ -194,6 +194,7 @@
"imageInput.browse": "перегляд",
"imageInput.dropImageHere": "Перетягніть зображення сюди або",
"imageInput.supportedFormats": "Підтримує PNG, JPG, JPEG, WEBP і GIF",
"imageUploader.imageList": "Список зображень",
"imageUploader.imageUpload": "Завантаження зображення",
"imageUploader.pasteImageLink": "Вставити посилання на зображення",
"imageUploader.pasteImageLinkInputPlaceholder": "Вставте посилання на зображення тут",
@ -512,6 +513,8 @@
"operation.ok": "ОК",
"operation.openInNewTab": "Відкрити в новій вкладці",
"operation.params": "Параметри",
"operation.pause": "Пауза",
"operation.play": "Відтворити",
"operation.refresh": "Перезапустити",
"operation.regenerate": "Відновити",
"operation.reload": "Перезавантажити",
@ -519,6 +522,7 @@
"operation.rename": "Перейменувати",
"operation.reset": "Скинути",
"operation.resetKeywords": "Скинути ключові слова",
"operation.retry": "Повторити",
"operation.save": "Зберегти",
"operation.saveAndEnable": "Зберегти та Увімкнути",
"operation.saveAndRegenerate": "Збереження та регенерація дочірніх фрагментів",
@ -533,13 +537,19 @@
"operation.skip": "Корабель",
"operation.submit": "Представити",
"operation.sure": "Я впевнений",
"operation.toggleFullscreen": "Перемкнути повноекранний режим",
"operation.toggleMute": "Перемкнути звук",
"operation.view": "Вид",
"operation.viewDetails": "Перегляд докладних відомостей",
"operation.viewMore": "ДИВИТИСЬ БІЛЬШЕ",
"operation.yes": "Так",
"operation.zoomIn": "Збільшити масштаб",
"operation.zoomOut": "Зменшити масштаб",
"pagination.editPageNumber": "Редагувати номер сторінки, поточна сторінка {{page}} з {{totalPages}}",
"pagination.next": "Next page",
"pagination.pageNumber": "Page number",
"pagination.perPage": "Елементів на сторінці",
"pagination.previous": "Previous page",
"placeholder.input": "Будь ласка, введіть текст",
"placeholder.search": "Пошук...",
"placeholder.select": "Будь ласка, оберіть параметр",
@ -677,5 +687,6 @@
"voiceInput.converting": "Перетворення на текст...",
"voiceInput.notAllow": "мікрофон не авторизований",
"voiceInput.speaking": "Говоріть зараз...",
"voiceInput.start": "Голосове введення",
"you": "Ти"
}

View File

@ -194,6 +194,7 @@
"imageInput.browse": "duyệt",
"imageInput.dropImageHere": "Kéo hình ảnh của bạn vào đây, hoặc",
"imageInput.supportedFormats": "Hỗ trợ PNG, JPG, JPEG, WEBP và GIF",
"imageUploader.imageList": "Danh sách hình ảnh",
"imageUploader.imageUpload": "Tải ảnh lên",
"imageUploader.pasteImageLink": "Dán liên kết ảnh",
"imageUploader.pasteImageLinkInputPlaceholder": "Dán liên kết ảnh ở đây",
@ -512,6 +513,8 @@
"operation.ok": "OK",
"operation.openInNewTab": "Mở trong tab mới",
"operation.params": "Tham số",
"operation.pause": "Tạm dừng",
"operation.play": "Phát",
"operation.refresh": "Làm mới",
"operation.regenerate": "Tái tạo",
"operation.reload": "Tải lại",
@ -519,6 +522,7 @@
"operation.rename": "Đổi tên",
"operation.reset": "Đặt lại",
"operation.resetKeywords": "Đặt lại từ khóa",
"operation.retry": "Thử lại",
"operation.save": "Lưu",
"operation.saveAndEnable": "Lưu & Kích hoạt",
"operation.saveAndRegenerate": "Lưu và tạo lại các phần con",
@ -533,13 +537,19 @@
"operation.skip": "Tàu",
"operation.submit": "Trình",
"operation.sure": "Tôi chắc chắn",
"operation.toggleFullscreen": "Chuyển đổi toàn màn hình",
"operation.toggleMute": "Bật/tắt tiếng",
"operation.view": "Cảnh",
"operation.viewDetails": "Xem chi tiết",
"operation.viewMore": "XEM THÊM",
"operation.yes": "Vâng",
"operation.zoomIn": "Phóng to",
"operation.zoomOut": "Thu nhỏ",
"pagination.editPageNumber": "Chỉnh sửa số trang, trang hiện tại {{page}} trên {{totalPages}}",
"pagination.next": "Next page",
"pagination.pageNumber": "Page number",
"pagination.perPage": "Mục trên mỗi trang",
"pagination.previous": "Previous page",
"placeholder.input": "Vui lòng nhập",
"placeholder.search": "Tìm kiếm...",
"placeholder.select": "Vui lòng chọn",
@ -677,5 +687,6 @@
"voiceInput.converting": "Chuyển đổi thành văn bản...",
"voiceInput.notAllow": "micro không được ủy quyền",
"voiceInput.speaking": "Hãy nói...",
"voiceInput.start": "Nhập bằng giọng nói",
"you": "Bạn"
}

View File

@ -194,6 +194,7 @@
"imageInput.browse": "浏览",
"imageInput.dropImageHere": "将图片拖放到此处,或",
"imageInput.supportedFormats": "支持 PNG、JPG、JPEG、WEBP 和 GIF 格式",
"imageUploader.imageList": "图片列表",
"imageUploader.imageUpload": "图片上传",
"imageUploader.pasteImageLink": "粘贴图片链接",
"imageUploader.pasteImageLinkInputPlaceholder": "将图像链接粘贴到此处",
@ -512,6 +513,8 @@
"operation.ok": "好的",
"operation.openInNewTab": "在新标签页打开",
"operation.params": "参数设置",
"operation.pause": "暂停",
"operation.play": "播放",
"operation.refresh": "重新开始",
"operation.regenerate": "重新生成",
"operation.reload": "刷新",
@ -519,6 +522,7 @@
"operation.rename": "重命名",
"operation.reset": "重置",
"operation.resetKeywords": "重置关键词",
"operation.retry": "重试",
"operation.save": "保存",
"operation.saveAndEnable": "保存并启用",
"operation.saveAndRegenerate": "保存并重新生成子分段",
@ -533,13 +537,19 @@
"operation.skip": "跳过",
"operation.submit": "提交",
"operation.sure": "我确定",
"operation.toggleFullscreen": "切换全屏",
"operation.toggleMute": "切换静音",
"operation.view": "查看",
"operation.viewDetails": "查看详情",
"operation.viewMore": "查看更多",
"operation.yes": "是",
"operation.zoomIn": "放大",
"operation.zoomOut": "缩小",
"pagination.editPageNumber": "编辑页码,当前第 {{page}} 页,共 {{totalPages}} 页",
"pagination.next": "下一页",
"pagination.pageNumber": "页码",
"pagination.perPage": "每页显示",
"pagination.previous": "上一页",
"placeholder.input": "请输入",
"placeholder.search": "搜索...",
"placeholder.select": "请选择",
@ -677,5 +687,6 @@
"voiceInput.converting": "正在转换为文本...",
"voiceInput.notAllow": "麦克风未授权",
"voiceInput.speaking": "现在讲...",
"voiceInput.start": "语音输入",
"you": "你"
}

View File

@ -194,6 +194,7 @@
"imageInput.browse": "瀏覽",
"imageInput.dropImageHere": "將您的圖片放在這裡,或",
"imageInput.supportedFormats": "支援 PNG、JPG、JPEG、WEBP 和 GIF",
"imageUploader.imageList": "圖片列表",
"imageUploader.imageUpload": "圖片上傳",
"imageUploader.pasteImageLink": "貼上圖片連結",
"imageUploader.pasteImageLinkInputPlaceholder": "將影象連結貼上到此處",
@ -512,6 +513,8 @@
"operation.ok": "好的",
"operation.openInNewTab": "在新選項卡中打開",
"operation.params": "引數設定",
"operation.pause": "暫停",
"operation.play": "播放",
"operation.refresh": "重新開始",
"operation.regenerate": "再生",
"operation.reload": "重新整理",
@ -519,6 +522,7 @@
"operation.rename": "重新命名",
"operation.reset": "重置",
"operation.resetKeywords": "重置關鍵字",
"operation.retry": "重試",
"operation.save": "儲存",
"operation.saveAndEnable": "儲存並啟用",
"operation.saveAndRegenerate": "保存並重新生成子塊",
@ -533,13 +537,19 @@
"operation.skip": "船",
"operation.submit": "提交",
"operation.sure": "我確定",
"operation.toggleFullscreen": "切換全螢幕",
"operation.toggleMute": "切換靜音",
"operation.view": "視圖",
"operation.viewDetails": "查看詳情",
"operation.viewMore": "查看更多",
"operation.yes": "是",
"operation.zoomIn": "放大",
"operation.zoomOut": "縮小",
"pagination.editPageNumber": "編輯頁碼,目前第 {{page}} 頁,共 {{totalPages}} 頁",
"pagination.next": "下一頁",
"pagination.pageNumber": "頁碼",
"pagination.perPage": "每頁項目數",
"pagination.previous": "上一頁",
"placeholder.input": "請輸入",
"placeholder.search": "搜尋...",
"placeholder.select": "請選擇",
@ -677,5 +687,6 @@
"voiceInput.converting": "正在轉換為文字...",
"voiceInput.notAllow": "麥克風未授權",
"voiceInput.speaking": "現在講...",
"voiceInput.start": "語音輸入",
"you": "你"
}