From 660f8857860972e1339ecfc34dae2cabab154217 Mon Sep 17 00:00:00 2001 From: Jongeun Date: Fri, 25 Sep 2026 15:06:26 +0000 Subject: [PATCH 01/87] build: depend on Astryx 0.6.2 and bump to 0.2.0-alpha.0 Pin @astryxdesign/core, theme-neutral and cli exactly as dependencies. Declare @astryxdesign/lab as an optional exact peer and @stylexjs/stylex ^0.19 as a peer. Raise the React peer floor to 19, which Astryx requires. pnpm-workspace.yaml carries the dev-install settings for the lab canary and the Astryx install scripts. --- package.json | 20 +- pnpm-lock.yaml | 799 +++++++++++++++++++++++++++++++++++++++++--- pnpm-workspace.yaml | 23 ++ 3 files changed, 798 insertions(+), 44 deletions(-) create mode 100644 pnpm-workspace.yaml diff --git a/package.json b/package.json index 4ae54fb..0d8b25a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@lablup/ui-common", - "version": "0.1.0-alpha.23", + "version": "0.2.0-alpha.0", "description": "Shared, product-neutral UI components and design tokens for Lablup products", "license": "Apache-2.0", "author": "Lablup Inc.", @@ -57,12 +57,26 @@ "check:boundary": "node scripts/check-boundary.mjs", "verify": "pnpm run typecheck && pnpm run lint && pnpm run format:check && pnpm run check:boundary && pnpm run test && pnpm run build && pnpm run check:pack" }, + "dependencies": { + "@astryxdesign/cli": "0.6.2", + "@astryxdesign/core": "0.6.2", + "@astryxdesign/theme-neutral": "0.6.2" + }, "peerDependencies": { - "react": "^18.2.0 || ^19.0.0", - "react-dom": "^18.2.0 || ^19.0.0" + "@astryxdesign/lab": "0.6.2-canary.c9fb1ad", + "@stylexjs/stylex": "^0.19.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "peerDependenciesMeta": { + "@astryxdesign/lab": { + "optional": true + } }, "devDependencies": { + "@astryxdesign/lab": "0.6.2-canary.c9fb1ad", "@eslint/js": "^9.39.0", + "@stylexjs/stylex": "0.19.0", "@testing-library/jest-dom": "6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f2dabfa..87224c8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,10 +7,26 @@ settings: importers: .: + dependencies: + '@astryxdesign/cli': + specifier: 0.6.2 + version: 0.6.2(@astryxdesign/core@0.6.2(@stylexjs/stylex@0.19.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@astryxdesign/lab@0.6.2-canary.c9fb1ad(@astryxdesign/core@0.6.2(@stylexjs/stylex@0.19.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@stylexjs/stylex@0.19.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@astryxdesign/theme-neutral@0.6.2(@astryxdesign/core@0.6.2(@stylexjs/stylex@0.19.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8))(gpt-tokenizer@3.4.0)(supports-color@8.1.1) + '@astryxdesign/core': + specifier: 0.6.2 + version: 0.6.2(@stylexjs/stylex@0.19.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@astryxdesign/theme-neutral': + specifier: 0.6.2 + version: 0.6.2(@astryxdesign/core@0.6.2(@stylexjs/stylex@0.19.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8) devDependencies: + '@astryxdesign/lab': + specifier: 0.6.2-canary.c9fb1ad + version: 0.6.2-canary.c9fb1ad(@astryxdesign/core@0.6.2(@stylexjs/stylex@0.19.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@stylexjs/stylex@0.19.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@eslint/js': specifier: ^9.39.0 version: 9.39.5 + '@stylexjs/stylex': + specifier: 0.19.0 + version: 0.19.0 '@testing-library/jest-dom': specifier: 6.9.1 version: 6.9.1 @@ -31,13 +47,13 @@ importers: version: 19.2.4(@types/react@19.2.18) '@vitejs/plugin-react': specifier: ^6.0.2 - version: 6.0.5(vite@8.2.0(@types/node@22.20.1)) + version: 6.0.5(vite@8.2.0(@types/node@22.20.1)(jiti@2.7.0)) eslint: specifier: ^9.39.0 - version: 9.39.5(supports-color@8.1.1) + version: 9.39.5(jiti@2.7.0)(supports-color@8.1.1) eslint-plugin-react-hooks: specifier: 6.0.0-rc.1 - version: 6.0.0-rc.1(eslint@9.39.5(supports-color@8.1.1))(supports-color@8.1.1) + version: 6.0.0-rc.1(eslint@9.39.5(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1) globals: specifier: ^16.5.0 version: 16.5.0 @@ -61,16 +77,16 @@ importers: version: 5.9.3 typescript-eslint: specifier: ^8.47.0 - version: 8.66.0(eslint@9.39.5(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3) + version: 8.66.0(eslint@9.39.5(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3) vite: specifier: ^8.0.16 - version: 8.2.0(@types/node@22.20.1) + version: 8.2.0(@types/node@22.20.1)(jiti@2.7.0) vite-plugin-dts: specifier: ^4.5.4 - version: 4.5.4(@types/node@22.20.1)(supports-color@8.1.1)(typescript@5.9.3)(vite@8.2.0(@types/node@22.20.1)) + version: 4.5.4(@types/node@22.20.1)(supports-color@8.1.1)(typescript@5.9.3)(vite@8.2.0(@types/node@22.20.1)(jiti@2.7.0)) vitest: specifier: ^4.1.8 - version: 4.1.10(@types/node@22.20.1)(jsdom@29.1.1)(vite@8.2.0(@types/node@22.20.1)) + version: 4.1.10(@types/node@22.20.1)(jsdom@29.1.1)(vite@8.2.0(@types/node@22.20.1)(jiti@2.7.0)) packages: @@ -92,6 +108,47 @@ packages: '@asamuzakjp/nwsapi@2.3.9': resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@astryxdesign/cli@0.6.2': + resolution: {integrity: sha512-7M3jqDRsFqsIyjMDJNHUaoJ9pcYa5e+MDrF/wMx40YAinkzRYonNy55mvcryiB6EcFrpmusrlWKspVvPDHd6QA==} + engines: {node: '>=22.13.0'} + hasBin: true + peerDependencies: + '@astryxdesign/charts': '*' + '@astryxdesign/core': '*' + '@astryxdesign/lab': '*' + '@astryxdesign/theme-neutral': '*' + gpt-tokenizer: ^3.4.0 + peerDependenciesMeta: + '@astryxdesign/charts': + optional: true + '@astryxdesign/core': + optional: true + '@astryxdesign/lab': + optional: true + '@astryxdesign/theme-neutral': + optional: true + + '@astryxdesign/core@0.6.2': + resolution: {integrity: sha512-QzRE4Dn4hePtLL2q8KgdBcPKQVlxg316XIHez087iTSa3dkyY3429Zi23G35yCDrRHn6zoAMpLLLQp12s4FVFg==} + peerDependencies: + '@stylexjs/stylex': ^0.19.0 + react: '>=19.0.0' + react-dom: '>=19.0.0' + + '@astryxdesign/lab@0.6.2-canary.c9fb1ad': + resolution: {integrity: sha512-w+xcHW+K+Jta+9aMC0uPln/+ZvTn8+0XIKw5D0nF5t4r6ApqN0LHtwxGAi9CvNupLzD0nDb0TJJBpH0tt0Zh9A==} + peerDependencies: + '@astryxdesign/core': 0.6.2-canary.c9fb1ad + '@stylexjs/stylex': '>=0.10.0' + react: '>=19.0.0' + react-dom: '>=19.0.0' + + '@astryxdesign/theme-neutral@0.6.2': + resolution: {integrity: sha512-DI80GzPqrcnMXHMGzNB8SFJSbTKOvU4FBtNANg8g/NexY4bHrmpOLzlUomGxuLdmDH/HCwVX6BRDK8s96OKJjA==} + peerDependencies: + '@astryxdesign/core': 0.6.2 + react: '>=19' + '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -179,12 +236,84 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/plugin-syntax-flow@7.29.7': + resolution: {integrity: sha512-ajMX6QPcyomotqwpzhkYGxcK2i/us0rs1Qo9QvUpa+Fca0FTmqrzKrctoIYLMxcOhGZldGT/BAVkRGTWBiR8gQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-jsx@7.29.7': + resolution: {integrity: sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-typescript@7.29.7': + resolution: {integrity: sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-class-properties@7.29.7': + resolution: {integrity: sha512-GtcpjFvanPfzNQi3eTitsCqtRRmmqzpy/A+yhTR1HaZo1Ly3EA8ZXxlPyHdR8/IuRMYc3E4wdGBewB2QKQjAaA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-flow-strip-types@7.29.7': + resolution: {integrity: sha512-wRHeUjUjCZnMHmiO5bRgjFLcoEh7JyTdByOW11ahhwNa4V0bmeGEaIvt51yq0zQp2yWIpqfxXXPyUP6GFJZHOQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-commonjs@7.29.7': + resolution: {integrity: sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-nullish-coalescing-operator@7.29.7': + resolution: {integrity: sha512-idmp1dFaekP9GbcMvG24Kvw2BfhFZjHnNJCkV4WuIY4PskJzwI3f1N5OdgYke38T7rftO6ERulFRn2cFeZwRkg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-optional-chaining@7.29.7': + resolution: {integrity: sha512-6GM1dhvK3gNODkXcEcMCOLEDCLSoZ/sBbro2Ax8HURyasQ4NshagQixkRFdh5niI6E4gmA/jYI/4aT7rRos3ZQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + '@babel/plugin-transform-private-methods@7.29.7': resolution: {integrity: sha512-/6Rz4DK1ETDEM/bWHsPHcaEe7ZaT1EqSXjtSP/L0DijOYuaUhiRiOKcwpZ8P7zR4xXEHc2ITdiCgBm9Tpyv9ug==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/plugin-transform-typescript@7.29.7': + resolution: {integrity: sha512-jK52h8LaLc7JarhQV2ofeFMts4H7vnOXnqZNA6fYglBTZewRBE51KWt3BUltW1P+KoPsYkHoJeXePuz4zo2LMw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/preset-flow@7.29.7': + resolution: {integrity: sha512-KYIRV0BuaN68CDdsqFkAD7MU7yipUqQNuNElwATdxaIdpTjhvtY82QvkBJs7zV3Evxj2jFAAZ1iO8nyy0nhjqA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/preset-typescript@7.29.7': + resolution: {integrity: sha512-/Foi8vKY2EVbed/1eZx0gJEEwHAIxogrySI7rULcRIvhZzbvoE/b5qG5Ghc0WKAFKOHA9SD1x7RsFlOYdutIiQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/register@7.29.7': + resolution: {integrity: sha512-AMGJoWuES861riy6pcB0fphE1YXybtQnBYQMuIyPv6mKLiosfa79BKTnAOyx215c/3RJPJpdQwoHZ3earVH7AA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + '@babel/runtime@7.29.7': resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} engines: {node: '>=6.9.0'} @@ -288,6 +417,15 @@ packages: '@noble/hashes': optional: true + '@formatjs/fast-memoize@3.1.7': + resolution: {integrity: sha512-zXfhLpvA6T7+efdt9JLbBwZ00tT7NsBMDVnDu8rpHeNNv8KfRZAMo2gkG0k9lK/Nzc//3kJ9pImsfuJxk3KhUA==} + + '@formatjs/icu-messageformat-parser@3.5.18': + resolution: {integrity: sha512-wX1efcL8d7K5QapOTRsP/OLTA/kpwe4LwqWyNxDNg5iRs8i9jJbCyxB5P8jc4KOW5oiLzz2DJr+BRY5xMV9+bA==} + + '@formatjs/icu-skeleton-parser@2.1.11': + resolution: {integrity: sha512-j8cUmOJzVgkHuS0QiQ6ga76UIoLOFSAMWhs7aZJztH3aAdCOAE6vpC8KVvFB4cU10ON0y2/5oOVmPJ43s2lTwA==} + '@humanfs/core@0.19.2': resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} engines: {node: '>=18.18.0'} @@ -475,6 +613,9 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@stylexjs/stylex@0.19.0': + resolution: {integrity: sha512-CnUFp7YMaDLDeemsWOfJgoC/gKM5P/yBNMcpJaE6ChJmXr7s0DJwSeGTTlHJcqqwN9OW1qGtmARWLFhGZN1pTA==} + '@testing-library/dom@10.4.1': resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} engines: {node: '>=18'} @@ -735,6 +876,10 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + ast-types@0.16.3: + resolution: {integrity: sha512-FvWoWYfSCM6kRxCSH+MGLHIKKGRL6A6AW7Zek2O32REPQRdg131428uRTKMBYAeRd3XXAaHDS60Wpri7CdKDrA==} + engines: {node: '>=4'} + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -765,6 +910,9 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + callsites@3.1.0: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} @@ -780,6 +928,10 @@ packages: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} + clone-deep@4.0.1: + resolution: {integrity: sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==} + engines: {node: '>=6'} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -787,6 +939,13 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + commander@12.1.0: + resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} + engines: {node: '>=18'} + + commondir@1.0.1: + resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==} + compare-versions@6.1.1: resolution: {integrity: sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==} @@ -806,6 +965,9 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + css-mediaquery@0.1.2: + resolution: {integrity: sha512-COtn4EROW5dBGlE/4PiKnh6rZpAPxDeFLaEEwt4i10jpDMFt2EhQGS79QmmrO+iKCHv0PU/HrOWEhijFd1x99Q==} + css-tree@3.2.1: resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} @@ -813,9 +975,50 @@ packages: css.escape@1.5.1: resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + data-urls@7.0.0: resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -907,6 +1110,7 @@ packages: eslint@9.39.5: resolution: {integrity: sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. hasBin: true peerDependencies: jiti: '*' @@ -918,6 +1122,11 @@ packages: resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + esquery@1.7.0: resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} engines: {node: '>=0.10'} @@ -972,6 +1181,14 @@ packages: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} + find-cache-dir@2.1.0: + resolution: {integrity: sha512-Tq6PixE0w/VMFfCgbONnkiQIVol/JJL7nRMi20fqzA4NRs9AfeqMGeRdPi3wIhYkxjeBaWh2rxwapn5Tu3IqOQ==} + engines: {node: '>=6'} + + find-up@3.0.0: + resolution: {integrity: sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==} + engines: {node: '>=6'} + find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -983,6 +1200,14 @@ packages: flatted@3.4.4: resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} + flow-estree@0.332.0: + resolution: {integrity: sha512-nsh1Ty3l/a8xJLGWnrZ0VFfQXjIpA6Gm1LqovVyj8RR6gE9Hz/cDl951qNNUnZtJ08u+6V0SFrY66XAoct76Fg==} + engines: {node: '>=18'} + + flow-parser@0.332.0: + resolution: {integrity: sha512-Ly9ylvWknL6YGW2lq0stmDg8PfEh9qHxPoqlZLDjjH/9yd9Ik37m335a0WngsxQvDfrW8lwBPm++RmZm3e4lFQ==} + engines: {node: '>=0.4.0'} + fs-extra@11.3.6: resolution: {integrity: sha512-w8ZNZr2mKIc7qeNaQ9AVPT1+iFaI+Avd4xudVOvdDJ8VytREi1Ft5Ih7hd9jjehod8vAM5GMsfQ/TpPf4EyoEA==} engines: {node: '>=14.14'} @@ -1011,6 +1236,9 @@ packages: resolution: {integrity: sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==} engines: {node: '>=18'} + gpt-tokenizer@3.4.0: + resolution: {integrity: sha512-wxFLnhIXTDjYebd9A9pGl3e31ZpSypbpIJSOswbgop5jLte/AsZVDvjlbEuVFlsqZixVKqbcoNmRlFDf6pz/UQ==} + graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} @@ -1060,6 +1288,16 @@ packages: resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} engines: {node: '>=8'} + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + + intl-messageformat@11.2.15: + resolution: {integrity: sha512-W4VNytsXyWgW5lvVchmsb9xgA5rSKqoflu3/IQ9UCtB0z4R+Bx9MoEkwFT/+30RCl/KzUoH0QGGe9lppF5SuvQ==} + + invariant@2.2.4: + resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} + is-core-module@2.16.2: resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} engines: {node: '>= 0.4'} @@ -1072,12 +1310,24 @@ packages: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} + is-plain-object@2.0.4: + resolution: {integrity: sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==} + engines: {node: '>=0.10.0'} + is-potential-custom-element-name@1.0.1: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + isobject@3.0.1: + resolution: {integrity: sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==} + engines: {node: '>=0.10.0'} + + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + jju@1.4.0: resolution: {integrity: sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==} @@ -1088,6 +1338,16 @@ packages: resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==} hasBin: true + jscodeshift@17.4.0: + resolution: {integrity: sha512-i3ESKiiTsGynxzTg5BhsZViD0ai72/6SsI1efDZxG6/5KCoElsmquxtyhXK5lpEgoO7MTNpYkjFEdEL97SkBNg==} + engines: {node: '>=16'} + hasBin: true + peerDependencies: + '@babel/preset-env': ^7.1.6 + peerDependenciesMeta: + '@babel/preset-env': + optional: true + jsdom@29.1.1: resolution: {integrity: sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==} engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} @@ -1125,6 +1385,10 @@ packages: keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + kind-of@6.0.3: + resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} + engines: {node: '>=0.10.0'} + kolorist@1.8.0: resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==} @@ -1210,6 +1474,10 @@ packages: resolution: {integrity: sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q==} engines: {node: '>=14'} + locate-path@3.0.0: + resolution: {integrity: sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==} + engines: {node: '>=6'} + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -1217,6 +1485,10 @@ packages: lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + lru-cache@11.5.2: resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} engines: {node: 20 || >=22} @@ -1224,6 +1496,11 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lucide-react@1.48.0: + resolution: {integrity: sha512-R0CIKY/fXiC6y9xRBADgsK+VW2p/pcTJOMhLZf1T+uG+vJUvyUR02nGOgmIIC7MPkiNK4Ox8QDnbqF8rJYWPZQ==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + lz-string@1.5.0: resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} hasBin: true @@ -1231,6 +1508,10 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + make-dir@2.1.0: + resolution: {integrity: sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==} + engines: {node: '>=6'} + mdn-data@2.27.1: resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} @@ -1270,6 +1551,13 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + neo-async@2.6.2: + resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + + node-diff3@3.2.1: + resolution: {integrity: sha512-eKZcJ8RtMQ3cIaA15EgmtwG927fYnRlhtdA4Q9HAcCpAZPQhhU2XptnTH9GeAkMiX2bAXxlJSAFa9shFbztPgw==} + engines: {bun: '>=1.3.10'} + node-releases@2.0.51: resolution: {integrity: sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==} engines: {node: '>=18'} @@ -1282,14 +1570,26 @@ packages: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} + p-locate@3.0.0: + resolution: {integrity: sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==} + engines: {node: '>=6'} + p-locate@5.0.0: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -1300,6 +1600,10 @@ packages: path-browserify@1.0.1: resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + path-exists@3.0.0: + resolution: {integrity: sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==} + engines: {node: '>=4'} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -1321,12 +1625,28 @@ packages: resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} engines: {node: '>=12'} + pify@4.0.1: + resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} + engines: {node: '>=6'} + + pirates@4.0.7: + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} + engines: {node: '>= 6'} + + pkg-dir@3.0.0: + resolution: {integrity: sha512-/E57AYkoeQ25qkxMj5PBOVgF8Kiu/h7cYS30Z5+R7WaiCCBfLq58ZI/dSeaEKb9WVJV5n/03QwrN3IeWIFllvw==} + engines: {node: '>=6'} + pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} pkg-types@2.3.1: resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} + postcss-selector-parser@7.1.6: + resolution: {integrity: sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==} + engines: {node: '>=4'} + postcss@8.5.25: resolution: {integrity: sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==} engines: {node: ^10 || ^12 || >=14} @@ -1363,6 +1683,10 @@ packages: resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} engines: {node: '>=0.10.0'} + recast@0.23.21: + resolution: {integrity: sha512-mFAyJq9vUbSTARLZUvAEf1z3YxlvAwswbmxMx2mPA/MSm4KmpwvwvhsH/NIrZhyOuwD60Lzyw2qh83uCbgTPYw==} + engines: {node: '>= 4'} + redent@3.0.0: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} @@ -1392,6 +1716,10 @@ packages: scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + semver@5.7.2: + resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} + hasBin: true + semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -1406,6 +1734,10 @@ packages: engines: {node: '>=10'} hasBin: true + shallow-clone@3.0.1: + resolution: {integrity: sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==} + engines: {node: '>=8'} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -1417,10 +1749,17 @@ packages: siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + source-map@0.6.1: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} @@ -1446,6 +1785,9 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + styleq@0.2.1: + resolution: {integrity: sha512-L0TR0NQb+X4/ktDEKmjWyp27gla+LUYi/by5k5SjKXf6/pvZP7wbwEB5J+tqxdFVPgzbsuz+d4RTScO/QZquBw==} + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -1461,6 +1803,9 @@ packages: symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tiny-invariant@1.3.3: + resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -1483,6 +1828,10 @@ packages: resolution: {integrity: sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==} hasBin: true + tmp@0.2.7: + resolution: {integrity: sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==} + engines: {node: '>=14.14'} + tough-cookie@6.0.2: resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==} engines: {node: '>=16'} @@ -1497,6 +1846,9 @@ packages: peerDependencies: typescript: '>=4.8.4' + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -1536,6 +1888,9 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + vite-plugin-dts@4.5.4: resolution: {integrity: sha512-d4sOM8M/8z7vRXHHq/ebbblfaxENjogAAekcfcDCCwAyvGqnPrc7f4NZbvItS+g4WTgerW0xDwSz5qz11JT3vg==} peerDependencies: @@ -1662,6 +2017,10 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + write-file-atomic@5.0.1: + resolution: {integrity: sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + xml-name-validator@5.0.0: resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} engines: {node: '>=18'} @@ -1685,6 +2044,9 @@ packages: zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zod@4.6.5: + resolution: {integrity: sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==} + snapshots: '@adobe/css-tools@4.5.0': {} @@ -1709,6 +2071,53 @@ snapshots: '@asamuzakjp/nwsapi@2.3.9': {} + '@astryxdesign/cli@0.6.2(@astryxdesign/core@0.6.2(@stylexjs/stylex@0.19.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@astryxdesign/lab@0.6.2-canary.c9fb1ad(@astryxdesign/core@0.6.2(@stylexjs/stylex@0.19.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@stylexjs/stylex@0.19.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@astryxdesign/theme-neutral@0.6.2(@astryxdesign/core@0.6.2(@stylexjs/stylex@0.19.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8))(gpt-tokenizer@3.4.0)(supports-color@8.1.1)': + dependencies: + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/parser': 7.29.8 + '@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/types': 7.29.8 + ast-types: 0.16.3 + commander: 12.1.0 + gpt-tokenizer: 3.4.0 + jiti: 2.7.0 + jscodeshift: 17.4.0(supports-color@8.1.1) + node-diff3: 3.2.1 + postcss: 8.5.25 + postcss-selector-parser: 7.1.6 + recast: 0.23.21 + zod: 4.6.5 + optionalDependencies: + '@astryxdesign/core': 0.6.2(@stylexjs/stylex@0.19.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@astryxdesign/lab': 0.6.2-canary.c9fb1ad(@astryxdesign/core@0.6.2(@stylexjs/stylex@0.19.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@stylexjs/stylex@0.19.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@astryxdesign/theme-neutral': 0.6.2(@astryxdesign/core@0.6.2(@stylexjs/stylex@0.19.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8) + transitivePeerDependencies: + - '@babel/preset-env' + - supports-color + + '@astryxdesign/core@0.6.2(@stylexjs/stylex@0.19.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@stylexjs/stylex': 0.19.0 + intl-messageformat: 11.2.15 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + + '@astryxdesign/lab@0.6.2-canary.c9fb1ad(@astryxdesign/core@0.6.2(@stylexjs/stylex@0.19.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@stylexjs/stylex@0.19.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@astryxdesign/core': 0.6.2(@stylexjs/stylex@0.19.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@stylexjs/stylex': 0.19.0 + d3-array: 3.2.4 + d3-scale: 4.0.2 + d3-shape: 3.2.0 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + + '@astryxdesign/theme-neutral@0.6.2(@astryxdesign/core@0.6.2(@stylexjs/stylex@0.19.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)': + dependencies: + '@astryxdesign/core': 0.6.2(@stylexjs/stylex@0.19.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + lucide-react: 1.48.0(react@19.2.8) + react: 19.2.8 + '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -1832,6 +2241,56 @@ snapshots: dependencies: '@babel/types': 7.29.8 + '@babel/plugin-syntax-flow@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))': + dependencies: + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))': + dependencies: + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))': + dependencies: + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-class-properties@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)': + dependencies: + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/helper-plugin-utils': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-flow-strip-types@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))': + dependencies: + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/helper-plugin-utils': 7.29.7 + '@babel/plugin-syntax-flow': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1)) + + '@babel/plugin-transform-modules-commonjs@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)': + dependencies: + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/helper-plugin-utils': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-nullish-coalescing-operator@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))': + dependencies: + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-optional-chaining@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)': + dependencies: + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7(supports-color@8.1.1) + transitivePeerDependencies: + - supports-color + '@babel/plugin-transform-private-methods@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: '@babel/core': 7.29.7(supports-color@8.1.1) @@ -1840,6 +2299,44 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/plugin-transform-typescript@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)': + dependencies: + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7(supports-color@8.1.1) + '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1)) + transitivePeerDependencies: + - supports-color + + '@babel/preset-flow@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))': + dependencies: + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + '@babel/plugin-transform-flow-strip-types': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1)) + + '@babel/preset-typescript@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)': + dependencies: + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + transitivePeerDependencies: + - supports-color + + '@babel/register@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))': + dependencies: + '@babel/core': 7.29.7(supports-color@8.1.1) + clone-deep: 4.0.1 + find-cache-dir: 2.1.0 + make-dir: 2.1.0 + pirates: 4.0.7 + source-map-support: 0.5.21 + '@babel/runtime@7.29.7': {} '@babel/template@7.29.7': @@ -1893,9 +2390,9 @@ snapshots: '@csstools/css-tokenizer@4.0.0': {} - '@eslint-community/eslint-utils@4.10.1(eslint@9.39.5(supports-color@8.1.1))': + '@eslint-community/eslint-utils@4.10.1(eslint@9.39.5(jiti@2.7.0)(supports-color@8.1.1))': dependencies: - eslint: 9.39.5(supports-color@8.1.1) + eslint: 9.39.5(jiti@2.7.0)(supports-color@8.1.1) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} @@ -1941,6 +2438,14 @@ snapshots: '@exodus/bytes@1.15.1': {} + '@formatjs/fast-memoize@3.1.7': {} + + '@formatjs/icu-messageformat-parser@3.5.18': + dependencies: + '@formatjs/icu-skeleton-parser': 2.1.11 + + '@formatjs/icu-skeleton-parser@2.1.11': {} + '@humanfs/core@0.19.2': dependencies: '@humanfs/types': 0.15.0 @@ -2104,6 +2609,12 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@stylexjs/stylex@0.19.0': + dependencies: + css-mediaquery: 0.1.2 + invariant: 2.2.4 + styleq: 0.2.1 + '@testing-library/dom@10.4.1': dependencies: '@babel/code-frame': 7.29.7 @@ -2165,15 +2676,15 @@ snapshots: dependencies: csstype: 3.2.3 - '@typescript-eslint/eslint-plugin@8.66.0(@typescript-eslint/parser@8.66.0(eslint@9.39.5(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3))(eslint@9.39.5(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.66.0(@typescript-eslint/parser@8.66.0(eslint@9.39.5(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.66.0(eslint@9.39.5(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3) + '@typescript-eslint/parser': 8.66.0(eslint@9.39.5(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3) '@typescript-eslint/scope-manager': 8.66.0 - '@typescript-eslint/type-utils': 8.66.0(eslint@9.39.5(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3) - '@typescript-eslint/utils': 8.66.0(eslint@9.39.5(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3) + '@typescript-eslint/type-utils': 8.66.0(eslint@9.39.5(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3) + '@typescript-eslint/utils': 8.66.0(eslint@9.39.5(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.66.0 - eslint: 9.39.5(supports-color@8.1.1) + eslint: 9.39.5(jiti@2.7.0)(supports-color@8.1.1) ignore: 7.0.6 natural-compare: 1.4.0 ts-api-utils: 2.5.0(typescript@5.9.3) @@ -2181,14 +2692,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.66.0(eslint@9.39.5(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3)': + '@typescript-eslint/parser@8.66.0(eslint@9.39.5(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3)': dependencies: '@typescript-eslint/scope-manager': 8.66.0 '@typescript-eslint/types': 8.66.0 '@typescript-eslint/typescript-estree': 8.66.0(supports-color@8.1.1)(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.66.0 debug: 4.4.3(supports-color@8.1.1) - eslint: 9.39.5(supports-color@8.1.1) + eslint: 9.39.5(jiti@2.7.0)(supports-color@8.1.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -2211,13 +2722,13 @@ snapshots: dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@8.66.0(eslint@9.39.5(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.66.0(eslint@9.39.5(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3)': dependencies: '@typescript-eslint/types': 8.66.0 '@typescript-eslint/typescript-estree': 8.66.0(supports-color@8.1.1)(typescript@5.9.3) - '@typescript-eslint/utils': 8.66.0(eslint@9.39.5(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3) + '@typescript-eslint/utils': 8.66.0(eslint@9.39.5(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3) debug: 4.4.3(supports-color@8.1.1) - eslint: 9.39.5(supports-color@8.1.1) + eslint: 9.39.5(jiti@2.7.0)(supports-color@8.1.1) ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: @@ -2240,13 +2751,13 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.66.0(eslint@9.39.5(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3)': + '@typescript-eslint/utils@8.66.0(eslint@9.39.5(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3)': dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(supports-color@8.1.1)) + '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(jiti@2.7.0)(supports-color@8.1.1)) '@typescript-eslint/scope-manager': 8.66.0 '@typescript-eslint/types': 8.66.0 '@typescript-eslint/typescript-estree': 8.66.0(supports-color@8.1.1)(typescript@5.9.3) - eslint: 9.39.5(supports-color@8.1.1) + eslint: 9.39.5(jiti@2.7.0)(supports-color@8.1.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -2256,10 +2767,10 @@ snapshots: '@typescript-eslint/types': 8.66.0 eslint-visitor-keys: 5.0.1 - '@vitejs/plugin-react@6.0.5(vite@8.2.0(@types/node@22.20.1))': + '@vitejs/plugin-react@6.0.5(vite@8.2.0(@types/node@22.20.1)(jiti@2.7.0))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: 8.2.0(@types/node@22.20.1) + vite: 8.2.0(@types/node@22.20.1)(jiti@2.7.0) '@vitest/expect@4.1.10': dependencies: @@ -2270,13 +2781,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.1 - '@vitest/mocker@4.1.10(vite@8.2.0(@types/node@22.20.1))': + '@vitest/mocker@4.1.10(vite@8.2.0(@types/node@22.20.1)(jiti@2.7.0))': dependencies: '@vitest/spy': 4.1.10 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.2.0(@types/node@22.20.1) + vite: 8.2.0(@types/node@22.20.1)(jiti@2.7.0) '@vitest/pretty-format@4.1.10': dependencies: @@ -2408,6 +2919,10 @@ snapshots: assertion-error@2.0.1: {} + ast-types@0.16.3: + dependencies: + tslib: 2.8.1 + balanced-match@1.0.2: {} balanced-match@4.0.4: {} @@ -2439,6 +2954,8 @@ snapshots: node-releases: 2.0.51 update-browserslist-db: 1.2.3(browserslist@4.28.7) + buffer-from@1.1.2: {} + callsites@3.1.0: {} caniuse-lite@1.0.30001806: {} @@ -2450,12 +2967,22 @@ snapshots: ansi-styles: 4.3.0 supports-color: 7.2.0 + clone-deep@4.0.1: + dependencies: + is-plain-object: 2.0.4 + kind-of: 6.0.3 + shallow-clone: 3.0.1 + color-convert@2.0.1: dependencies: color-name: 1.1.4 color-name@1.1.4: {} + commander@12.1.0: {} + + commondir@1.0.1: {} + compare-versions@6.1.1: {} concat-map@0.0.1: {} @@ -2472,6 +2999,8 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + css-mediaquery@0.1.2: {} + css-tree@3.2.1: dependencies: mdn-data: 2.27.1 @@ -2479,8 +3008,44 @@ snapshots: css.escape@1.5.1: {} + cssesc@3.0.0: {} + csstype@3.2.3: {} + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-color@3.1.0: {} + + d3-format@3.1.2: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@3.1.0: {} + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + data-urls@7.0.0: dependencies: whatwg-mimetype: 5.0.0 @@ -2524,12 +3089,12 @@ snapshots: escape-string-regexp@4.0.0: {} - eslint-plugin-react-hooks@6.0.0-rc.1(eslint@9.39.5(supports-color@8.1.1))(supports-color@8.1.1): + eslint-plugin-react-hooks@6.0.0-rc.1(eslint@9.39.5(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1): dependencies: '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/parser': 7.29.8 '@babel/plugin-transform-private-methods': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) - eslint: 9.39.5(supports-color@8.1.1) + eslint: 9.39.5(jiti@2.7.0)(supports-color@8.1.1) hermes-parser: 0.25.1 zod: 3.25.76 zod-validation-error: 3.5.4(zod@3.25.76) @@ -2547,9 +3112,9 @@ snapshots: eslint-visitor-keys@5.0.1: {} - eslint@9.39.5(supports-color@8.1.1): + eslint@9.39.5(jiti@2.7.0)(supports-color@8.1.1): dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(supports-color@8.1.1)) + '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(jiti@2.7.0)(supports-color@8.1.1)) '@eslint-community/regexpp': 4.12.2 '@eslint/config-array': 0.21.2(supports-color@8.1.1) '@eslint/config-helpers': 0.4.2 @@ -2583,6 +3148,8 @@ snapshots: minimatch: 3.1.5 natural-compare: 1.4.0 optionator: 0.9.4 + optionalDependencies: + jiti: 2.7.0 transitivePeerDependencies: - supports-color @@ -2592,6 +3159,8 @@ snapshots: acorn-jsx: 5.3.2(acorn@8.18.0) eslint-visitor-keys: 4.2.1 + esprima@4.0.1: {} + esquery@1.7.0: dependencies: estraverse: 5.3.0 @@ -2630,6 +3199,16 @@ snapshots: dependencies: flat-cache: 4.0.1 + find-cache-dir@2.1.0: + dependencies: + commondir: 1.0.1 + make-dir: 2.1.0 + pkg-dir: 3.0.0 + + find-up@3.0.0: + dependencies: + locate-path: 3.0.0 + find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -2642,6 +3221,12 @@ snapshots: flatted@3.4.4: {} + flow-estree@0.332.0: {} + + flow-parser@0.332.0: + dependencies: + flow-estree: 0.332.0 + fs-extra@11.3.6: dependencies: graceful-fs: 4.2.11 @@ -2663,6 +3248,8 @@ snapshots: globals@16.5.0: {} + gpt-tokenizer@3.4.0: {} + graceful-fs@4.2.11: {} has-flag@4.0.0: {} @@ -2700,6 +3287,17 @@ snapshots: indent-string@4.0.0: {} + internmap@2.0.3: {} + + intl-messageformat@11.2.15: + dependencies: + '@formatjs/fast-memoize': 3.1.7 + '@formatjs/icu-messageformat-parser': 3.5.18 + + invariant@2.2.4: + dependencies: + loose-envify: 1.4.0 + is-core-module@2.16.2: dependencies: hasown: 2.0.4 @@ -2710,10 +3308,18 @@ snapshots: dependencies: is-extglob: 2.1.1 + is-plain-object@2.0.4: + dependencies: + isobject: 3.0.1 + is-potential-custom-element-name@1.0.1: {} isexe@2.0.0: {} + isobject@3.0.1: {} + + jiti@2.7.0: {} + jju@1.4.0: {} js-tokens@4.0.0: {} @@ -2722,6 +3328,29 @@ snapshots: dependencies: argparse: 2.0.1 + jscodeshift@17.4.0(supports-color@8.1.1): + dependencies: + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/parser': 7.29.8 + '@babel/plugin-transform-class-properties': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/plugin-transform-nullish-coalescing-operator': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-optional-chaining': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/plugin-transform-private-methods': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/preset-flow': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/preset-typescript': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/register': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1)) + flow-parser: 0.332.0 + graceful-fs: 4.2.11 + neo-async: 2.6.2 + picocolors: 1.1.1 + picomatch: 4.0.5 + recast: 0.23.21 + tmp: 0.2.7 + write-file-atomic: 5.0.1 + transitivePeerDependencies: + - supports-color + jsdom@29.1.1: dependencies: '@asamuzakjp/css-color': 5.1.11 @@ -2770,6 +3399,8 @@ snapshots: dependencies: json-buffer: 3.0.1 + kind-of@6.0.3: {} + kolorist@1.8.0: {} levn@0.4.1: @@ -2832,24 +3463,42 @@ snapshots: pkg-types: 2.3.1 quansync: 0.2.11 + locate-path@3.0.0: + dependencies: + p-locate: 3.0.0 + path-exists: 3.0.0 + locate-path@6.0.0: dependencies: p-locate: 5.0.0 lodash.merge@4.6.2: {} + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + lru-cache@11.5.2: {} lru-cache@5.1.1: dependencies: yallist: 3.1.1 + lucide-react@1.48.0(react@19.2.8): + dependencies: + react: 19.2.8 + lz-string@1.5.0: {} magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + make-dir@2.1.0: + dependencies: + pify: 4.0.1 + semver: 5.7.2 + mdn-data@2.27.1: {} min-indent@1.0.1: {} @@ -2885,6 +3534,10 @@ snapshots: natural-compare@1.4.0: {} + neo-async@2.6.2: {} + + node-diff3@3.2.1: {} + node-releases@2.0.51: {} obug@2.1.4: {} @@ -2898,14 +3551,24 @@ snapshots: type-check: 0.4.0 word-wrap: 1.2.5 + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 + p-locate@3.0.0: + dependencies: + p-limit: 2.3.0 + p-locate@5.0.0: dependencies: p-limit: 3.1.0 + p-try@2.2.0: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -2916,6 +3579,8 @@ snapshots: path-browserify@1.0.1: {} + path-exists@3.0.0: {} + path-exists@4.0.0: {} path-key@3.1.1: {} @@ -2928,6 +3593,14 @@ snapshots: picomatch@4.0.5: {} + pify@4.0.1: {} + + pirates@4.0.7: {} + + pkg-dir@3.0.0: + dependencies: + find-up: 3.0.0 + pkg-types@1.3.1: dependencies: confbox: 0.1.8 @@ -2940,6 +3613,11 @@ snapshots: exsolve: 1.1.1 pathe: 2.0.3 + postcss-selector-parser@7.1.6: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + postcss@8.5.25: dependencies: nanoid: 3.3.17 @@ -2969,6 +3647,14 @@ snapshots: react@19.2.8: {} + recast@0.23.21: + dependencies: + ast-types: 0.16.3 + esprima: 4.0.1 + source-map: 0.6.1 + tiny-invariant: 1.3.3 + tslib: 2.8.1 + redent@3.0.0: dependencies: indent-string: 4.0.0 @@ -3011,12 +3697,18 @@ snapshots: scheduler@0.27.0: {} + semver@5.7.2: {} + semver@6.3.1: {} semver@7.7.4: {} semver@7.8.5: {} + shallow-clone@3.0.1: + dependencies: + kind-of: 6.0.3 + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -3025,8 +3717,15 @@ snapshots: siginfo@2.0.0: {} + signal-exit@4.1.0: {} + source-map-js@1.2.1: {} + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + source-map@0.6.1: {} sprintf-js@1.0.3: {} @@ -3043,6 +3742,8 @@ snapshots: strip-json-comments@3.1.1: {} + styleq@0.2.1: {} + supports-color@7.2.0: dependencies: has-flag: 4.0.0 @@ -3055,6 +3756,8 @@ snapshots: symbol-tree@3.2.4: {} + tiny-invariant@1.3.3: {} + tinybench@2.9.0: {} tinyexec@1.3.0: {} @@ -3072,6 +3775,8 @@ snapshots: dependencies: tldts-core: 7.4.10 + tmp@0.2.7: {} + tough-cookie@6.0.2: dependencies: tldts: 7.4.10 @@ -3084,17 +3789,19 @@ snapshots: dependencies: typescript: 5.9.3 + tslib@2.8.1: {} + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 - typescript-eslint@8.66.0(eslint@9.39.5(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3): + typescript-eslint@8.66.0(eslint@9.39.5(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.66.0(@typescript-eslint/parser@8.66.0(eslint@9.39.5(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3))(eslint@9.39.5(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3) - '@typescript-eslint/parser': 8.66.0(eslint@9.39.5(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.66.0(@typescript-eslint/parser@8.66.0(eslint@9.39.5(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3) + '@typescript-eslint/parser': 8.66.0(eslint@9.39.5(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3) '@typescript-eslint/typescript-estree': 8.66.0(supports-color@8.1.1)(typescript@5.9.3) - '@typescript-eslint/utils': 8.66.0(eslint@9.39.5(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3) - eslint: 9.39.5(supports-color@8.1.1) + '@typescript-eslint/utils': 8.66.0(eslint@9.39.5(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3) + eslint: 9.39.5(jiti@2.7.0)(supports-color@8.1.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -3119,7 +3826,9 @@ snapshots: dependencies: punycode: 2.3.1 - vite-plugin-dts@4.5.4(@types/node@22.20.1)(supports-color@8.1.1)(typescript@5.9.3)(vite@8.2.0(@types/node@22.20.1)): + util-deprecate@1.0.2: {} + + vite-plugin-dts@4.5.4(@types/node@22.20.1)(supports-color@8.1.1)(typescript@5.9.3)(vite@8.2.0(@types/node@22.20.1)(jiti@2.7.0)): dependencies: '@microsoft/api-extractor': 7.58.12(@types/node@22.20.1) '@rollup/pluginutils': 5.4.0 @@ -3132,13 +3841,13 @@ snapshots: magic-string: 0.30.21 typescript: 5.9.3 optionalDependencies: - vite: 8.2.0(@types/node@22.20.1) + vite: 8.2.0(@types/node@22.20.1)(jiti@2.7.0) transitivePeerDependencies: - '@types/node' - rollup - supports-color - vite@8.2.0(@types/node@22.20.1): + vite@8.2.0(@types/node@22.20.1)(jiti@2.7.0): dependencies: lightningcss: 1.33.0 picomatch: 4.0.5 @@ -3148,11 +3857,12 @@ snapshots: optionalDependencies: '@types/node': 22.20.1 fsevents: 2.3.3 + jiti: 2.7.0 - vitest@4.1.10(@types/node@22.20.1)(jsdom@29.1.1)(vite@8.2.0(@types/node@22.20.1)): + vitest@4.1.10(@types/node@22.20.1)(jsdom@29.1.1)(vite@8.2.0(@types/node@22.20.1)(jiti@2.7.0)): dependencies: '@vitest/expect': 4.1.10 - '@vitest/mocker': 4.1.10(vite@8.2.0(@types/node@22.20.1)) + '@vitest/mocker': 4.1.10(vite@8.2.0(@types/node@22.20.1)(jiti@2.7.0)) '@vitest/pretty-format': 4.1.10 '@vitest/runner': 4.1.10 '@vitest/snapshot': 4.1.10 @@ -3169,7 +3879,7 @@ snapshots: tinyexec: 1.3.0 tinyglobby: 0.2.17 tinyrainbow: 3.1.1 - vite: 8.2.0(@types/node@22.20.1) + vite: 8.2.0(@types/node@22.20.1)(jiti@2.7.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.20.1 @@ -3206,6 +3916,11 @@ snapshots: word-wrap@1.2.5: {} + write-file-atomic@5.0.1: + dependencies: + imurmurhash: 0.1.4 + signal-exit: 4.1.0 + xml-name-validator@5.0.0: {} xmlchars@2.2.0: {} @@ -3219,3 +3934,5 @@ snapshots: zod: 3.25.76 zod@3.25.76: {} + + zod@4.6.5: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..aa15fac --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,23 @@ +# Settings for this repository's own dev install. They do not travel with the +# published package; consumers resolve @lablup/ui-common's dependencies under +# their own pnpm settings. + +# @astryxdesign/lab is a canary build. Canary releases never age out of a +# release-age quarantine by design, so it is excluded for as long as the exact +# pin exists (the same exclusion backend.ai-webui carries). +minimumReleaseAgeExclude: + - "@astryxdesign/lab@0.6.2-canary.c9fb1ad" + +peerDependencyRules: + # The lab canary declares an exact peer on the core canary it was cut from + # (`@astryxdesign/core@0.6.2-canary.c9fb1ad`). It runs against the stable + # 0.6.2 that ui-common pins, which is how backend.ai-webui installs it too. + # This only silences the mismatch warning in this repository's dev install. + allowedVersions: + "@astryxdesign/lab>@astryxdesign/core": "0.6.2" + +allowBuilds: + # Each postinstall only prints an `astryx init` nudge; no build output is + # needed. Listing them keeps pnpm from failing with ERR_PNPM_IGNORED_BUILDS. + "@astryxdesign/cli": false + "@astryxdesign/core": false From 8095cdcad730dff6fa268a742566e8b66efc4c48 Mon Sep 17 00:00:00 2001 From: Jongeun Date: Fri, 25 Sep 2026 15:22:43 +0000 Subject: [PATCH 02/87] feat!: mirror the Astryx export surface through a generator scripts/gen-exports.mjs mirrors every subpath of @astryxdesign/core 1:1 (lab under ./lab, theme-neutral under ./theme/neutral) as one-line re-export files in src/astryx, writes the root barrel as explicit named re-exports of core's root plus the customs in exports.customs.json, and writes package.json#exports. exports.exclude.json is the only way to hide a subpath; Dialog, docs.mjs and groups.doc.mjs are excluded. src/exports.test.ts regenerates in memory and byte-compares with the committed output, so every Astryx bump fails until the generator is re-run. BREAKING CHANGE: the root barrel now exports Astryx's Badge, Button, EmptyState, ProgressBar, Skeleton and Tooltip; the old customs of those names stay at @lablup/ui-common/components/ until 0.3. @lablup/ui-common/hooks is now the Astryx hooks mirror; usePrefersReducedMotion moves to the root barrel only. check:pack now also fails when a packed module imports a package that is neither a dependency nor a peer, and when an Astryx locale catalog is not mirrored. --- exports.customs.json | 69 ++ exports.exclude.json | 17 + package.json | 502 ++++++++- scripts/check-pack.mjs | 51 +- scripts/gen-exports.d.mts | 27 + scripts/gen-exports.mjs | 543 ++++++++++ src/astryx/AlertDialog.ts | 2 + src/astryx/AppShell.ts | 2 + src/astryx/AspectRatio.ts | 2 + src/astryx/Avatar.ts | 2 + src/astryx/AvatarGroup.ts | 2 + src/astryx/Badge.ts | 2 + src/astryx/Banner.ts | 2 + src/astryx/BaseProps.ts | 2 + src/astryx/Blockquote.ts | 2 + src/astryx/BottomSheet.ts | 2 + src/astryx/Breadcrumbs.ts | 2 + src/astryx/Button.ts | 2 + src/astryx/ButtonGroup.ts | 2 + src/astryx/Calendar.ts | 2 + src/astryx/Calendar/utils.ts | 2 + src/astryx/Card.ts | 2 + src/astryx/Carousel.ts | 2 + src/astryx/Center.ts | 2 + src/astryx/Chat.ts | 2 + src/astryx/CheckboxInput.ts | 2 + src/astryx/CheckboxList.ts | 2 + src/astryx/Citation.ts | 2 + src/astryx/ClickableCard.ts | 2 + src/astryx/Code.ts | 2 + src/astryx/CodeBlock.ts | 2 + src/astryx/Collapsible.ts | 2 + src/astryx/CommandPalette.ts | 2 + src/astryx/ComplexSelector.ts | 2 + src/astryx/ContextMenu.ts | 2 + src/astryx/DateInput.ts | 2 + src/astryx/DateRangeInput.ts | 2 + src/astryx/DateTimeInput.ts | 2 + src/astryx/Divider.ts | 2 + src/astryx/DropdownMenu.ts | 2 + src/astryx/EmptyState.ts | 2 + src/astryx/Field.ts | 2 + src/astryx/FieldStatus.ts | 2 + src/astryx/FileInput.ts | 2 + src/astryx/FormLayout.ts | 2 + src/astryx/Grid.ts | 2 + src/astryx/HStack.ts | 2 + src/astryx/Heading.ts | 2 + src/astryx/HoverCard.ts | 2 + src/astryx/Icon.ts | 2 + src/astryx/IconButton.ts | 2 + src/astryx/Indicator.ts | 2 + src/astryx/InputGroup.ts | 2 + src/astryx/InteractiveRoleContext.ts | 2 + src/astryx/Item.ts | 2 + src/astryx/Kbd.ts | 2 + src/astryx/Layer.ts | 2 + src/astryx/Layout.ts | 2 + src/astryx/Lightbox.ts | 2 + src/astryx/Link.ts | 2 + src/astryx/List.ts | 2 + src/astryx/Markdown.ts | 2 + src/astryx/Markdown/utils.ts | 2 + src/astryx/MetadataList.ts | 2 + src/astryx/MobileNav.ts | 2 + src/astryx/MoreMenu.ts | 2 + src/astryx/MultiSelector.ts | 2 + src/astryx/NavIcon.ts | 2 + src/astryx/NavMenu.ts | 2 + src/astryx/NumberInput.ts | 2 + src/astryx/Outline.ts | 2 + src/astryx/OverflowList.ts | 2 + src/astryx/Overlay.ts | 2 + src/astryx/Pagination.ts | 2 + src/astryx/Popover.ts | 2 + src/astryx/PowerSearch.ts | 2 + src/astryx/PowerSearch/utils.ts | 2 + src/astryx/ProgressBar.ts | 2 + src/astryx/RadioList.ts | 2 + src/astryx/Resizable.ts | 2 + src/astryx/Resizable/utils.ts | 2 + src/astryx/ScrollableArea.ts | 2 + src/astryx/Section.ts | 2 + src/astryx/SegmentedControl.ts | 2 + src/astryx/SelectableCard.ts | 2 + src/astryx/Selector.ts | 2 + src/astryx/Selector/utils.ts | 2 + src/astryx/SideNav.ts | 2 + src/astryx/SizeContext.ts | 2 + src/astryx/Skeleton.ts | 2 + src/astryx/Slider.ts | 2 + src/astryx/Spinner.ts | 2 + src/astryx/Stack.ts | 2 + src/astryx/StatusDot.ts | 2 + src/astryx/Stepper.ts | 2 + src/astryx/Switch.ts | 2 + src/astryx/TabList.ts | 2 + src/astryx/Table.ts | 2 + src/astryx/Table/utils.ts | 2 + src/astryx/Text.ts | 2 + src/astryx/TextArea.ts | 2 + src/astryx/TextInput.ts | 2 + src/astryx/Thumbnail.ts | 2 + src/astryx/TimeInput.ts | 2 + src/astryx/Timestamp.ts | 2 + src/astryx/Toast.ts | 2 + src/astryx/ToggleButton.ts | 2 + src/astryx/Token.ts | 2 + src/astryx/Tokenizer.ts | 2 + src/astryx/Toolbar.ts | 2 + src/astryx/Tooltip.ts | 2 + src/astryx/TopNav.ts | 2 + src/astryx/TreeList.ts | 2 + src/astryx/Typeahead.ts | 2 + src/astryx/Typeahead/utils.ts | 2 + src/astryx/VStack.ts | 2 + src/astryx/VisuallyHidden.ts | 2 + src/astryx/astryx.css | 2 + src/astryx/hooks.ts | 2 + src/astryx/i18n.ts | 2 + src/astryx/lab.ts | 2 + src/astryx/lab/lab.css | 2 + src/astryx/naming.ts | 2 + src/astryx/reset.css | 2 + src/astryx/tailwind-theme.css | 2 + src/astryx/theme.ts | 2 + src/astryx/theme/neutral.ts | 2 + src/astryx/theme/neutral/built.ts | 2 + src/astryx/theme/neutral/theme.css | 2 + src/astryx/theme/syntax.ts | 2 + src/astryx/theme/tokens.stylex.ts | 2 + src/astryx/theme/tokens.ts | 2 + src/astryx/utils.ts | 2 + src/exports.test.ts | 112 ++ src/index.ts | 1445 +++++++++++++++++++++++++- vite.config.ts | 71 +- 136 files changed, 3032 insertions(+), 59 deletions(-) create mode 100644 exports.customs.json create mode 100644 exports.exclude.json create mode 100644 scripts/gen-exports.d.mts create mode 100644 scripts/gen-exports.mjs create mode 100644 src/astryx/AlertDialog.ts create mode 100644 src/astryx/AppShell.ts create mode 100644 src/astryx/AspectRatio.ts create mode 100644 src/astryx/Avatar.ts create mode 100644 src/astryx/AvatarGroup.ts create mode 100644 src/astryx/Badge.ts create mode 100644 src/astryx/Banner.ts create mode 100644 src/astryx/BaseProps.ts create mode 100644 src/astryx/Blockquote.ts create mode 100644 src/astryx/BottomSheet.ts create mode 100644 src/astryx/Breadcrumbs.ts create mode 100644 src/astryx/Button.ts create mode 100644 src/astryx/ButtonGroup.ts create mode 100644 src/astryx/Calendar.ts create mode 100644 src/astryx/Calendar/utils.ts create mode 100644 src/astryx/Card.ts create mode 100644 src/astryx/Carousel.ts create mode 100644 src/astryx/Center.ts create mode 100644 src/astryx/Chat.ts create mode 100644 src/astryx/CheckboxInput.ts create mode 100644 src/astryx/CheckboxList.ts create mode 100644 src/astryx/Citation.ts create mode 100644 src/astryx/ClickableCard.ts create mode 100644 src/astryx/Code.ts create mode 100644 src/astryx/CodeBlock.ts create mode 100644 src/astryx/Collapsible.ts create mode 100644 src/astryx/CommandPalette.ts create mode 100644 src/astryx/ComplexSelector.ts create mode 100644 src/astryx/ContextMenu.ts create mode 100644 src/astryx/DateInput.ts create mode 100644 src/astryx/DateRangeInput.ts create mode 100644 src/astryx/DateTimeInput.ts create mode 100644 src/astryx/Divider.ts create mode 100644 src/astryx/DropdownMenu.ts create mode 100644 src/astryx/EmptyState.ts create mode 100644 src/astryx/Field.ts create mode 100644 src/astryx/FieldStatus.ts create mode 100644 src/astryx/FileInput.ts create mode 100644 src/astryx/FormLayout.ts create mode 100644 src/astryx/Grid.ts create mode 100644 src/astryx/HStack.ts create mode 100644 src/astryx/Heading.ts create mode 100644 src/astryx/HoverCard.ts create mode 100644 src/astryx/Icon.ts create mode 100644 src/astryx/IconButton.ts create mode 100644 src/astryx/Indicator.ts create mode 100644 src/astryx/InputGroup.ts create mode 100644 src/astryx/InteractiveRoleContext.ts create mode 100644 src/astryx/Item.ts create mode 100644 src/astryx/Kbd.ts create mode 100644 src/astryx/Layer.ts create mode 100644 src/astryx/Layout.ts create mode 100644 src/astryx/Lightbox.ts create mode 100644 src/astryx/Link.ts create mode 100644 src/astryx/List.ts create mode 100644 src/astryx/Markdown.ts create mode 100644 src/astryx/Markdown/utils.ts create mode 100644 src/astryx/MetadataList.ts create mode 100644 src/astryx/MobileNav.ts create mode 100644 src/astryx/MoreMenu.ts create mode 100644 src/astryx/MultiSelector.ts create mode 100644 src/astryx/NavIcon.ts create mode 100644 src/astryx/NavMenu.ts create mode 100644 src/astryx/NumberInput.ts create mode 100644 src/astryx/Outline.ts create mode 100644 src/astryx/OverflowList.ts create mode 100644 src/astryx/Overlay.ts create mode 100644 src/astryx/Pagination.ts create mode 100644 src/astryx/Popover.ts create mode 100644 src/astryx/PowerSearch.ts create mode 100644 src/astryx/PowerSearch/utils.ts create mode 100644 src/astryx/ProgressBar.ts create mode 100644 src/astryx/RadioList.ts create mode 100644 src/astryx/Resizable.ts create mode 100644 src/astryx/Resizable/utils.ts create mode 100644 src/astryx/ScrollableArea.ts create mode 100644 src/astryx/Section.ts create mode 100644 src/astryx/SegmentedControl.ts create mode 100644 src/astryx/SelectableCard.ts create mode 100644 src/astryx/Selector.ts create mode 100644 src/astryx/Selector/utils.ts create mode 100644 src/astryx/SideNav.ts create mode 100644 src/astryx/SizeContext.ts create mode 100644 src/astryx/Skeleton.ts create mode 100644 src/astryx/Slider.ts create mode 100644 src/astryx/Spinner.ts create mode 100644 src/astryx/Stack.ts create mode 100644 src/astryx/StatusDot.ts create mode 100644 src/astryx/Stepper.ts create mode 100644 src/astryx/Switch.ts create mode 100644 src/astryx/TabList.ts create mode 100644 src/astryx/Table.ts create mode 100644 src/astryx/Table/utils.ts create mode 100644 src/astryx/Text.ts create mode 100644 src/astryx/TextArea.ts create mode 100644 src/astryx/TextInput.ts create mode 100644 src/astryx/Thumbnail.ts create mode 100644 src/astryx/TimeInput.ts create mode 100644 src/astryx/Timestamp.ts create mode 100644 src/astryx/Toast.ts create mode 100644 src/astryx/ToggleButton.ts create mode 100644 src/astryx/Token.ts create mode 100644 src/astryx/Tokenizer.ts create mode 100644 src/astryx/Toolbar.ts create mode 100644 src/astryx/Tooltip.ts create mode 100644 src/astryx/TopNav.ts create mode 100644 src/astryx/TreeList.ts create mode 100644 src/astryx/Typeahead.ts create mode 100644 src/astryx/Typeahead/utils.ts create mode 100644 src/astryx/VStack.ts create mode 100644 src/astryx/VisuallyHidden.ts create mode 100644 src/astryx/astryx.css create mode 100644 src/astryx/hooks.ts create mode 100644 src/astryx/i18n.ts create mode 100644 src/astryx/lab.ts create mode 100644 src/astryx/lab/lab.css create mode 100644 src/astryx/naming.ts create mode 100644 src/astryx/reset.css create mode 100644 src/astryx/tailwind-theme.css create mode 100644 src/astryx/theme.ts create mode 100644 src/astryx/theme/neutral.ts create mode 100644 src/astryx/theme/neutral/built.ts create mode 100644 src/astryx/theme/neutral/theme.css create mode 100644 src/astryx/theme/syntax.ts create mode 100644 src/astryx/theme/tokens.stylex.ts create mode 100644 src/astryx/theme/tokens.ts create mode 100644 src/astryx/utils.ts create mode 100644 src/exports.test.ts diff --git a/exports.customs.json b/exports.customs.json new file mode 100644 index 0000000..51b819a --- /dev/null +++ b/exports.customs.json @@ -0,0 +1,69 @@ +[ + { + "name": "BaseCard", + "source": "components/BaseCard/index.ts", + "legacy": { "replacedBy": "Card" } + }, + { + "name": "Badge", + "source": "components/Badge/index.ts", + "legacy": { "replacedBy": "Badge" } + }, + { + "name": "Button", + "source": "components/Button/index.ts", + "legacy": { "replacedBy": "Button" } + }, + { + "name": "DataTable", + "source": "components/DataTable/index.ts", + "legacy": { "replacedBy": "Table" } + }, + { "name": "DigitPopIn", "source": "components/DigitPopIn/index.ts" }, + { + "name": "Drawer", + "source": "components/Drawer/index.ts", + "legacy": { "replacedBy": "lab/Drawer" } + }, + { + "name": "EmptyState", + "source": "components/EmptyState/index.ts", + "legacy": { "replacedBy": "EmptyState" } + }, + { "name": "ErrorState", "source": "components/ErrorState/index.ts" }, + { "name": "PageHeader", "source": "components/PageHeader/index.ts" }, + { "name": "PageLayout", "source": "components/PageLayout/index.ts" }, + { + "name": "ProgressBar", + "source": "components/ProgressBar/index.ts", + "legacy": { "replacedBy": "ProgressBar" } + }, + { + "name": "Skeleton", + "source": "components/Skeleton/index.ts", + "legacy": { "replacedBy": "Skeleton", "names": ["Skeleton", "SkeletonProps"] } + }, + { "name": "SmoothHeight", "source": "components/SmoothHeight/index.ts" }, + { + "name": "Select", + "source": "components/Select/index.ts", + "legacy": { "replacedBy": "Selector" } + }, + { "name": "StatCard", "source": "components/StatCard/index.ts" }, + { + "name": "StatusTag", + "source": "components/StatusTag/index.ts", + "legacy": { "replacedBy": "StatusDot" } + }, + { + "name": "Tabs", + "source": "components/Tabs/index.ts", + "legacy": { "replacedBy": "TabList" } + }, + { + "name": "Tooltip", + "source": "components/Tooltip/index.ts", + "legacy": { "replacedBy": "Tooltip" } + }, + { "name": "usePrefersReducedMotion", "source": "hooks/index.ts" } +] diff --git a/exports.exclude.json b/exports.exclude.json new file mode 100644 index 0000000..5b6103b --- /dev/null +++ b/exports.exclude.json @@ -0,0 +1,17 @@ +[ + { + "name": "Dialog", + "replacedBy": "Modal", + "reason": "ui-common ships Modal as the one dialog surface. Hiding Dialog keeps consumers from building a second, differently behaved dialog." + }, + { + "name": "docs.mjs", + "replacedBy": null, + "reason": "Documentation data for the Astryx CLI, not runtime API. The CLI reads it from @astryxdesign/core directly." + }, + { + "name": "groups.doc.mjs", + "replacedBy": null, + "reason": "Documentation data for the Astryx CLI, not runtime API. The CLI reads it from @astryxdesign/core directly." + } +] diff --git a/package.json b/package.json index 0d8b25a..88681ea 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "author": "Lablup Inc.", "type": "module", "sideEffects": [ - "**/*.css" + "**/*.css", + "**/*.stylex.js" ], "repository": { "type": "git", @@ -37,12 +38,502 @@ "types": "./dist/components/*/index.d.ts", "import": "./dist/components/*/index.js" }, - "./hooks": { - "types": "./dist/hooks/index.d.ts", - "import": "./dist/hooks/index.js" - }, "./styles/base.css": "./dist/styles/base.css", "./styles/themes/*.css": "./dist/styles/themes/*.css", + "./reset.css": "./dist/astryx/reset.css", + "./astryx.css": "./dist/astryx/astryx.css", + "./tailwind-theme.css": "./dist/astryx/tailwind-theme.css", + "./BaseProps": { + "types": "./dist/astryx/BaseProps.d.ts", + "import": "./dist/astryx/BaseProps.js" + }, + "./naming": { + "types": "./dist/astryx/naming.d.ts", + "import": "./dist/astryx/naming.js" + }, + "./theme/tokens": { + "types": "./dist/astryx/theme/tokens.d.ts", + "import": "./dist/astryx/theme/tokens.js" + }, + "./theme/tokens.stylex": { + "types": "./dist/astryx/theme/tokens.stylex.d.ts", + "import": "./dist/astryx/theme/tokens.stylex.js" + }, + "./theme/syntax": { + "types": "./dist/astryx/theme/syntax.d.ts", + "import": "./dist/astryx/theme/syntax.js" + }, + "./locales/*.json": "./dist/locales/*.json", + "./AlertDialog": { + "types": "./dist/astryx/AlertDialog.d.ts", + "import": "./dist/astryx/AlertDialog.js" + }, + "./AppShell": { + "types": "./dist/astryx/AppShell.d.ts", + "import": "./dist/astryx/AppShell.js" + }, + "./AspectRatio": { + "types": "./dist/astryx/AspectRatio.d.ts", + "import": "./dist/astryx/AspectRatio.js" + }, + "./Avatar": { + "types": "./dist/astryx/Avatar.d.ts", + "import": "./dist/astryx/Avatar.js" + }, + "./AvatarGroup": { + "types": "./dist/astryx/AvatarGroup.d.ts", + "import": "./dist/astryx/AvatarGroup.js" + }, + "./Badge": { + "types": "./dist/astryx/Badge.d.ts", + "import": "./dist/astryx/Badge.js" + }, + "./Banner": { + "types": "./dist/astryx/Banner.d.ts", + "import": "./dist/astryx/Banner.js" + }, + "./Blockquote": { + "types": "./dist/astryx/Blockquote.d.ts", + "import": "./dist/astryx/Blockquote.js" + }, + "./BottomSheet": { + "types": "./dist/astryx/BottomSheet.d.ts", + "import": "./dist/astryx/BottomSheet.js" + }, + "./Breadcrumbs": { + "types": "./dist/astryx/Breadcrumbs.d.ts", + "import": "./dist/astryx/Breadcrumbs.js" + }, + "./Button": { + "types": "./dist/astryx/Button.d.ts", + "import": "./dist/astryx/Button.js" + }, + "./ButtonGroup": { + "types": "./dist/astryx/ButtonGroup.d.ts", + "import": "./dist/astryx/ButtonGroup.js" + }, + "./Calendar": { + "types": "./dist/astryx/Calendar.d.ts", + "import": "./dist/astryx/Calendar.js" + }, + "./Card": { + "types": "./dist/astryx/Card.d.ts", + "import": "./dist/astryx/Card.js" + }, + "./Carousel": { + "types": "./dist/astryx/Carousel.d.ts", + "import": "./dist/astryx/Carousel.js" + }, + "./Center": { + "types": "./dist/astryx/Center.d.ts", + "import": "./dist/astryx/Center.js" + }, + "./Chat": { + "types": "./dist/astryx/Chat.d.ts", + "import": "./dist/astryx/Chat.js" + }, + "./CheckboxInput": { + "types": "./dist/astryx/CheckboxInput.d.ts", + "import": "./dist/astryx/CheckboxInput.js" + }, + "./CheckboxList": { + "types": "./dist/astryx/CheckboxList.d.ts", + "import": "./dist/astryx/CheckboxList.js" + }, + "./Citation": { + "types": "./dist/astryx/Citation.d.ts", + "import": "./dist/astryx/Citation.js" + }, + "./ClickableCard": { + "types": "./dist/astryx/ClickableCard.d.ts", + "import": "./dist/astryx/ClickableCard.js" + }, + "./Code": { + "types": "./dist/astryx/Code.d.ts", + "import": "./dist/astryx/Code.js" + }, + "./CodeBlock": { + "types": "./dist/astryx/CodeBlock.d.ts", + "import": "./dist/astryx/CodeBlock.js" + }, + "./Collapsible": { + "types": "./dist/astryx/Collapsible.d.ts", + "import": "./dist/astryx/Collapsible.js" + }, + "./CommandPalette": { + "types": "./dist/astryx/CommandPalette.d.ts", + "import": "./dist/astryx/CommandPalette.js" + }, + "./ComplexSelector": { + "types": "./dist/astryx/ComplexSelector.d.ts", + "import": "./dist/astryx/ComplexSelector.js" + }, + "./ContextMenu": { + "types": "./dist/astryx/ContextMenu.d.ts", + "import": "./dist/astryx/ContextMenu.js" + }, + "./DateInput": { + "types": "./dist/astryx/DateInput.d.ts", + "import": "./dist/astryx/DateInput.js" + }, + "./DateRangeInput": { + "types": "./dist/astryx/DateRangeInput.d.ts", + "import": "./dist/astryx/DateRangeInput.js" + }, + "./DateTimeInput": { + "types": "./dist/astryx/DateTimeInput.d.ts", + "import": "./dist/astryx/DateTimeInput.js" + }, + "./Divider": { + "types": "./dist/astryx/Divider.d.ts", + "import": "./dist/astryx/Divider.js" + }, + "./DropdownMenu": { + "types": "./dist/astryx/DropdownMenu.d.ts", + "import": "./dist/astryx/DropdownMenu.js" + }, + "./EmptyState": { + "types": "./dist/astryx/EmptyState.d.ts", + "import": "./dist/astryx/EmptyState.js" + }, + "./Field": { + "types": "./dist/astryx/Field.d.ts", + "import": "./dist/astryx/Field.js" + }, + "./FieldStatus": { + "types": "./dist/astryx/FieldStatus.d.ts", + "import": "./dist/astryx/FieldStatus.js" + }, + "./FileInput": { + "types": "./dist/astryx/FileInput.d.ts", + "import": "./dist/astryx/FileInput.js" + }, + "./FormLayout": { + "types": "./dist/astryx/FormLayout.d.ts", + "import": "./dist/astryx/FormLayout.js" + }, + "./Grid": { + "types": "./dist/astryx/Grid.d.ts", + "import": "./dist/astryx/Grid.js" + }, + "./HStack": { + "types": "./dist/astryx/HStack.d.ts", + "import": "./dist/astryx/HStack.js" + }, + "./Heading": { + "types": "./dist/astryx/Heading.d.ts", + "import": "./dist/astryx/Heading.js" + }, + "./HoverCard": { + "types": "./dist/astryx/HoverCard.d.ts", + "import": "./dist/astryx/HoverCard.js" + }, + "./Icon": { + "types": "./dist/astryx/Icon.d.ts", + "import": "./dist/astryx/Icon.js" + }, + "./IconButton": { + "types": "./dist/astryx/IconButton.d.ts", + "import": "./dist/astryx/IconButton.js" + }, + "./Indicator": { + "types": "./dist/astryx/Indicator.d.ts", + "import": "./dist/astryx/Indicator.js" + }, + "./InputGroup": { + "types": "./dist/astryx/InputGroup.d.ts", + "import": "./dist/astryx/InputGroup.js" + }, + "./InteractiveRoleContext": { + "types": "./dist/astryx/InteractiveRoleContext.d.ts", + "import": "./dist/astryx/InteractiveRoleContext.js" + }, + "./Item": { + "types": "./dist/astryx/Item.d.ts", + "import": "./dist/astryx/Item.js" + }, + "./Kbd": { + "types": "./dist/astryx/Kbd.d.ts", + "import": "./dist/astryx/Kbd.js" + }, + "./Layer": { + "types": "./dist/astryx/Layer.d.ts", + "import": "./dist/astryx/Layer.js" + }, + "./Layout": { + "types": "./dist/astryx/Layout.d.ts", + "import": "./dist/astryx/Layout.js" + }, + "./Lightbox": { + "types": "./dist/astryx/Lightbox.d.ts", + "import": "./dist/astryx/Lightbox.js" + }, + "./Link": { + "types": "./dist/astryx/Link.d.ts", + "import": "./dist/astryx/Link.js" + }, + "./List": { + "types": "./dist/astryx/List.d.ts", + "import": "./dist/astryx/List.js" + }, + "./Markdown": { + "types": "./dist/astryx/Markdown.d.ts", + "import": "./dist/astryx/Markdown.js" + }, + "./MetadataList": { + "types": "./dist/astryx/MetadataList.d.ts", + "import": "./dist/astryx/MetadataList.js" + }, + "./MobileNav": { + "types": "./dist/astryx/MobileNav.d.ts", + "import": "./dist/astryx/MobileNav.js" + }, + "./MoreMenu": { + "types": "./dist/astryx/MoreMenu.d.ts", + "import": "./dist/astryx/MoreMenu.js" + }, + "./MultiSelector": { + "types": "./dist/astryx/MultiSelector.d.ts", + "import": "./dist/astryx/MultiSelector.js" + }, + "./NavIcon": { + "types": "./dist/astryx/NavIcon.d.ts", + "import": "./dist/astryx/NavIcon.js" + }, + "./NavMenu": { + "types": "./dist/astryx/NavMenu.d.ts", + "import": "./dist/astryx/NavMenu.js" + }, + "./NumberInput": { + "types": "./dist/astryx/NumberInput.d.ts", + "import": "./dist/astryx/NumberInput.js" + }, + "./Outline": { + "types": "./dist/astryx/Outline.d.ts", + "import": "./dist/astryx/Outline.js" + }, + "./OverflowList": { + "types": "./dist/astryx/OverflowList.d.ts", + "import": "./dist/astryx/OverflowList.js" + }, + "./Overlay": { + "types": "./dist/astryx/Overlay.d.ts", + "import": "./dist/astryx/Overlay.js" + }, + "./Pagination": { + "types": "./dist/astryx/Pagination.d.ts", + "import": "./dist/astryx/Pagination.js" + }, + "./Popover": { + "types": "./dist/astryx/Popover.d.ts", + "import": "./dist/astryx/Popover.js" + }, + "./PowerSearch": { + "types": "./dist/astryx/PowerSearch.d.ts", + "import": "./dist/astryx/PowerSearch.js" + }, + "./ProgressBar": { + "types": "./dist/astryx/ProgressBar.d.ts", + "import": "./dist/astryx/ProgressBar.js" + }, + "./RadioList": { + "types": "./dist/astryx/RadioList.d.ts", + "import": "./dist/astryx/RadioList.js" + }, + "./Resizable": { + "types": "./dist/astryx/Resizable.d.ts", + "import": "./dist/astryx/Resizable.js" + }, + "./ScrollableArea": { + "types": "./dist/astryx/ScrollableArea.d.ts", + "import": "./dist/astryx/ScrollableArea.js" + }, + "./Section": { + "types": "./dist/astryx/Section.d.ts", + "import": "./dist/astryx/Section.js" + }, + "./SegmentedControl": { + "types": "./dist/astryx/SegmentedControl.d.ts", + "import": "./dist/astryx/SegmentedControl.js" + }, + "./SelectableCard": { + "types": "./dist/astryx/SelectableCard.d.ts", + "import": "./dist/astryx/SelectableCard.js" + }, + "./Selector": { + "types": "./dist/astryx/Selector.d.ts", + "import": "./dist/astryx/Selector.js" + }, + "./SideNav": { + "types": "./dist/astryx/SideNav.d.ts", + "import": "./dist/astryx/SideNav.js" + }, + "./SizeContext": { + "types": "./dist/astryx/SizeContext.d.ts", + "import": "./dist/astryx/SizeContext.js" + }, + "./Skeleton": { + "types": "./dist/astryx/Skeleton.d.ts", + "import": "./dist/astryx/Skeleton.js" + }, + "./Slider": { + "types": "./dist/astryx/Slider.d.ts", + "import": "./dist/astryx/Slider.js" + }, + "./Spinner": { + "types": "./dist/astryx/Spinner.d.ts", + "import": "./dist/astryx/Spinner.js" + }, + "./Stack": { + "types": "./dist/astryx/Stack.d.ts", + "import": "./dist/astryx/Stack.js" + }, + "./StatusDot": { + "types": "./dist/astryx/StatusDot.d.ts", + "import": "./dist/astryx/StatusDot.js" + }, + "./Stepper": { + "types": "./dist/astryx/Stepper.d.ts", + "import": "./dist/astryx/Stepper.js" + }, + "./Switch": { + "types": "./dist/astryx/Switch.d.ts", + "import": "./dist/astryx/Switch.js" + }, + "./TabList": { + "types": "./dist/astryx/TabList.d.ts", + "import": "./dist/astryx/TabList.js" + }, + "./Table": { + "types": "./dist/astryx/Table.d.ts", + "import": "./dist/astryx/Table.js" + }, + "./Text": { + "types": "./dist/astryx/Text.d.ts", + "import": "./dist/astryx/Text.js" + }, + "./TextArea": { + "types": "./dist/astryx/TextArea.d.ts", + "import": "./dist/astryx/TextArea.js" + }, + "./TextInput": { + "types": "./dist/astryx/TextInput.d.ts", + "import": "./dist/astryx/TextInput.js" + }, + "./Thumbnail": { + "types": "./dist/astryx/Thumbnail.d.ts", + "import": "./dist/astryx/Thumbnail.js" + }, + "./TimeInput": { + "types": "./dist/astryx/TimeInput.d.ts", + "import": "./dist/astryx/TimeInput.js" + }, + "./Timestamp": { + "types": "./dist/astryx/Timestamp.d.ts", + "import": "./dist/astryx/Timestamp.js" + }, + "./Toast": { + "types": "./dist/astryx/Toast.d.ts", + "import": "./dist/astryx/Toast.js" + }, + "./ToggleButton": { + "types": "./dist/astryx/ToggleButton.d.ts", + "import": "./dist/astryx/ToggleButton.js" + }, + "./Token": { + "types": "./dist/astryx/Token.d.ts", + "import": "./dist/astryx/Token.js" + }, + "./Tokenizer": { + "types": "./dist/astryx/Tokenizer.d.ts", + "import": "./dist/astryx/Tokenizer.js" + }, + "./Toolbar": { + "types": "./dist/astryx/Toolbar.d.ts", + "import": "./dist/astryx/Toolbar.js" + }, + "./Tooltip": { + "types": "./dist/astryx/Tooltip.d.ts", + "import": "./dist/astryx/Tooltip.js" + }, + "./TopNav": { + "types": "./dist/astryx/TopNav.d.ts", + "import": "./dist/astryx/TopNav.js" + }, + "./TreeList": { + "types": "./dist/astryx/TreeList.d.ts", + "import": "./dist/astryx/TreeList.js" + }, + "./Typeahead": { + "types": "./dist/astryx/Typeahead.d.ts", + "import": "./dist/astryx/Typeahead.js" + }, + "./VStack": { + "types": "./dist/astryx/VStack.d.ts", + "import": "./dist/astryx/VStack.js" + }, + "./VisuallyHidden": { + "types": "./dist/astryx/VisuallyHidden.d.ts", + "import": "./dist/astryx/VisuallyHidden.js" + }, + "./hooks": { + "types": "./dist/astryx/hooks.d.ts", + "import": "./dist/astryx/hooks.js" + }, + "./i18n": { + "types": "./dist/astryx/i18n.d.ts", + "import": "./dist/astryx/i18n.js" + }, + "./theme": { + "types": "./dist/astryx/theme.d.ts", + "import": "./dist/astryx/theme.js" + }, + "./utils": { + "types": "./dist/astryx/utils.d.ts", + "import": "./dist/astryx/utils.js" + }, + "./Calendar/utils": { + "types": "./dist/astryx/Calendar/utils.d.ts", + "import": "./dist/astryx/Calendar/utils.js" + }, + "./Markdown/utils": { + "types": "./dist/astryx/Markdown/utils.d.ts", + "import": "./dist/astryx/Markdown/utils.js" + }, + "./PowerSearch/utils": { + "types": "./dist/astryx/PowerSearch/utils.d.ts", + "import": "./dist/astryx/PowerSearch/utils.js" + }, + "./Resizable/utils": { + "types": "./dist/astryx/Resizable/utils.d.ts", + "import": "./dist/astryx/Resizable/utils.js" + }, + "./Selector/utils": { + "types": "./dist/astryx/Selector/utils.d.ts", + "import": "./dist/astryx/Selector/utils.js" + }, + "./Table/utils": { + "types": "./dist/astryx/Table/utils.d.ts", + "import": "./dist/astryx/Table/utils.js" + }, + "./Typeahead/utils": { + "types": "./dist/astryx/Typeahead/utils.d.ts", + "import": "./dist/astryx/Typeahead/utils.js" + }, + "./lab": { + "types": "./dist/astryx/lab.d.ts", + "import": "./dist/astryx/lab.js" + }, + "./lab/lab.css": "./dist/astryx/lab/lab.css", + "./theme/neutral": { + "types": "./dist/astryx/theme/neutral.d.ts", + "import": "./dist/astryx/theme/neutral.js" + }, + "./theme/neutral/built": { + "types": "./dist/astryx/theme/neutral/built.d.ts", + "import": "./dist/astryx/theme/neutral/built.js" + }, + "./theme/neutral/theme.css": "./dist/astryx/theme/neutral/theme.css", "./package.json": "./package.json" }, "scripts": { @@ -53,6 +544,7 @@ "format:check": "prettier --check .", "test": "vitest run", "test:watch": "vitest", + "gen:exports": "node scripts/gen-exports.mjs", "check:pack": "node scripts/check-pack.mjs", "check:boundary": "node scripts/check-boundary.mjs", "verify": "pnpm run typecheck && pnpm run lint && pnpm run format:check && pnpm run check:boundary && pnpm run test && pnpm run build && pnpm run check:pack" diff --git a/scripts/check-pack.mjs b/scripts/check-pack.mjs index e1e04ba..66ada02 100644 --- a/scripts/check-pack.mjs +++ b/scripts/check-pack.mjs @@ -15,7 +15,7 @@ * files that arrive advertised by no one. */ import { execFileSync } from "node:child_process"; -import { readFile } from "node:fs/promises"; +import { readFile, readdir } from "node:fs/promises"; import { fileURLToPath } from "node:url"; import { dirname, resolve } from "node:path"; import { @@ -142,6 +142,55 @@ for (const stylesheet of packed.filter((f) => f.endsWith(".css"))) { ); } +/** + * Every bare specifier a packed module imports has to be something the + * consumer is guaranteed to install: a dependency or a peer. Astryx and + * StyleX are external on purpose, so a missing declaration does not fail the + * build; it fails at the consumer, as an unresolvable import, and only for + * the subpath that happens to reach it. + */ +const declared = new Set([ + ...Object.keys(pkg.dependencies ?? {}), + ...Object.keys(pkg.peerDependencies ?? {}), +]); + +function packageOf(specifier) { + const parts = specifier.split("/"); + return specifier.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0]; +} + +const bareImport = + /(?:\bimport\s*(?:[\w*{}\s,$]+\s*from\s*)?|\bexport\s*[\w*{}\s,$]+\s*from\s*|\bimport\s*\()\s*["']([^"'./][^"']*)["']/g; + +for (const file of packed.filter((f) => f.endsWith(".js"))) { + const code = await readFile(resolve(root, file), "utf8"); + for (const [, specifier] of code.matchAll(bareImport)) { + if (specifier.startsWith("node:")) continue; + const name = packageOf(specifier); + if (!declared.has(name)) { + failures.push( + `"${file}" imports "${specifier}", but "${name}" is neither a dependency nor a ` + + `peer, so a consumer is not guaranteed to have it`, + ); + } + } +} + +/** + * `locales/*.json` mirrors Astryx core's catalogs. A pattern export only has + * to match one file to pass the check above, so compare the whole set. + */ +const coreLocales = ( + await readdir(resolve(root, "node_modules/@astryxdesign/core/locales")) +).filter((f) => f.endsWith(".json")); +for (const locale of coreLocales) { + if (!packedSet.has(`dist/locales/${locale}`)) { + failures.push( + `Astryx locale "${locale}" is not mirrored at dist/locales/${locale}`, + ); + } +} + if (failures.length > 0) { console.error(`Packed artifact check failed (${failures.length}):\n`); for (const f of failures) console.error(` ${f}`); diff --git a/scripts/gen-exports.d.mts b/scripts/gen-exports.d.mts new file mode 100644 index 0000000..85333a8 --- /dev/null +++ b/scripts/gen-exports.d.mts @@ -0,0 +1,27 @@ +/** Types for the export-surface generator, so tests can import it. */ + +export declare const MIRROR_DIR: string; + +export declare const MIRRORED_PACKAGES: readonly { name: string; prefix: string }[]; + +export type ExportTarget = string | { types: string; import: string }; + +export declare const OWN_EXPORTS: Record; + +export interface GenerateResult { + /** Repository-relative path to generated file content. */ + files: Map; + /** The complete `exports` map for package.json. */ + exports: Record; + report: { + /** Core root names left out because their subpath is excluded. */ + droppedFromRoot: string[]; + /** Deprecated custom names left out because Astryx owns the name. */ + legacyCollisions: string[]; + }; +} + +export declare function generate(): Promise; + +/** Every file currently under MIRROR_DIR, repository-relative, sorted. */ +export declare function listMirrorFiles(): Promise; diff --git a/scripts/gen-exports.mjs b/scripts/gen-exports.mjs new file mode 100644 index 0000000..cce087b --- /dev/null +++ b/scripts/gen-exports.mjs @@ -0,0 +1,543 @@ +/** + * Export-surface generator. + * + * ui-common mirrors Astryx 1:1. Every subpath in the `exports` map of + * `@astryxdesign/core` becomes the same subpath here, `@astryxdesign/lab` + * becomes `lab`, and `@astryxdesign/theme-neutral` becomes `theme/neutral`. + * Nothing is curated by hand. The only way to hide a subpath is an entry in + * `exports.exclude.json`. + * + * Outputs, all committed: + * + * - `src/astryx/**`: one re-export file per mirrored subpath. A JS subpath is + * `export * from ""`. A CSS subpath is one `@import`. + * - `src/index.ts`: the root barrel. Explicit named re-exports of core's root + * (minus the names of excluded subpaths), plus the ui-common customs listed + * in `exports.customs.json`. + * - `package.json#exports`. + * + * `src/exports.test.ts` runs `generate()` in memory and byte-compares the + * result with the committed files, so the build goes red on every Astryx + * bump until this script is re-run and the diff is reviewed. + * + * Usage: `pnpm run gen:exports` + */ +import { readFile, readdir, rm, mkdir, writeFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { dirname, join, relative, resolve, sep } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +import * as prettier from "prettier"; +import ts from "typescript"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + +/** Where the mirrored re-export files live, relative to the repository. */ +export const MIRROR_DIR = "src/astryx"; + +const GENERATED_HEADER = "@generated by scripts/gen-exports.mjs. Do not edit."; + +/** Astryx packages mirrored, and the ui-common subpath each one lands under. */ +export const MIRRORED_PACKAGES = [ + { name: "@astryxdesign/core", prefix: "" }, + { name: "@astryxdesign/lab", prefix: "lab" }, + { name: "@astryxdesign/theme-neutral", prefix: "theme/neutral" }, +]; + +/** + * ui-common's own subpaths. Everything else in the exports map is mirrored. + * Adding one here is a decision about the public surface, so it is code, not + * data. + */ +export const OWN_EXPORTS = { + ".": { types: "./dist/index.d.ts", import: "./dist/index.js" }, + "./components/*": { + types: "./dist/components/*/index.d.ts", + import: "./dist/components/*/index.js", + }, + "./styles/base.css": "./dist/styles/base.css", + "./styles/themes/*.css": "./dist/styles/themes/*.css", +}; + +/** The locale catalogs are JSON, so they are merged at build time instead. */ +const LOCALES_PATTERN = "./locales/*.json"; + +/** + * The installed package's directory. Read through node_modules rather than + * `require.resolve`, because Astryx does not export `./package.json`. + */ +function packageDir(name) { + const dir = join(root, "node_modules", name); + if (!existsSync(join(dir, "package.json"))) { + throw new Error(`${name} is not installed. Run pnpm install first.`); + } + return dir; +} + +async function readJson(path) { + return JSON.parse(await readFile(path, "utf8")); +} + +function posix(path) { + return path.split(sep).join("/"); +} + +/** `{ name, replacedBy, reason }[]` from exports.exclude.json, validated. */ +async function readExclusions() { + const list = await readJson(join(root, "exports.exclude.json")); + if (!Array.isArray(list)) throw new Error("exports.exclude.json must be an array"); + for (const entry of list) { + if (typeof entry?.name !== "string" || typeof entry?.reason !== "string") { + throw new Error( + `exports.exclude.json: every entry needs "name" and "reason": ${JSON.stringify(entry)}`, + ); + } + if (!("replacedBy" in entry)) { + throw new Error( + `exports.exclude.json: "${entry.name}" needs "replacedBy" (use null when nothing replaces it)`, + ); + } + } + return list; +} + +/** `{ name, source, subpath?, legacy? }[]` from exports.customs.json, validated. */ +async function readCustoms() { + const list = await readJson(join(root, "exports.customs.json")); + if (!Array.isArray(list)) throw new Error("exports.customs.json must be an array"); + for (const entry of list) { + if (typeof entry?.name !== "string" || typeof entry?.source !== "string") { + throw new Error( + `exports.customs.json: every entry needs "name" and "source": ${JSON.stringify(entry)}`, + ); + } + if (!existsSync(join(root, "src", entry.source))) { + throw new Error( + `exports.customs.json: "${entry.name}" source src/${entry.source} does not exist`, + ); + } + } + return list; +} + +/** + * Every mirrored subpath: which Astryx specifier it re-exports, and whether it + * is a script module or a stylesheet. + */ +async function collectMirroredSubpaths() { + const subpaths = []; + for (const { name, prefix } of MIRRORED_PACKAGES) { + const dir = packageDir(name); + const pkg = await readJson(join(dir, "package.json")); + for (const [key, value] of Object.entries(pkg.exports ?? {})) { + if (key === "./package.json") continue; + const rest = key === "." ? "" : key.slice(2); + const subpath = [prefix, rest].filter(Boolean).join("/"); + const specifier = rest === "" ? name : `${name}/${rest}`; + const target = + typeof value === "string" ? value : (value.default ?? value.import); + subpaths.push({ + package: name, + packageDir: dir, + key, + subpath, + specifier, + types: typeof value === "string" ? undefined : value.types, + target, + // Core's own root is not a mirror file: it is the root barrel, + // written out name by name below. + kind: + subpath === "" + ? "root" + : key.includes("*") + ? "pattern" + : key.endsWith(".css") + ? "css" + : /\.m?js$/.test(key) + ? "data" + : "js", + }); + } + } + return subpaths; +} + +/** + * Names a module exports, split into runtime values and type-only names. + * Read through the TypeScript checker so `export type { X }` is recognised + * even when `X` also names a value somewhere down the re-export chain. + */ +function moduleExports(checker, sourceFile) { + const moduleSymbol = checker.getSymbolAtLocation(sourceFile); + if (!moduleSymbol) return { values: [], types: [] }; + const values = []; + const types = []; + for (const symbol of checker.getExportsOfModule(moduleSymbol)) { + const name = symbol.getName(); + if (name === "default") continue; + if (isTypeOnly(checker, symbol)) { + types.push(name); + continue; + } + const target = + symbol.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(symbol) : symbol; + if (target.flags & ts.SymbolFlags.Value) values.push(name); + else types.push(name); + } + return { values: values.sort(), types: types.sort() }; +} + +function hasDefaultExport(checker, sourceFile) { + const moduleSymbol = checker.getSymbolAtLocation(sourceFile); + if (!moduleSymbol) return false; + return checker + .getExportsOfModule(moduleSymbol) + .some((s) => s.getName() === "default"); +} + +function isTypeOnly(checker, symbol) { + const seen = new Set(); + let current = symbol; + while (current && current.flags & ts.SymbolFlags.Alias && !seen.has(current)) { + seen.add(current); + for (const declaration of current.declarations ?? []) { + if (ts.isExportSpecifier(declaration) || ts.isImportSpecifier(declaration)) { + if (declaration.isTypeOnly || declaration.parent.parent.isTypeOnly) return true; + } + if (ts.isImportClause(declaration) && declaration.isTypeOnly) return true; + if (ts.isNamespaceExport(declaration) && declaration.parent.isTypeOnly) + return true; + } + current = checker.getImmediateAliasedSymbol(current); + } + return false; +} + +function createProgram(files) { + return ts.createProgram(files, { + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + jsx: ts.JsxEmit.ReactJSX, + allowJs: false, + skipLibCheck: true, + noEmit: true, + types: [], + }); +} + +function jsFile(subpath) { + return `${MIRROR_DIR}/${subpath}.ts`; +} + +function cssFile(subpath) { + return `${MIRROR_DIR}/${subpath}`; +} + +/** Relative import specifier from src/index.ts to a custom's source file. */ +function customSpecifier(source) { + const withoutExt = source.replace(/\.tsx?$/, "").replace(/\/index$/, ""); + return `./${withoutExt}`; +} + +/** A list as ` * `-prefixed comment lines of at most 80 columns. */ +function wrapComment(names) { + const lines = []; + let line = ""; + for (const [i, name] of names.entries()) { + const word = `${name}${i === names.length - 1 ? "." : ","}`; + if (line && ` * ${line} ${word}`.length > 80) { + lines.push(line); + line = word; + } else { + line = line ? `${line} ${word}` : word; + } + } + if (line) lines.push(line); + return lines.map((l) => ` * ${l}\n`).join(""); +} + +function exportList(keyword, names, from) { + if (names.length === 0) return ""; + return `${keyword} {\n${names.map((n) => ` ${n},`).join("\n")}\n} from "${from}";\n`; +} + +async function format(content, filepath) { + const config = (await prettier.resolveConfig(join(root, filepath))) ?? {}; + return prettier.format(content, { ...config, filepath: join(root, filepath) }); +} + +/** + * Compute every output in memory. Returns + * `{ files: Map, exports, report }`. + */ +export async function generate() { + const exclusions = await readExclusions(); + const customs = await readCustoms(); + const excludedNames = new Set(exclusions.map((e) => e.name)); + const all = await collectMirroredSubpaths(); + + const known = new Set(all.map((s) => s.subpath)); + for (const name of excludedNames) { + if (!known.has(name)) { + throw new Error( + `exports.exclude.json names "${name}", which no mirrored package exports any more. ` + + `Remove the entry.`, + ); + } + } + + const mirrored = all.filter((s) => !excludedNames.has(s.subpath)); + const excluded = all.filter((s) => excludedNames.has(s.subpath)); + + for (const s of mirrored) { + if (s.kind === "data") { + throw new Error( + `${s.package} exports "${s.key}", a data module ui-common does not know how to mirror. ` + + `Mirror it deliberately or add it to exports.exclude.json.`, + ); + } + if ( + s.kind === "pattern" && + !(s.package === "@astryxdesign/core" && s.key === LOCALES_PATTERN) + ) { + throw new Error( + `${s.package} exports the pattern "${s.key}", which the generator does not handle.`, + ); + } + } + + // Type information: every mirrored JS module (to find default exports), + // core's root, the excluded modules (their names leave the root barrel), + // lab's root (for the name rule), and each custom's source. + const coreDir = packageDir("@astryxdesign/core"); + const labDir = packageDir("@astryxdesign/lab"); + const typesPath = (s) => (s.types ? join(s.packageDir, s.types) : undefined); + const coreRootTypes = join(coreDir, "dist/index.d.ts"); + const labRootTypes = join(labDir, "dist/index.d.ts"); + const customPaths = customs.map((c) => join(root, "src", c.source)); + const jsSubpaths = all.filter((s) => s.kind === "js"); + const program = createProgram([ + coreRootTypes, + labRootTypes, + ...jsSubpaths.map(typesPath).filter(Boolean), + ...customPaths, + ]); + const checker = program.getTypeChecker(); + const sourceOf = (path) => { + const file = program.getSourceFile(path); + if (!file) throw new Error(`TypeScript could not load ${relative(root, path)}`); + return file; + }; + + const files = new Map(); + + // 1. Mirrored re-export files. + for (const s of mirrored) { + if (s.kind === "js") { + const path = jsFile(s.subpath); + const types = typesPath(s); + const hasDefault = types ? hasDefaultExport(checker, sourceOf(types)) : false; + let body = `// ${GENERATED_HEADER}\nexport * from "${s.specifier}";\n`; + if (hasDefault) body += `export { default } from "${s.specifier}";\n`; + files.set(path, await format(body, path)); + } else if (s.kind === "css") { + const path = cssFile(s.subpath); + files.set( + path, + await format(`/* ${GENERATED_HEADER} */\n@import "${s.specifier}";\n`, path), + ); + } + } + + // 2. Root barrel. + const coreRoot = moduleExports(checker, sourceOf(coreRootTypes)); + const labRoot = moduleExports(checker, sourceOf(labRootTypes)); + + // Cross-check the classification against what core actually exports at + // runtime. A mismatch means a type-only name would be emitted as a value + // (a runtime SyntaxError for consumers) or the reverse. + const runtime = Object.keys( + await import(pathToFileURL(join(coreDir, "dist/index.js")).href), + ) + .filter((n) => n !== "default") + .sort(); + if (JSON.stringify(runtime) !== JSON.stringify(coreRoot.values)) { + const onlyRuntime = runtime.filter((n) => !coreRoot.values.includes(n)); + const onlyTypes = coreRoot.values.filter((n) => !runtime.includes(n)); + throw new Error( + `Value/type classification of @astryxdesign/core disagrees with its runtime exports. ` + + `Runtime only: ${onlyRuntime.join(", ") || "-"}. Declared only: ${onlyTypes.join(", ") || "-"}.`, + ); + } + + const droppedFromRoot = new Set(); + for (const s of excluded.filter( + (e) => e.kind === "js" && e.package === "@astryxdesign/core", + )) { + const types = typesPath(s); + if (!types) continue; + const names = moduleExports(checker, sourceOf(types)); + for (const n of [...names.values, ...names.types]) droppedFromRoot.add(n); + } + + const coreValues = coreRoot.values.filter((n) => !droppedFromRoot.has(n)); + const coreTypes = coreRoot.types.filter((n) => !droppedFromRoot.has(n)); + const coreNames = new Set([...coreRoot.values, ...coreRoot.types]); + const labNames = new Set([...labRoot.values, ...labRoot.types]); + + const report = { droppedFromRoot: [...droppedFromRoot].sort(), legacyCollisions: [] }; + const customBlocks = []; + const rootNames = new Set([...coreValues, ...coreTypes]); + + for (const custom of customs) { + const names = moduleExports(checker, sourceOf(join(root, "src", custom.source))); + // A legacy entry deprecates the names it lists, or its whole module when + // it lists none. Only a deprecated name may collide with Astryx. + const deprecated = (n) => + custom.legacy !== undefined && + (custom.legacy.names === undefined || custom.legacy.names.includes(n)); + const keep = { values: [], types: [] }; + for (const [bucket, list] of [ + ["values", names.values], + ["types", names.types], + ]) { + for (const n of list) { + const clashCore = coreNames.has(n); + const clashLab = labNames.has(n); + if ((clashCore || clashLab) && !deprecated(n)) { + throw new Error( + `exports.customs.json: "${custom.name}" exports "${n}", which ${ + clashCore ? "@astryxdesign/core" : "@astryxdesign/lab" + } also exports. ui-common customs never share a name with Astryx.`, + ); + } + if (clashCore) { + report.legacyCollisions.push(n); + continue; + } + if (rootNames.has(n)) { + throw new Error( + `exports.customs.json: "${n}" is exported twice from the root barrel.`, + ); + } + rootNames.add(n); + keep[bucket].push(n); + } + } + if (keep.values.length + keep.types.length === 0) continue; + const from = customSpecifier(custom.source); + let comment = `// ${custom.name}\n`; + if (custom.legacy) { + const what = custom.legacy.names ? custom.legacy.names.join(", ") : "this module"; + comment += `// Deprecated, removed in 0.3: ${what}. Replaced by ${custom.legacy.replacedBy}.\n`; + } + customBlocks.push( + comment + + exportList("export", keep.values, from) + + exportList("export type", keep.types, from), + ); + } + + const barrel = + `/**\n * ${GENERATED_HEADER}\n *\n` + + ` * The root barrel: Astryx core's root exports, minus the names of the\n` + + ` * subpaths in exports.exclude.json, then the ui-common customs listed in\n` + + ` * exports.customs.json. Edit those files and run \`pnpm run gen:exports\`.\n` + + (report.droppedFromRoot.length > 0 + ? ` *\n * Left out because their subpath is excluded:\n${wrapComment(report.droppedFromRoot)}` + : "") + + (report.legacyCollisions.length > 0 + ? ` *\n * Deprecated customs whose names Astryx owns. The Astryx export wins\n` + + ` * here; the custom stays at @lablup/ui-common/components/ until 0.3:\n` + + wrapComment(report.legacyCollisions) + : "") + + ` */\n\n` + + exportList("export", coreValues, "@astryxdesign/core") + + exportList("export type", coreTypes, "@astryxdesign/core") + + `\n` + + customBlocks.join("\n"); + files.set("src/index.ts", await format(barrel, "src/index.ts")); + + // 3. The exports map. + const exportsMap = { ...OWN_EXPORTS }; + for (const s of mirrored) { + if (s.kind === "js") { + exportsMap[`./${s.subpath}`] = { + types: `./dist/astryx/${s.subpath}.d.ts`, + import: `./dist/astryx/${s.subpath}.js`, + }; + } else if (s.kind === "css") { + exportsMap[`./${s.subpath}`] = `./dist/astryx/${s.subpath}`; + } else if (s.kind === "pattern") { + exportsMap[LOCALES_PATTERN] = "./dist/locales/*.json"; + } + } + for (const custom of customs) { + if (!custom.subpath) continue; + const key = `./${custom.subpath}`; + if (key in exportsMap) { + throw new Error( + `exports.customs.json: subpath "${custom.subpath}" is already taken.`, + ); + } + if (known.has(custom.subpath)) { + throw new Error( + `exports.customs.json: subpath "${custom.subpath}" is an Astryx subpath (even if excluded).`, + ); + } + const base = custom.source.replace(/\.tsx?$/, ""); + exportsMap[key] = { types: `./dist/${base}.d.ts`, import: `./dist/${base}.js` }; + } + exportsMap["./package.json"] = "./package.json"; + + return { files, exports: exportsMap, report }; +} + +/** Every file currently under the mirror directory, repository-relative. */ +export async function listMirrorFiles() { + const out = []; + async function walk(dir) { + if (!existsSync(dir)) return; + for (const entry of await readdir(dir, { withFileTypes: true })) { + const path = join(dir, entry.name); + if (entry.isDirectory()) await walk(path); + else out.push(posix(relative(root, path))); + } + } + await walk(join(root, MIRROR_DIR)); + return out.sort(); +} + +async function main() { + const { files, exports: exportsMap, report } = await generate(); + + for (const stale of await listMirrorFiles()) { + if (!files.has(stale)) await rm(join(root, stale)); + } + for (const [path, content] of files) { + await mkdir(dirname(join(root, path)), { recursive: true }); + await writeFile(join(root, path), content); + } + + const pkgPath = join(root, "package.json"); + const pkg = await readJson(pkgPath); + pkg.exports = exportsMap; + await writeFile(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`); + + console.log( + `Wrote ${files.size} file(s) and ${Object.keys(exportsMap).length} export path(s).`, + ); + if (report.droppedFromRoot.length > 0) { + console.log(`Excluded from the root barrel: ${report.droppedFromRoot.join(", ")}`); + } + if (report.legacyCollisions.length > 0) { + console.log( + `Deprecated custom names Astryx owns: ${report.legacyCollisions.join("; ")}`, + ); + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + await main(); +} diff --git a/src/astryx/AlertDialog.ts b/src/astryx/AlertDialog.ts new file mode 100644 index 0000000..4b77447 --- /dev/null +++ b/src/astryx/AlertDialog.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/AlertDialog"; diff --git a/src/astryx/AppShell.ts b/src/astryx/AppShell.ts new file mode 100644 index 0000000..16e5a88 --- /dev/null +++ b/src/astryx/AppShell.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/AppShell"; diff --git a/src/astryx/AspectRatio.ts b/src/astryx/AspectRatio.ts new file mode 100644 index 0000000..f38fec9 --- /dev/null +++ b/src/astryx/AspectRatio.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/AspectRatio"; diff --git a/src/astryx/Avatar.ts b/src/astryx/Avatar.ts new file mode 100644 index 0000000..3199cdd --- /dev/null +++ b/src/astryx/Avatar.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/Avatar"; diff --git a/src/astryx/AvatarGroup.ts b/src/astryx/AvatarGroup.ts new file mode 100644 index 0000000..51d032e --- /dev/null +++ b/src/astryx/AvatarGroup.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/AvatarGroup"; diff --git a/src/astryx/Badge.ts b/src/astryx/Badge.ts new file mode 100644 index 0000000..9655f0b --- /dev/null +++ b/src/astryx/Badge.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/Badge"; diff --git a/src/astryx/Banner.ts b/src/astryx/Banner.ts new file mode 100644 index 0000000..115ba57 --- /dev/null +++ b/src/astryx/Banner.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/Banner"; diff --git a/src/astryx/BaseProps.ts b/src/astryx/BaseProps.ts new file mode 100644 index 0000000..58ab7d5 --- /dev/null +++ b/src/astryx/BaseProps.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/BaseProps"; diff --git a/src/astryx/Blockquote.ts b/src/astryx/Blockquote.ts new file mode 100644 index 0000000..0bb5a60 --- /dev/null +++ b/src/astryx/Blockquote.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/Blockquote"; diff --git a/src/astryx/BottomSheet.ts b/src/astryx/BottomSheet.ts new file mode 100644 index 0000000..75bc65c --- /dev/null +++ b/src/astryx/BottomSheet.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/BottomSheet"; diff --git a/src/astryx/Breadcrumbs.ts b/src/astryx/Breadcrumbs.ts new file mode 100644 index 0000000..6458701 --- /dev/null +++ b/src/astryx/Breadcrumbs.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/Breadcrumbs"; diff --git a/src/astryx/Button.ts b/src/astryx/Button.ts new file mode 100644 index 0000000..757ff95 --- /dev/null +++ b/src/astryx/Button.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/Button"; diff --git a/src/astryx/ButtonGroup.ts b/src/astryx/ButtonGroup.ts new file mode 100644 index 0000000..07c64f2 --- /dev/null +++ b/src/astryx/ButtonGroup.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/ButtonGroup"; diff --git a/src/astryx/Calendar.ts b/src/astryx/Calendar.ts new file mode 100644 index 0000000..9085140 --- /dev/null +++ b/src/astryx/Calendar.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/Calendar"; diff --git a/src/astryx/Calendar/utils.ts b/src/astryx/Calendar/utils.ts new file mode 100644 index 0000000..50eee5a --- /dev/null +++ b/src/astryx/Calendar/utils.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/Calendar/utils"; diff --git a/src/astryx/Card.ts b/src/astryx/Card.ts new file mode 100644 index 0000000..fbff993 --- /dev/null +++ b/src/astryx/Card.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/Card"; diff --git a/src/astryx/Carousel.ts b/src/astryx/Carousel.ts new file mode 100644 index 0000000..8da1df8 --- /dev/null +++ b/src/astryx/Carousel.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/Carousel"; diff --git a/src/astryx/Center.ts b/src/astryx/Center.ts new file mode 100644 index 0000000..c55713e --- /dev/null +++ b/src/astryx/Center.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/Center"; diff --git a/src/astryx/Chat.ts b/src/astryx/Chat.ts new file mode 100644 index 0000000..00e867c --- /dev/null +++ b/src/astryx/Chat.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/Chat"; diff --git a/src/astryx/CheckboxInput.ts b/src/astryx/CheckboxInput.ts new file mode 100644 index 0000000..859625d --- /dev/null +++ b/src/astryx/CheckboxInput.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/CheckboxInput"; diff --git a/src/astryx/CheckboxList.ts b/src/astryx/CheckboxList.ts new file mode 100644 index 0000000..a92a085 --- /dev/null +++ b/src/astryx/CheckboxList.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/CheckboxList"; diff --git a/src/astryx/Citation.ts b/src/astryx/Citation.ts new file mode 100644 index 0000000..c27072f --- /dev/null +++ b/src/astryx/Citation.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/Citation"; diff --git a/src/astryx/ClickableCard.ts b/src/astryx/ClickableCard.ts new file mode 100644 index 0000000..42469b1 --- /dev/null +++ b/src/astryx/ClickableCard.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/ClickableCard"; diff --git a/src/astryx/Code.ts b/src/astryx/Code.ts new file mode 100644 index 0000000..d2fb2d7 --- /dev/null +++ b/src/astryx/Code.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/Code"; diff --git a/src/astryx/CodeBlock.ts b/src/astryx/CodeBlock.ts new file mode 100644 index 0000000..b76a5c2 --- /dev/null +++ b/src/astryx/CodeBlock.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/CodeBlock"; diff --git a/src/astryx/Collapsible.ts b/src/astryx/Collapsible.ts new file mode 100644 index 0000000..b210f0e --- /dev/null +++ b/src/astryx/Collapsible.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/Collapsible"; diff --git a/src/astryx/CommandPalette.ts b/src/astryx/CommandPalette.ts new file mode 100644 index 0000000..d4afa57 --- /dev/null +++ b/src/astryx/CommandPalette.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/CommandPalette"; diff --git a/src/astryx/ComplexSelector.ts b/src/astryx/ComplexSelector.ts new file mode 100644 index 0000000..4f2471c --- /dev/null +++ b/src/astryx/ComplexSelector.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/ComplexSelector"; diff --git a/src/astryx/ContextMenu.ts b/src/astryx/ContextMenu.ts new file mode 100644 index 0000000..260d128 --- /dev/null +++ b/src/astryx/ContextMenu.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/ContextMenu"; diff --git a/src/astryx/DateInput.ts b/src/astryx/DateInput.ts new file mode 100644 index 0000000..dd3b7d2 --- /dev/null +++ b/src/astryx/DateInput.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/DateInput"; diff --git a/src/astryx/DateRangeInput.ts b/src/astryx/DateRangeInput.ts new file mode 100644 index 0000000..a5c2e0e --- /dev/null +++ b/src/astryx/DateRangeInput.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/DateRangeInput"; diff --git a/src/astryx/DateTimeInput.ts b/src/astryx/DateTimeInput.ts new file mode 100644 index 0000000..a632bf3 --- /dev/null +++ b/src/astryx/DateTimeInput.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/DateTimeInput"; diff --git a/src/astryx/Divider.ts b/src/astryx/Divider.ts new file mode 100644 index 0000000..4f16da1 --- /dev/null +++ b/src/astryx/Divider.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/Divider"; diff --git a/src/astryx/DropdownMenu.ts b/src/astryx/DropdownMenu.ts new file mode 100644 index 0000000..cb1abe6 --- /dev/null +++ b/src/astryx/DropdownMenu.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/DropdownMenu"; diff --git a/src/astryx/EmptyState.ts b/src/astryx/EmptyState.ts new file mode 100644 index 0000000..f42a2e2 --- /dev/null +++ b/src/astryx/EmptyState.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/EmptyState"; diff --git a/src/astryx/Field.ts b/src/astryx/Field.ts new file mode 100644 index 0000000..d817141 --- /dev/null +++ b/src/astryx/Field.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/Field"; diff --git a/src/astryx/FieldStatus.ts b/src/astryx/FieldStatus.ts new file mode 100644 index 0000000..863aeeb --- /dev/null +++ b/src/astryx/FieldStatus.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/FieldStatus"; diff --git a/src/astryx/FileInput.ts b/src/astryx/FileInput.ts new file mode 100644 index 0000000..6ab53cb --- /dev/null +++ b/src/astryx/FileInput.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/FileInput"; diff --git a/src/astryx/FormLayout.ts b/src/astryx/FormLayout.ts new file mode 100644 index 0000000..bc40aac --- /dev/null +++ b/src/astryx/FormLayout.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/FormLayout"; diff --git a/src/astryx/Grid.ts b/src/astryx/Grid.ts new file mode 100644 index 0000000..9684044 --- /dev/null +++ b/src/astryx/Grid.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/Grid"; diff --git a/src/astryx/HStack.ts b/src/astryx/HStack.ts new file mode 100644 index 0000000..33574ed --- /dev/null +++ b/src/astryx/HStack.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/HStack"; diff --git a/src/astryx/Heading.ts b/src/astryx/Heading.ts new file mode 100644 index 0000000..3fd159d --- /dev/null +++ b/src/astryx/Heading.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/Heading"; diff --git a/src/astryx/HoverCard.ts b/src/astryx/HoverCard.ts new file mode 100644 index 0000000..b6f7ef0 --- /dev/null +++ b/src/astryx/HoverCard.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/HoverCard"; diff --git a/src/astryx/Icon.ts b/src/astryx/Icon.ts new file mode 100644 index 0000000..6d9b3ae --- /dev/null +++ b/src/astryx/Icon.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/Icon"; diff --git a/src/astryx/IconButton.ts b/src/astryx/IconButton.ts new file mode 100644 index 0000000..fd2b4d9 --- /dev/null +++ b/src/astryx/IconButton.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/IconButton"; diff --git a/src/astryx/Indicator.ts b/src/astryx/Indicator.ts new file mode 100644 index 0000000..dbd21f2 --- /dev/null +++ b/src/astryx/Indicator.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/Indicator"; diff --git a/src/astryx/InputGroup.ts b/src/astryx/InputGroup.ts new file mode 100644 index 0000000..966d12d --- /dev/null +++ b/src/astryx/InputGroup.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/InputGroup"; diff --git a/src/astryx/InteractiveRoleContext.ts b/src/astryx/InteractiveRoleContext.ts new file mode 100644 index 0000000..6abde61 --- /dev/null +++ b/src/astryx/InteractiveRoleContext.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/InteractiveRoleContext"; diff --git a/src/astryx/Item.ts b/src/astryx/Item.ts new file mode 100644 index 0000000..4149a86 --- /dev/null +++ b/src/astryx/Item.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/Item"; diff --git a/src/astryx/Kbd.ts b/src/astryx/Kbd.ts new file mode 100644 index 0000000..aeb9ce8 --- /dev/null +++ b/src/astryx/Kbd.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/Kbd"; diff --git a/src/astryx/Layer.ts b/src/astryx/Layer.ts new file mode 100644 index 0000000..ca32af8 --- /dev/null +++ b/src/astryx/Layer.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/Layer"; diff --git a/src/astryx/Layout.ts b/src/astryx/Layout.ts new file mode 100644 index 0000000..a377f87 --- /dev/null +++ b/src/astryx/Layout.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/Layout"; diff --git a/src/astryx/Lightbox.ts b/src/astryx/Lightbox.ts new file mode 100644 index 0000000..a7cfb72 --- /dev/null +++ b/src/astryx/Lightbox.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/Lightbox"; diff --git a/src/astryx/Link.ts b/src/astryx/Link.ts new file mode 100644 index 0000000..f4a6d81 --- /dev/null +++ b/src/astryx/Link.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/Link"; diff --git a/src/astryx/List.ts b/src/astryx/List.ts new file mode 100644 index 0000000..abd8d21 --- /dev/null +++ b/src/astryx/List.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/List"; diff --git a/src/astryx/Markdown.ts b/src/astryx/Markdown.ts new file mode 100644 index 0000000..dab662a --- /dev/null +++ b/src/astryx/Markdown.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/Markdown"; diff --git a/src/astryx/Markdown/utils.ts b/src/astryx/Markdown/utils.ts new file mode 100644 index 0000000..ef851d8 --- /dev/null +++ b/src/astryx/Markdown/utils.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/Markdown/utils"; diff --git a/src/astryx/MetadataList.ts b/src/astryx/MetadataList.ts new file mode 100644 index 0000000..49d47c8 --- /dev/null +++ b/src/astryx/MetadataList.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/MetadataList"; diff --git a/src/astryx/MobileNav.ts b/src/astryx/MobileNav.ts new file mode 100644 index 0000000..af5aff3 --- /dev/null +++ b/src/astryx/MobileNav.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/MobileNav"; diff --git a/src/astryx/MoreMenu.ts b/src/astryx/MoreMenu.ts new file mode 100644 index 0000000..3bf485e --- /dev/null +++ b/src/astryx/MoreMenu.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/MoreMenu"; diff --git a/src/astryx/MultiSelector.ts b/src/astryx/MultiSelector.ts new file mode 100644 index 0000000..94b726a --- /dev/null +++ b/src/astryx/MultiSelector.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/MultiSelector"; diff --git a/src/astryx/NavIcon.ts b/src/astryx/NavIcon.ts new file mode 100644 index 0000000..cf05e92 --- /dev/null +++ b/src/astryx/NavIcon.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/NavIcon"; diff --git a/src/astryx/NavMenu.ts b/src/astryx/NavMenu.ts new file mode 100644 index 0000000..4d62477 --- /dev/null +++ b/src/astryx/NavMenu.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/NavMenu"; diff --git a/src/astryx/NumberInput.ts b/src/astryx/NumberInput.ts new file mode 100644 index 0000000..058c959 --- /dev/null +++ b/src/astryx/NumberInput.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/NumberInput"; diff --git a/src/astryx/Outline.ts b/src/astryx/Outline.ts new file mode 100644 index 0000000..e5164be --- /dev/null +++ b/src/astryx/Outline.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/Outline"; diff --git a/src/astryx/OverflowList.ts b/src/astryx/OverflowList.ts new file mode 100644 index 0000000..67bbdf5 --- /dev/null +++ b/src/astryx/OverflowList.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/OverflowList"; diff --git a/src/astryx/Overlay.ts b/src/astryx/Overlay.ts new file mode 100644 index 0000000..90e649c --- /dev/null +++ b/src/astryx/Overlay.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/Overlay"; diff --git a/src/astryx/Pagination.ts b/src/astryx/Pagination.ts new file mode 100644 index 0000000..3cdd54d --- /dev/null +++ b/src/astryx/Pagination.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/Pagination"; diff --git a/src/astryx/Popover.ts b/src/astryx/Popover.ts new file mode 100644 index 0000000..6a32710 --- /dev/null +++ b/src/astryx/Popover.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/Popover"; diff --git a/src/astryx/PowerSearch.ts b/src/astryx/PowerSearch.ts new file mode 100644 index 0000000..ab4a63d --- /dev/null +++ b/src/astryx/PowerSearch.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/PowerSearch"; diff --git a/src/astryx/PowerSearch/utils.ts b/src/astryx/PowerSearch/utils.ts new file mode 100644 index 0000000..a668109 --- /dev/null +++ b/src/astryx/PowerSearch/utils.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/PowerSearch/utils"; diff --git a/src/astryx/ProgressBar.ts b/src/astryx/ProgressBar.ts new file mode 100644 index 0000000..c7c823f --- /dev/null +++ b/src/astryx/ProgressBar.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/ProgressBar"; diff --git a/src/astryx/RadioList.ts b/src/astryx/RadioList.ts new file mode 100644 index 0000000..f1eea27 --- /dev/null +++ b/src/astryx/RadioList.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/RadioList"; diff --git a/src/astryx/Resizable.ts b/src/astryx/Resizable.ts new file mode 100644 index 0000000..e1ca0cb --- /dev/null +++ b/src/astryx/Resizable.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/Resizable"; diff --git a/src/astryx/Resizable/utils.ts b/src/astryx/Resizable/utils.ts new file mode 100644 index 0000000..7b532c6 --- /dev/null +++ b/src/astryx/Resizable/utils.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/Resizable/utils"; diff --git a/src/astryx/ScrollableArea.ts b/src/astryx/ScrollableArea.ts new file mode 100644 index 0000000..83f2cdf --- /dev/null +++ b/src/astryx/ScrollableArea.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/ScrollableArea"; diff --git a/src/astryx/Section.ts b/src/astryx/Section.ts new file mode 100644 index 0000000..e98045d --- /dev/null +++ b/src/astryx/Section.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/Section"; diff --git a/src/astryx/SegmentedControl.ts b/src/astryx/SegmentedControl.ts new file mode 100644 index 0000000..da2d55f --- /dev/null +++ b/src/astryx/SegmentedControl.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/SegmentedControl"; diff --git a/src/astryx/SelectableCard.ts b/src/astryx/SelectableCard.ts new file mode 100644 index 0000000..152e0f4 --- /dev/null +++ b/src/astryx/SelectableCard.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/SelectableCard"; diff --git a/src/astryx/Selector.ts b/src/astryx/Selector.ts new file mode 100644 index 0000000..c58b146 --- /dev/null +++ b/src/astryx/Selector.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/Selector"; diff --git a/src/astryx/Selector/utils.ts b/src/astryx/Selector/utils.ts new file mode 100644 index 0000000..3710836 --- /dev/null +++ b/src/astryx/Selector/utils.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/Selector/utils"; diff --git a/src/astryx/SideNav.ts b/src/astryx/SideNav.ts new file mode 100644 index 0000000..7e56f91 --- /dev/null +++ b/src/astryx/SideNav.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/SideNav"; diff --git a/src/astryx/SizeContext.ts b/src/astryx/SizeContext.ts new file mode 100644 index 0000000..692413b --- /dev/null +++ b/src/astryx/SizeContext.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/SizeContext"; diff --git a/src/astryx/Skeleton.ts b/src/astryx/Skeleton.ts new file mode 100644 index 0000000..488b9ef --- /dev/null +++ b/src/astryx/Skeleton.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/Skeleton"; diff --git a/src/astryx/Slider.ts b/src/astryx/Slider.ts new file mode 100644 index 0000000..ea43590 --- /dev/null +++ b/src/astryx/Slider.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/Slider"; diff --git a/src/astryx/Spinner.ts b/src/astryx/Spinner.ts new file mode 100644 index 0000000..5389f0b --- /dev/null +++ b/src/astryx/Spinner.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/Spinner"; diff --git a/src/astryx/Stack.ts b/src/astryx/Stack.ts new file mode 100644 index 0000000..4621c20 --- /dev/null +++ b/src/astryx/Stack.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/Stack"; diff --git a/src/astryx/StatusDot.ts b/src/astryx/StatusDot.ts new file mode 100644 index 0000000..c17f4fc --- /dev/null +++ b/src/astryx/StatusDot.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/StatusDot"; diff --git a/src/astryx/Stepper.ts b/src/astryx/Stepper.ts new file mode 100644 index 0000000..7ea3312 --- /dev/null +++ b/src/astryx/Stepper.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/Stepper"; diff --git a/src/astryx/Switch.ts b/src/astryx/Switch.ts new file mode 100644 index 0000000..1ad15bb --- /dev/null +++ b/src/astryx/Switch.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/Switch"; diff --git a/src/astryx/TabList.ts b/src/astryx/TabList.ts new file mode 100644 index 0000000..d7fdcab --- /dev/null +++ b/src/astryx/TabList.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/TabList"; diff --git a/src/astryx/Table.ts b/src/astryx/Table.ts new file mode 100644 index 0000000..f096fda --- /dev/null +++ b/src/astryx/Table.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/Table"; diff --git a/src/astryx/Table/utils.ts b/src/astryx/Table/utils.ts new file mode 100644 index 0000000..d86a7be --- /dev/null +++ b/src/astryx/Table/utils.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/Table/utils"; diff --git a/src/astryx/Text.ts b/src/astryx/Text.ts new file mode 100644 index 0000000..3a43d2a --- /dev/null +++ b/src/astryx/Text.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/Text"; diff --git a/src/astryx/TextArea.ts b/src/astryx/TextArea.ts new file mode 100644 index 0000000..059e151 --- /dev/null +++ b/src/astryx/TextArea.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/TextArea"; diff --git a/src/astryx/TextInput.ts b/src/astryx/TextInput.ts new file mode 100644 index 0000000..50b78dd --- /dev/null +++ b/src/astryx/TextInput.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/TextInput"; diff --git a/src/astryx/Thumbnail.ts b/src/astryx/Thumbnail.ts new file mode 100644 index 0000000..310d119 --- /dev/null +++ b/src/astryx/Thumbnail.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/Thumbnail"; diff --git a/src/astryx/TimeInput.ts b/src/astryx/TimeInput.ts new file mode 100644 index 0000000..0654e48 --- /dev/null +++ b/src/astryx/TimeInput.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/TimeInput"; diff --git a/src/astryx/Timestamp.ts b/src/astryx/Timestamp.ts new file mode 100644 index 0000000..1289a02 --- /dev/null +++ b/src/astryx/Timestamp.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/Timestamp"; diff --git a/src/astryx/Toast.ts b/src/astryx/Toast.ts new file mode 100644 index 0000000..8eac8e3 --- /dev/null +++ b/src/astryx/Toast.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/Toast"; diff --git a/src/astryx/ToggleButton.ts b/src/astryx/ToggleButton.ts new file mode 100644 index 0000000..c00d894 --- /dev/null +++ b/src/astryx/ToggleButton.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/ToggleButton"; diff --git a/src/astryx/Token.ts b/src/astryx/Token.ts new file mode 100644 index 0000000..5e6cd28 --- /dev/null +++ b/src/astryx/Token.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/Token"; diff --git a/src/astryx/Tokenizer.ts b/src/astryx/Tokenizer.ts new file mode 100644 index 0000000..135aec0 --- /dev/null +++ b/src/astryx/Tokenizer.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/Tokenizer"; diff --git a/src/astryx/Toolbar.ts b/src/astryx/Toolbar.ts new file mode 100644 index 0000000..22ef533 --- /dev/null +++ b/src/astryx/Toolbar.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/Toolbar"; diff --git a/src/astryx/Tooltip.ts b/src/astryx/Tooltip.ts new file mode 100644 index 0000000..2aa278c --- /dev/null +++ b/src/astryx/Tooltip.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/Tooltip"; diff --git a/src/astryx/TopNav.ts b/src/astryx/TopNav.ts new file mode 100644 index 0000000..15d473e --- /dev/null +++ b/src/astryx/TopNav.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/TopNav"; diff --git a/src/astryx/TreeList.ts b/src/astryx/TreeList.ts new file mode 100644 index 0000000..7ba7682 --- /dev/null +++ b/src/astryx/TreeList.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/TreeList"; diff --git a/src/astryx/Typeahead.ts b/src/astryx/Typeahead.ts new file mode 100644 index 0000000..e8ca15a --- /dev/null +++ b/src/astryx/Typeahead.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/Typeahead"; diff --git a/src/astryx/Typeahead/utils.ts b/src/astryx/Typeahead/utils.ts new file mode 100644 index 0000000..30b7969 --- /dev/null +++ b/src/astryx/Typeahead/utils.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/Typeahead/utils"; diff --git a/src/astryx/VStack.ts b/src/astryx/VStack.ts new file mode 100644 index 0000000..c36a322 --- /dev/null +++ b/src/astryx/VStack.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/VStack"; diff --git a/src/astryx/VisuallyHidden.ts b/src/astryx/VisuallyHidden.ts new file mode 100644 index 0000000..defc97d --- /dev/null +++ b/src/astryx/VisuallyHidden.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/VisuallyHidden"; diff --git a/src/astryx/astryx.css b/src/astryx/astryx.css new file mode 100644 index 0000000..3aef57d --- /dev/null +++ b/src/astryx/astryx.css @@ -0,0 +1,2 @@ +/* @generated by scripts/gen-exports.mjs. Do not edit. */ +@import "@astryxdesign/core/astryx.css"; diff --git a/src/astryx/hooks.ts b/src/astryx/hooks.ts new file mode 100644 index 0000000..078bf70 --- /dev/null +++ b/src/astryx/hooks.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/hooks"; diff --git a/src/astryx/i18n.ts b/src/astryx/i18n.ts new file mode 100644 index 0000000..badb298 --- /dev/null +++ b/src/astryx/i18n.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/i18n"; diff --git a/src/astryx/lab.ts b/src/astryx/lab.ts new file mode 100644 index 0000000..a5a725c --- /dev/null +++ b/src/astryx/lab.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/lab"; diff --git a/src/astryx/lab/lab.css b/src/astryx/lab/lab.css new file mode 100644 index 0000000..809df59 --- /dev/null +++ b/src/astryx/lab/lab.css @@ -0,0 +1,2 @@ +/* @generated by scripts/gen-exports.mjs. Do not edit. */ +@import "@astryxdesign/lab/lab.css"; diff --git a/src/astryx/naming.ts b/src/astryx/naming.ts new file mode 100644 index 0000000..304f659 --- /dev/null +++ b/src/astryx/naming.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/naming"; diff --git a/src/astryx/reset.css b/src/astryx/reset.css new file mode 100644 index 0000000..5ff652e --- /dev/null +++ b/src/astryx/reset.css @@ -0,0 +1,2 @@ +/* @generated by scripts/gen-exports.mjs. Do not edit. */ +@import "@astryxdesign/core/reset.css"; diff --git a/src/astryx/tailwind-theme.css b/src/astryx/tailwind-theme.css new file mode 100644 index 0000000..949693b --- /dev/null +++ b/src/astryx/tailwind-theme.css @@ -0,0 +1,2 @@ +/* @generated by scripts/gen-exports.mjs. Do not edit. */ +@import "@astryxdesign/core/tailwind-theme.css"; diff --git a/src/astryx/theme.ts b/src/astryx/theme.ts new file mode 100644 index 0000000..cd87c95 --- /dev/null +++ b/src/astryx/theme.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/theme"; diff --git a/src/astryx/theme/neutral.ts b/src/astryx/theme/neutral.ts new file mode 100644 index 0000000..867cdc1 --- /dev/null +++ b/src/astryx/theme/neutral.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/theme-neutral"; diff --git a/src/astryx/theme/neutral/built.ts b/src/astryx/theme/neutral/built.ts new file mode 100644 index 0000000..25fe369 --- /dev/null +++ b/src/astryx/theme/neutral/built.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/theme-neutral/built"; diff --git a/src/astryx/theme/neutral/theme.css b/src/astryx/theme/neutral/theme.css new file mode 100644 index 0000000..5bfc62c --- /dev/null +++ b/src/astryx/theme/neutral/theme.css @@ -0,0 +1,2 @@ +/* @generated by scripts/gen-exports.mjs. Do not edit. */ +@import "@astryxdesign/theme-neutral/theme.css"; diff --git a/src/astryx/theme/syntax.ts b/src/astryx/theme/syntax.ts new file mode 100644 index 0000000..140f720 --- /dev/null +++ b/src/astryx/theme/syntax.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/theme/syntax"; diff --git a/src/astryx/theme/tokens.stylex.ts b/src/astryx/theme/tokens.stylex.ts new file mode 100644 index 0000000..e5af598 --- /dev/null +++ b/src/astryx/theme/tokens.stylex.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/theme/tokens.stylex"; diff --git a/src/astryx/theme/tokens.ts b/src/astryx/theme/tokens.ts new file mode 100644 index 0000000..1f8761c --- /dev/null +++ b/src/astryx/theme/tokens.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/theme/tokens"; diff --git a/src/astryx/utils.ts b/src/astryx/utils.ts new file mode 100644 index 0000000..737b584 --- /dev/null +++ b/src/astryx/utils.ts @@ -0,0 +1,2 @@ +// @generated by scripts/gen-exports.mjs. Do not edit. +export * from "@astryxdesign/core/utils"; diff --git a/src/exports.test.ts b/src/exports.test.ts new file mode 100644 index 0000000..25449d4 --- /dev/null +++ b/src/exports.test.ts @@ -0,0 +1,112 @@ +/** + * Export-surface drift guard (FR-4047). + * + * The mirrored Astryx surface, the root barrel and package.json#exports are + * all generated by scripts/gen-exports.mjs and committed. This regenerates + * them in memory and compares byte for byte, so a bump of any + * @astryxdesign/* pin, or an edit to exports.exclude.json or + * exports.customs.json, fails here until `pnpm run gen:exports` is re-run and + * its diff reviewed. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { beforeAll, describe, expect, it } from "vitest"; + +import { + generate, + listMirrorFiles, + type GenerateResult, +} from "../scripts/gen-exports.mjs"; + +const ROOT = join(__dirname, ".."); + +let result: GenerateResult; + +beforeAll(async () => { + result = await generate(); +}, 120_000); + +describe("generated export surface", () => { + it("every generated file matches the committed copy", () => { + const stale: string[] = []; + for (const [path, content] of result.files) { + let committed: string | undefined; + try { + committed = readFileSync(join(ROOT, path), "utf8"); + } catch { + committed = undefined; + } + if (committed !== content) stale.push(path); + } + expect(stale, "run `pnpm run gen:exports` and review the diff").toEqual([]); + }); + + it("no mirror file is left over from an older Astryx", async () => { + const generated = [...result.files.keys()].filter((p) => + p.startsWith("src/astryx/"), + ); + expect(await listMirrorFiles()).toEqual(generated.sort()); + }); + + it("package.json#exports matches the generated map, key order included", () => { + const pkg = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8")) as { + exports: unknown; + }; + expect(JSON.stringify(pkg.exports, null, 2)).toBe( + JSON.stringify(result.exports, null, 2), + ); + }); +}); + +describe("export surface rules", () => { + it("mirrors a core component at the same top-level subpath", () => { + expect(result.exports["./Button"]).toEqual({ + types: "./dist/astryx/Button.d.ts", + import: "./dist/astryx/Button.js", + }); + expect(result.exports["./Table/utils"]).toBeDefined(); + }); + + it("keeps the tokens subpath ending in .stylex, which the StyleX compiler keys on", () => { + expect(Object.keys(result.exports)).toContain("./theme/tokens.stylex"); + }); + + it("mirrors lab and the neutral theme under their prefixes", () => { + for (const key of [ + "./lab", + "./lab/lab.css", + "./theme/neutral", + "./theme/neutral/built", + "./theme/neutral/theme.css", + ]) { + expect(Object.keys(result.exports)).toContain(key); + } + }); + + it("hides every excluded subpath and keeps its names out of the root barrel", () => { + const exclusions = JSON.parse( + readFileSync(join(ROOT, "exports.exclude.json"), "utf8"), + ) as { name: string }[]; + for (const { name } of exclusions) { + expect(Object.keys(result.exports)).not.toContain(`./${name}`); + } + const barrel = result.files.get("src/index.ts") ?? ""; + expect(barrel).not.toMatch(/^\s+Dialog,$/m); + expect(result.report.droppedFromRoot).toContain("Dialog"); + }); + + it("CSS mirrors are one @import of the Astryx sheet", () => { + const css = [...result.files].filter(([path]) => path.endsWith(".css")); + expect(css.length).toBeGreaterThan(0); + for (const [path, content] of css) { + const rules = content.replace(/\/\*[\s\S]*?\*\//g, "").trim(); + expect(rules, path).toMatch(/^@import "@astryxdesign\/[^"]+\.css";$/); + } + }); + + it("a deprecated custom whose name Astryx owns yields to Astryx in the root barrel", () => { + const barrel = result.files.get("src/index.ts") ?? ""; + expect(result.report.legacyCollisions).toContain("Button"); + expect(barrel).not.toMatch(/export \{[^}]*\bButton\b[^}]*\} from "\.\/components/); + }); +}); diff --git a/src/index.ts b/src/index.ts index a738320..756f5dd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,35 +1,1420 @@ /** - * @lablup/ui-common + * @generated by scripts/gen-exports.mjs. Do not edit. * - * Product-neutral UI components and design tokens shared across Lablup - * products. Import from here for the full surface, or from - * `@lablup/ui-common/components/` when you want the smallest - * possible graph (see the component subpath exports in package.json). + * The root barrel: Astryx core's root exports, minus the names of the + * subpaths in exports.exclude.json, then the ui-common customs listed in + * exports.customs.json. Edit those files and run `pnpm run gen:exports`. * - * Component CSS travels with the component. What stays opt-in is the palette: - * `@lablup/ui-common/styles/base.css` (required, and the default theme on its - * own) plus `styles/themes/orange-dark.css` if you switch through - * `[data-theme]`. A product with its own identity ships its own themes over - * the same token names. + * Left out because their subpath is excluded: + * Dialog, DialogHeader, DialogHeaderProps, DialogPosition, DialogProps, + * DialogPurpose, DialogVariant, DialogVariantMap, ImperativeDialogReturn, + * useImperativeDialog. + * + * Deprecated customs whose names Astryx owns. The Astryx export wins + * here; the custom stays at @lablup/ui-common/components/ until 0.3: + * Badge, BadgeProps, Button, ButtonProps, EmptyState, EmptyStateProps, + * ProgressBar, ProgressBarProps, ProgressBarVariant, Skeleton, SkeletonProps, + * Tooltip, TooltipProps. */ -export * from "./components/BaseCard"; -export * from "./components/Badge"; -export * from "./components/Button"; -export * from "./components/DataTable"; -export * from "./components/DigitPopIn"; -export * from "./components/Drawer"; -export * from "./components/EmptyState"; -export * from "./components/ErrorState"; -export * from "./components/PageHeader"; -export * from "./components/PageLayout"; -export * from "./components/ProgressBar"; -export * from "./components/Skeleton"; -export * from "./components/SmoothHeight"; -export * from "./components/Select"; -export * from "./components/StatCard"; -export * from "./components/StatusTag"; -export * from "./components/Tabs"; -export * from "./components/Tooltip"; - -export * from "./hooks"; +export { + AlertDialog, + AppShell, + AppShellMobileContext, + AspectRatio, + Avatar, + AvatarGroup, + AvatarGroupOverflow, + AvatarStatusDot, + Badge, + Banner, + BaseTypeahead, + Blockquote, + BottomSheet, + BottomSheetSwitcher, + BreadcrumbItem, + BreadcrumbMenuCheckboxItem, + BreadcrumbMenuDivider, + BreadcrumbMenuItem, + BreadcrumbMenuRadioGroup, + BreadcrumbMenuRadioItem, + BreadcrumbMenuSubMenu, + Breadcrumbs, + Button, + ButtonGroup, + Calendar, + Card, + Carousel, + Center, + ChatComposer, + ChatComposerDrawer, + ChatComposerInput, + ChatComposerTokenElement, + ChatDictationButton, + ChatLayout, + ChatLayoutScrollButton, + ChatMessage, + ChatMessageBubble, + ChatMessageList, + ChatMessageMetadata, + ChatSendButton, + ChatSystemMessage, + ChatTokenizedText, + ChatToolCalls, + CheckIndicator, + CheckboxIndicator, + CheckboxInput, + CheckboxList, + CheckboxListItem, + Citation, + ClickableCard, + Code, + CodeBlock, + Collapsible, + CollapsibleGroup, + CommandPalette, + CommandPaletteEmpty, + CommandPaletteFooter, + CommandPaletteGroup, + CommandPaletteInput, + CommandPaletteItem, + CommandPaletteList, + ComplexSelector, + ContextMenu, + ContextMenuCheckboxItem, + ContextMenuDivider, + ContextMenuItem, + ContextMenuRadioGroup, + ContextMenuRadioItem, + ContextMenuSubMenu, + DATE_FORMAT_LONG, + DATE_FORMAT_MONTH_ONLY, + DATE_FORMAT_MONTH_YEAR, + DATE_FORMAT_SHORT, + DATE_FORMAT_SHORT_WITH_WEEKDAY, + DATE_FORMAT_SHORT_WITH_YEAR, + DATE_FORMAT_WEEKDAY_ONLY, + DATE_FORMAT_WITH_WEEKDAY, + DEFAULT_MIN_COLUMN_WIDTH, + DEFAULT_WIDTH_BREAKPOINTS, + DateInput, + DateRangeInput, + DateTimeInput, + Divider, + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContext, + DropdownMenuDivider, + DropdownMenuItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSubMenu, + EDGE_COMP_ATTR, + EmptyState, + Field, + FieldLabel, + FieldStatus, + FileInput, + FormLayout, + FormLayoutContext, + Grid, + GridSpan, + HStack, + Heading, + HoverCard, + INTERACTIVE_SELECTORS, + Icon, + IconButton, + InputClearButton, + InputGroup, + InputGroupText, + InteractiveRoleContext, + InternationalizationContext, + InternationalizationProvider, + Item, + Kbd, + LayerProvider, + Layout, + LayoutAreaContext, + LayoutContent, + LayoutDividerContext, + LayoutFooter, + LayoutHeader, + LayoutPanel, + Lightbox, + Link, + LinkProvider, + List, + ListItem, + Markdown, + MediaTheme, + MetadataList, + MetadataListItem, + MobileNav, + MobileNavToggle, + MoreMenu, + MultiSelector, + NavHeadingCloseContext, + NavHeadingMenu, + NavHeadingMenuContext, + NavHeadingMenuItem, + NavIcon, + NumberInput, + Outline, + OverflowList, + Overlay, + Pagination, + Popover, + PowerSearch, + PowerSearchFilterEditor, + PowerSearchToken, + ProgressBar, + RadioIndicator, + RadioList, + RadioListContext, + RadioListItem, + ResizeHandle, + SHARED_DATE_FORMAT_OPTIONS, + SYNC_TOKENIZE_THRESHOLD, + ScrollableArea, + Section, + SegmentedControl, + SegmentedControlItem, + SelectableCard, + Selector, + SelectorOption, + SideNav, + SideNavCollapseButton, + SideNavHeading, + SideNavItem, + SideNavRenderContext, + SideNavSection, + SizeContext, + SizeProvider, + Skeleton, + Slider, + Spinner, + Stack, + StackItem, + StatusDot, + Step, + Stepper, + Switch, + SyntaxTheme, + TOKEN_TYPES, + Tab, + TabList, + TabMenu, + Table, + TableBody, + TableCell, + TableContext, + TableFooter, + TableHeader, + TableHeaderCell, + TableRow, + Text, + TextArea, + TextInput, + Theme, + ThemeContext, + Thumbnail, + TimeInput, + Timestamp, + Toast, + ToggleButton, + ToggleButtonGroup, + Token, + Tokenizer, + Toolbar, + Tooltip, + TopNav, + TopNavHeading, + TopNavItem, + TopNavMegaMenu, + TopNavMegaMenuFeaturedCard, + TopNavMegaMenuItem, + TopNavMenu, + TopNavRenderContext, + TreeList, + Typeahead, + TypeaheadItem, + VStack, + VisuallyHidden, + WIDTH_BREAKPOINT_NAMES, + adjustTime, + applyHighlightRangesBatch, + applyHighlightRangesChunked, + applyHighlightRangesFlat, + borderDefaults, + borderVars, + characterCount, + clampTime, + cleanupRanges, + colorDefaults, + colorVars, + compareTime, + composeEventHandlers, + container, + createISOTimeString, + createIncrementalState, + createPowerSearchConfig, + createStaticSource, + dataTokenDefaults, + dateToISO, + defaultIndicators, + defineSyntaxTheme, + defineTheme, + devError, + devWarn, + domainTokenDefaults, + durationDefaults, + durationVars, + easeDefaults, + easeVars, + edgeCompSlot, + ensureHighlightStyles, + expandColorScale, + expandMotionScale, + expandRadiusScale, + expandTypeScale, + firstCharacter, + flatTokensToLines, + focusDefaults, + focusOutlineProps, + focusOutlineStyles, + focusVars, + fontWeightDefaults, + fontWeightVars, + formatColor, + formatDevMessage, + formatDisplayTime12h, + formatDisplayTime24h, + formatHex, + formatISOTime, + formatSharedDate, + generateAdaptationCSS, + generateColumns, + generateOnMediaCSS, + generatePageRange, + generateThemeCSS, + generateThemeRules, + generateThemeRulesSplit, + generateTypeScaleComponents, + getDaysInMonth, + getExtendedIcon, + getIcon, + getIconRegistry, + getIndicator, + getInputARIA, + getItemGroup, + getKey, + getLocaleDirection, + getRegisteredTheme, + getRegisteredThemes, + getWeekNumber, + groupItems, + hasActiveFocusTrapEscape, + indicatorScope, + inputStatusBorderStyles, + inputStatusFocusStyles, + inputStatusFocusWithinStyles, + inputStatusHoverShadowStyles, + inputWrapperStyles, + isDateInRange, + isDefinedTheme, + isFocusDetached, + isImeKeyEvent, + isLocaleDayFirst, + isRenderable, + isSameDay, + isTimeInRange, + mergeProps, + mergeRefs, + observeResize, + overlayPaddingReset, + paginateData, + parseColor, + parseDateInput, + parseHex, + parseISO, + parseISOTime, + parseInline, + parseMarkdown, + parseMarkdownIncremental, + parseOutlineFromMarkdown, + parseRgb, + parseStyleKey, + parseTimeInput, + percent, + pixel, + plainDateAddDays, + plainDateAddMonths, + plainDateCreate, + plainDateDayOfWeek, + plainDateFormat, + plainDateFromDate, + plainDateFromISO, + plainDateFromInstant, + plainDateGetWeekNumber, + plainDateIsAfter, + plainDateIsBefore, + plainDateIsEqual, + plainDateIsInRange, + plainDateMax, + plainDateMin, + plainDateSetEndOfWeekExclusive, + plainDateSetFirstOfMonth, + plainDateSetStartOfWeek, + plainDateToDate, + plainDateToISO, + plainDateToInstant, + plainDateToday, + proportional, + radiusDefaults, + radiusVars, + registerIcons, + registerTheme, + renderIconSlot, + resetIcons, + resetThemes, + resolveColumnWidths, + resolveContextActions, + resolveOperatorLabel, + resolveSize, + resolveThemeToken, + resolveThemeTokens, + rtlStyles, + shadowDefaults, + shadowVars, + sizeDefaults, + sizeVars, + spacingDefaults, + spacingVars, + stack, + stackItem, + syntaxTokenDefaults, + textSizeDefaults, + textSizeVars, + themeDataAttributes, + themeProps, + toGLFloats, + toSearchFilters, + tokenDefaults, + tokenVar, + tokenVars, + tokenize, + tokenizeAsync, + tokenizeStreaming, + truncateCharacters, + typeScaleDefaults, + typeScaleVars, + typographyDefaults, + typographyVars, + unobserveResize, + useAnnounce, + useAppShellMobile, + useAvatarGroup, + useBaseTablePlugins, + useButtonGroup, + useCalendarConstraints, + useCalendarDays, + useCalendarNavigation, + useChatComposerContext, + useChatComposerTokens, + useChatDictation, + useChatLayoutContext, + useChatNewMessages, + useChatPasteAsToken, + useChatStreamScroll, + useClickableContainer, + useClipboard, + useCollapsible, + useCollator, + useCombobox, + useCommandPaletteContext, + useContainerReveal, + useDevWarning, + useDirection, + useDropdownMenuContext, + useEntryAnimation, + useFocusTrap, + useGridFocus, + useHotkeys, + useHoverCard, + useIcon, + useImageMode, + useImperativeAlertDialog, + useIndicator, + useIndicatorFocusRing, + useInputContainer, + useInputGroup, + useInputStatusIcon, + useInteractiveRole, + useInteractiveRoleContext, + useKeyboardHint, + useLayer, + useLightbox, + useLinkComponent, + useLinkify, + useListFocus, + useLocale, + useLongPress, + useMediaQuery, + useMergedRefs, + useMultiCombobox, + useNavHeadingCloseContext, + useNavHeadingMenuContext, + useOutlineFromDOM, + useOutlineFromMarkdown, + useOverflow, + useOverlay, + usePopover, + usePowerSearchConfig, + useResizable, + useScrollLock, + useScrollOverflow, + useScrollableArea, + useSelectedItemOffset, + useSideNavCollapse, + useSideNavRenderMode, + useSize, + useSpeechRecognition, + useStepperContext, + useStreamingText, + useSyntaxTheme, + useTabListContext, + useTableColumnResize, + useTableColumnSettings, + useTableColumnSettingsState, + useTableFilterState, + useTableFiltering, + useTableGroupedRows, + useTablePagination, + useTableRowExpansion, + useTableRowIndex, + useTableRowStatus, + useTableSelection, + useTableSelectionState, + useTableSortable, + useTableSortableState, + useTableStickyColumns, + useTableTreeData, + useTableTreeState, + useTheme, + useThemeName, + useToast, + useTooltip, + useTopNavRenderMode, + useTranslator, + useTreeFocus, + useTruncation, + useTypeahead, + warnOnce, +} from "@astryxdesign/core"; +export type { + AlertDialogProps, + AnnounceFn, + AnnouncePoliteness, + AppShellBreakpoint, + AppShellMobileContextValue, + AppShellProps, + AppShellVariant, + AppShellVariantMap, + AspectRatioFit, + AspectRatioProps, + AspectRatioShape, + AvatarGroupContextValue, + AvatarGroupOverflowProps, + AvatarGroupProps, + AvatarProps, + AvatarShape, + AvatarSize, + AvatarStatusDotProps, + AvatarStatusDotVariant, + AvatarStatusDotVariantMap, + BadgeProps, + BadgeVariant, + BadgeVariantMap, + BannerContainer, + BannerContainerMap, + BannerProps, + BannerStatus, + BannerStatusMap, + BaseProps, + BaseTableProps, + BaseTypeaheadProps, + BlockNode, + BlockNodeWithMath, + BlockquoteProps, + BodyCellRenderProps, + BodyRowRenderProps, + BorderVarName, + BottomSheetHeight, + BottomSheetProps, + BottomSheetSnapPoint, + BottomSheetSwitcherProps, + BreadcrumbItemProps, + BreadcrumbMenuCheckboxItemProps, + BreadcrumbMenuDividerData, + BreadcrumbMenuDividerProps, + BreadcrumbMenuItemData, + BreadcrumbMenuItemProps, + BreadcrumbMenuOption, + BreadcrumbMenuRadioGroupProps, + BreadcrumbMenuRadioItemProps, + BreadcrumbMenuSection, + BreadcrumbMenuSubMenuProps, + BreadcrumbsProps, + BreadcrumbsVariant, + BreadcrumbsVariantMap, + BuiltinTextColor, + BuiltinTextType, + ButtonGroupContextValue, + ButtonGroupOrientation, + ButtonGroupProps, + ButtonProps, + ButtonSize, + ButtonVariant, + ButtonVariantMap, + CalendarDay, + CalendarHandle, + CalendarProps, + CardProps, + CardVariant, + CardVariantMap, + CarouselHandle, + CarouselProps, + Catalog, + CenterAxis, + CenterProps, + ChatComposerContextValue, + ChatComposerDensity, + ChatComposerDrawerProps, + ChatComposerInputControl, + ChatComposerInputHandle, + ChatComposerInputProps, + ChatComposerProps, + ChatComposerStatus, + ChatComposerToken, + ChatComposerTrigger, + ChatComposerTriggerItem, + ChatDensity, + ChatDictationButtonProps, + ChatLayoutProps, + ChatLayoutScrollButtonProps, + ChatMessageBubbleProps, + ChatMessageBubbleVariant, + ChatMessageListProps, + ChatMessageMetadataProps, + ChatMessageProps, + ChatMessageSender, + ChatMessageStatus, + ChatScrollToBottomOptions, + ChatSendButtonProps, + ChatSystemMessageProps, + ChatSystemMessageVariant, + ChatTokenizedTextProps, + ChatToolCallItem, + ChatToolCallStatus, + ChatToolCallsProps, + CheckboxInputProps, + CheckboxInputSize, + CheckboxListItemProps, + CheckboxListProps, + CitationProps, + CitationSource, + ClassProps, + ClassValue, + ClickableCardProps, + ClickableContainerResult, + CodeBlockProps, + CodeColor, + CodeProps, + CodeSize, + CollapsibleChevronPosition, + CollapsibleConfig, + CollapsibleGroupDensity, + CollapsibleGroupProps, + CollapsibleProps, + ColorScaleConfig, + ColorScaleTokens, + ColorVarName, + ColumnSettingsOption, + ColumnWidth, + CommandPaletteContextValue, + CommandPaletteEmptyProps, + CommandPaletteFooterProps, + CommandPaletteGroupProps, + CommandPaletteInputProps, + CommandPaletteItemProps, + CommandPaletteListProps, + CommandPaletteProps, + ComplexSelectorHandle, + ComplexSelectorProps, + ComplexSelectorRenderState, + ComplexSelectorSize, + ComplexSelectorStatus, + ComplexSelectorVariant, + ComponentStyleMap, + ContainerComponent, + ContainerOptions, + ContainerRevealOptions, + ContentRevealOptions, + ContextLayerOptions, + ContextLayerReturn, + ContextMenuCheckboxItemProps, + ContextMenuDividerData, + ContextMenuDividerProps, + ContextMenuItemData, + ContextMenuItemProps, + ContextMenuOption, + ContextMenuProps, + ContextMenuRadioGroupProps, + ContextMenuRadioItemProps, + ContextMenuSection, + ContextMenuSubMenuProps, + ContextRenderProps, + CoreIndicatorName, + CoreTokenName, + CreateStaticSourceOptions, + CustomOperatorValue, + CustomTextTypes, + DataTokenName, + DateAbsoluteOperatorValue, + DateInputFormat, + DateInputNativePicker, + DateInputProps, + DateInputSize, + DateInputStatus, + DateInputStatusType, + DateRange, + DateRangeFilterPreset, + DateRangeInputProps, + DateRangeInputSize, + DateRangeInputStatus, + DateRangeInputStatusType, + DateRangeOperatorValue, + DateRangePreset, + DateRelativeOperatorValue, + DateTimeInputHourFormat, + DateTimeInputNativePicker, + DateTimeInputProps, + DateTimeInputSize, + DateTimeInputStatus, + DateTimeInputStatusType, + DateTimeInputTimeIncrement, + DateTimeInputTimeOptionInterval, + DateTimeRange, + DateTimeRangePart, + DayOfWeek, + DayOfWeekName, + DefineThemeInput, + DefinedTheme, + DividerProps, + DividerVariant, + DividerVariantMap, + DomainTokenName, + DropdownMenuButtonProps, + DropdownMenuCheckboxItemProps, + DropdownMenuContextValue, + DropdownMenuDividerData, + DropdownMenuDividerProps, + DropdownMenuItemData, + DropdownMenuItemProps, + DropdownMenuOption, + DropdownMenuPresentation, + DropdownMenuProps, + DropdownMenuRadioGroupProps, + DropdownMenuRadioItemProps, + DropdownMenuSection, + DropdownMenuSize, + DropdownMenuSubMenuProps, + DurationVarName, + EaseVarName, + ElementSize, + EmptyOperatorValue, + EmptyStateProps, + EntityListOperatorValue, + EntryAnimationPreset, + EnumItem, + EnumListOperatorValue, + EnumOperatorValue, + ExtendedIconName, + FieldDefinition, + FieldLabelProps, + FieldProps, + FieldStatusInput, + FieldStatusProps, + FieldStatusType, + FieldStatusVariant, + FieldStatusVariantMap, + FileInputProps, + FileInputStatus, + FileInputStatusType, + FilterValue, + FilterValueCustom, + FilterValueDateAbsolute, + FilterValueDateRange, + FilterValueDateRelative, + FilterValueEmpty, + FilterValueEntityList, + FilterValueEnum, + FilterValueEnumList, + FilterValueFloat, + FilterValueInteger, + FilterValueNested, + FilterValueString, + FilterValueStringList, + FilterValueTime, + FixedLayerOptions, + FixedLayerReturn, + FixedRenderProps, + FloatOperatorValue, + FontWeight, + FontWeightVarName, + FormLayoutDirection, + FormLayoutProps, + FormOptionality, + GridAlignment, + GridColumns, + GridProps, + GridSpanProps, + GroupItemsOptions, + HStackProps, + HeaderCellRenderProps, + HeaderRowRenderProps, + HeadingLevel, + HeadingProps, + HeadingTag, + HeadingType, + HeadingTypeMap, + Hotkey, + HoverCardFocusTrigger, + HoverCardOptions, + HoverCardProps, + HoverCardReturn, + HoverCardTouchTrigger, + ISODateString, + ISODateTimeString, + ISOTimeString, + IconButtonProps, + IconColor, + IconName, + IconProps, + IconRegistry, + IconRegistrySource, + IconSize, + IconType, + ImageSampleRegion, + ImperativeAlertDialogReturn, + IncrementalParseState, + IndicatorComponent, + IndicatorFamily, + IndicatorFamilyMap, + IndicatorMap, + IndicatorName, + IndicatorNameOfFamily, + IndicatorPosition, + IndicatorProps, + IndicatorRegistry, + IndicatorRegistrySource, + IndicatorSize, + IndicatorState, + InferData, + InlineNode, + InlineNodeWithMath, + InputARIA, + InputARIAInputGroup, + InputGroupContextValue, + InputGroupProps, + InputGroupSize, + InputGroupTextProps, + InputSize, + InputStatus, + InputStatusType, + IntegerOperatorValue, + InteractiveRole, + InternationalizationContextValue, + InternationalizationProviderProps, + ItemAlign, + ItemDensity, + ItemGroup, + ItemProps, + KbdProps, + Key, + KeyFallback, + KeyboardHintOrientation, + LayerAlignment, + LayerPlacement, + LayerProviderProps, + LayerToastConfig, + LayoutArea, + LayoutContentProps, + LayoutDividerContextValue, + LayoutFooterProps, + LayoutHeaderProps, + LayoutHeight, + LayoutPanelProps, + LayoutProps, + LightboxMedia, + LightboxMediaType, + LightboxProps, + LinkComponentType, + LinkProps, + LinkProviderProps, + LinkifyPattern, + ListDensity, + ListFocusOrientation, + ListItemNode, + ListItemProps, + ListProps, + ListStyle, + Locale, + MarkdownComponents, + MarkdownInlinePlugin, + MarkdownProps, + MarkdownSource, + MathBlockNode, + MathInlineNode, + MathParseOptions, + MediaThemeMode, + MediaThemeProps, + MenuPresentation, + MessageEntry, + MessagesByLocale, + MetadataListColumns, + MetadataListItemProps, + MetadataListLabelConfig, + MetadataListProps, + MobileNavConfig, + MobileNavProps, + MobileNavToggleProps, + MoreMenuProps, + MotionScaleConfig, + MotionScaleTokens, + MultiSelectorDivider, + MultiSelectorOptionData, + MultiSelectorOptionType, + MultiSelectorPresentation, + MultiSelectorProps, + MultiSelectorSection, + MultiSelectorSelectedItem, + MultiSelectorSize, + MultiSelectorStatus, + MultiSelectorStatusType, + NamespacedIconName, + NavHeadingCloseContextValue, + NavHeadingMenuContextValue, + NavHeadingMenuItemProps, + NavHeadingMenuProps, + NavHeadingMenuSize, + NavIconProps, + NestedOperatorValue, + NumberInputProps, + NumberInputSize, + NumberInputStatus, + NumberInputStatusType, + OperatorTokenizationConfig, + OperatorValue, + OutlineItem, + OutlineProps, + OverflowItem, + OverflowListProps, + OverlayAlign, + OverlayContainerProps, + OverlayPosition, + OverlayProps, + OverlayScrimMode, + OverlayShowOn, + Overrides, + PaginationProps, + PaginationSize, + PaginationVariant, + PaginationVariantMap, + ParseOptions, + ParsedTime, + PartialFilter, + PixelWidth, + PlainDate, + PopoverProps, + PopoverTriggerRenderProps, + PowerSearchChangeType, + PowerSearchComponentOverride, + PowerSearchComponents, + PowerSearchConfig, + PowerSearchEditorProps, + PowerSearchEntity, + PowerSearchField, + PowerSearchFilter, + PowerSearchHandle, + PowerSearchOperator, + PowerSearchOperatorBase, + PowerSearchOperatorWithI18nKey, + PowerSearchOperatorWithLabel, + PowerSearchProps, + PowerSearchSize, + PowerSearchTokenProps, + ProgressBarMark, + ProgressBarProps, + ProgressBarVariant, + ProgressBarVariantMap, + ProportionalWidth, + ProseElement, + RGBA, + RadioListContextValue, + RadioListItemProps, + RadioListProps, + RadioListSize, + RadiusScaleConfig, + RadiusScaleTokens, + RadiusVarName, + RelativeDateFilterPreset, + ResizableConfig, + ResizablePercentSize, + ResizableProps, + ResizableRegion, + ResizableRegionConfig, + ResizableSize, + ResizeHandleProps, + ResolveThemeTokenOptions, + ResolveThemeTokensOptions, + ResolvedDefinedTheme, + ResolvedThemeMode, + ScrollAxis, + ScrollAxisState, + ScrollKeyboardAccess, + ScrollOverflowState, + ScrollOverscroll, + ScrollStickyContainment, + ScrollWrapperRenderProps, + ScrollableAreaProps, + ScrollableAreaState, + ScrollableAreaStickyContainment, + ScrollableElementProps, + SearchSource, + SearchableItem, + SectionProps, + SectionVariant, + SectionVariantMap, + SegmentedControlItemProps, + SegmentedControlLayout, + SegmentedControlProps, + SegmentedControlSize, + SelectableCardProps, + SelectorDivider, + SelectorOptionData, + SelectorOptionType, + SelectorPresentation, + SelectorProps, + SelectorSection, + SelectorSize, + SelectorStatus, + SelectorStatusType, + ShadowVarName, + SharedDateFormat, + ShowToastFn, + SideNavCollapseButtonProps, + SideNavCollapseState, + SideNavCollapsibleConfig, + SideNavControlledCollapsible, + SideNavHeadingProps, + SideNavImperativeCollapseHandle, + SideNavItemProps, + SideNavProps, + SideNavRenderMode, + SideNavSectionProps, + SizeValue, + SizeVarName, + SkeletonProps, + SkeletonRadius, + SliderBaseProps, + SliderProps, + SliderRangeProps, + SliderSingleProps, + SourceRange, + SpacingStep, + SpacingToken, + SpacingVarName, + SpinnerProps, + SpinnerShade, + SpinnerSize, + StackAlignment, + StackCrossAlignment, + StackDirection, + StackItemCrossAlignSelf, + StackItemOptions, + StackItemProps, + StackItemSize, + StackMainAlignment, + StackOptions, + StackProps, + StackWrap, + StatusDotProps, + StatusDotVariant, + StatusDotVariantMap, + StepIndicatorPreset, + StepProps, + StepStatus, + StepperCollapsedVariant, + StepperContextValue, + StepperHorizontalOptions, + StepperIndicatorPosition, + StepperOrientation, + StepperProps, + StepperRegistrationOptions, + StreamingTextSpeed, + StringListOperatorValue, + StringOperatorValue, + StyleOverrides, + SwitchLabelPosition, + SwitchLabelSpacing, + SwitchProps, + SyntaxThemeDefinition, + SyntaxThemeInput, + SyntaxThemeTokenInput, + SyntaxThemeTokenKey, + SyntaxThemeTokenMap, + SyntaxToken, + SyntaxTokenName, + SyntaxTokenValue, + TabListLayout, + TabListOverflow, + TabListPattern, + TabListProps, + TabListSize, + TabMenuOption, + TabMenuProps, + TabProps, + TableAlignment, + TableBodyProps, + TableCellNode, + TableCellProps, + TableColumn, + TableColumnAlign, + TableContextAction, + TableContextActions, + TableContextValue, + TableDensity, + TableDividers, + TableFilterFieldRef, + TableFilterState, + TableFilterValue, + TableFilterVariant, + TableFooterProps, + TableHeaderCellProps, + TableHeaderProps, + TablePlugin, + TableProps, + TableRenderProps, + TableRowProps, + TableRowStatus, + TableSemanticRowStatus, + TableSortComparator, + TableSortDirection, + TableSortEntry, + TableSortState, + TableSortableColumnConfig, + TableTextOverflow, + TableTreeRowMeta, + TableVerticalAlign, + TextAreaProps, + TextAreaSize, + TextAreaStatus, + TextAreaStatusType, + TextColor, + TextColorMap, + TextDisplay, + TextInputProps, + TextInputSize, + TextInputStatus, + TextInputStatusType, + TextInputType, + TextJustify, + TextProps, + TextSize, + TextSizeVarName, + TextType, + TextWeight, + TextWrap, + TextXStyleAllowed, + ThemeAdaptationCondition, + ThemeAdaptationRule, + ThemeAdaptationTypographyConfig, + ThemeAdaptationValue, + ThemeAdaptationWidthCondition, + ThemeAdaptations, + ThemeCSSOutput, + ThemeContextValue, + ThemeDataAttributes, + ThemeMode, + ThemeProps, + ThemeRulesSplit, + ThumbnailProps, + TimeInputHourFormat, + TimeInputNativePicker, + TimeInputProps, + TimeInputSize, + TimeInputStatus, + TimeInputStatusType, + TimeOperatorValue, + TimestampFormat, + TimestampProps, + TimestampTooltipEntry, + TimestampTooltipFormat, + ToastCollisionBehavior, + ToastContentRenderFn, + ToastContentRenderProps, + ToastDismissFn, + ToastDismissReason, + ToastOptions, + ToastPosition, + ToastProps, + ToastType, + ToggleButtonGroupMultipleProps, + ToggleButtonGroupProps, + ToggleButtonGroupSingleProps, + ToggleButtonProps, + TokenColor, + TokenColorMap, + TokenLine, + TokenName, + TokenPortal, + TokenProps, + TokenSize, + TokenValue, + TokenizerChange, + TokenizerHandle, + TokenizerOverflowBehavior, + TokenizerProps, + TokenizerSize, + TokenizerStatus, + TokenizerStatusType, + ToolbarProps, + ToolbarSize, + TooltipFocusTrigger, + TooltipOptions, + TooltipProps, + TooltipReturn, + TooltipTouchTrigger, + TopNavHeadingProps, + TopNavItemProps, + TopNavMegaMenuFeaturedCardProps, + TopNavMegaMenuItemProps, + TopNavMegaMenuProps, + TopNavMenuItemData, + TopNavMenuProps, + TopNavProps, + TopNavRenderMode, + Translator, + TranslatorFn, + TreeListDensity, + TreeListItemData, + TreeListProps, + TreeListVariant, + TreeListVariantMap, + TypeScaleConfig, + TypeScaleTokens, + TypeScaleVarName, + TypeaheadItemProps, + TypeaheadProps, + TypeaheadSize, + TypeaheadStatus, + TypeaheadStatusType, + TypographyConfig, + TypographyRole, + TypographyVarName, + UseCalendarConstraintsOptions, + UseCalendarConstraintsReturn, + UseCalendarDaysOptions, + UseCalendarDaysReturn, + UseCalendarNavigationOptions, + UseCalendarNavigationReturn, + UseChatComposerTokensOptions, + UseChatComposerTokensReturn, + UseChatDictationOptions, + UseChatDictationReturn, + UseChatNewMessagesOptions, + UseChatNewMessagesReturn, + UseChatPasteAsTokenOptions, + UseChatPasteAsTokenReturn, + UseChatStreamScrollOptions, + UseChatStreamScrollReturn, + UseClickableContainerOptions, + UseClipboardOptions, + UseClipboardReturn, + UseCollapsibleOptions, + UseCollapsibleReturn, + UseContainerRevealOptions, + UseContainerRevealReturn, + UseFocusTrapOptions, + UseFocusTrapReturn, + UseGridFocusOptions, + UseGridFocusReturn, + UseImageModeOptions, + UseInputContainerOptions, + UseInputStatusIconOptions, + UseInputStatusIconReturn, + UseInteractiveRoleOptions, + UseKeyboardHintOptions, + UseKeyboardHintReturn, + UseLightboxOptions, + UseLightboxReturn, + UseLinkifyOptions, + UseListFocusOptions, + UseListFocusReturn, + UseLongPressHandlers, + UseLongPressOptions, + UseOverflowOptions, + UseOverflowReturn, + UseOverlayOptions, + UseOverlayResult, + UsePopoverOptions, + UsePopoverReturn, + UseResizableMultiConfig, + UseResizableSingleConfig, + UseScrollableAreaOptions, + UseScrollableAreaResult, + UseSpeechRecognitionOptions, + UseSpeechRecognitionReturn, + UseStreamingTextOptions, + UseSyntaxThemeReturn, + UseTableColumnResizeConfig, + UseTableColumnSettingsConfig, + UseTableColumnSettingsStateConfig, + UseTableColumnSettingsStateReturn, + UseTableFilteringConfig, + UseTableGroupedRowsConfig, + UseTableGroupedRowsResult, + UseTablePaginationConfig, + UseTableRowExpansionConfig, + UseTableRowIndexConfig, + UseTableRowStatusConfig, + UseTableSelectionConfig, + UseTableSelectionStateConfig, + UseTableSelectionStateResult, + UseTableSortableConfig, + UseTableSortableStateConfig, + UseTableSortableStateResult, + UseTableStickyColumnsConfig, + UseTableTreeDataConfig, + UseTableTreeStateConfig, + UseTableTreeStateResult, + UseThemeReturn, + UseTreeFocusOptions, + UseTreeFocusReturn, + UseTruncationOptions, + UseTruncationReturn, + UseTypeaheadOptions, + UseTypeaheadReturn, + VStackProps, + VisuallyHiddenProps, + WidthBreakpointName, + WidthBreakpoints, + WordBreak, +} from "@astryxdesign/core"; + +// BaseCard +// Deprecated, removed in 0.3: this module. Replaced by Card. +export { BaseCard } from "./components/BaseCard"; +export type { + BaseCardProps, + BaseCardState, + BaseCardVariant, +} from "./components/BaseCard"; + +// DataTable +// Deprecated, removed in 0.3: this module. Replaced by Table. +export { DataTable } from "./components/DataTable"; +export type { + DataTableColumn, + DataTablePersistedState, + DataTableProps, + SortDirection, +} from "./components/DataTable"; + +// DigitPopIn +export { DigitPopIn } from "./components/DigitPopIn"; +export type { DigitPopInProps } from "./components/DigitPopIn"; + +// Drawer +// Deprecated, removed in 0.3: this module. Replaced by lab/Drawer. +export { Drawer } from "./components/Drawer"; +export type { DrawerProps } from "./components/Drawer"; + +// EmptyState +// Deprecated, removed in 0.3: this module. Replaced by EmptyState. +export type { + EmptyStateAction, + EmptyStateSecondaryAction, +} from "./components/EmptyState"; + +// ErrorState +export { ErrorState } from "./components/ErrorState"; +export type { ErrorAction, ErrorStateProps, ErrorTone } from "./components/ErrorState"; + +// PageHeader +export { PageHeader } from "./components/PageHeader"; +export type { PageHeaderProps } from "./components/PageHeader"; + +// PageLayout +export { PageLayout } from "./components/PageLayout"; +export type { PageLayoutProps, PageLayoutVariant } from "./components/PageLayout"; + +// ProgressBar +// Deprecated, removed in 0.3: this module. Replaced by ProgressBar. +export type { ProgressBarSize } from "./components/ProgressBar"; + +// Skeleton +// Deprecated, removed in 0.3: Skeleton, SkeletonProps. Replaced by Skeleton. +export { + SkeletonCard, + SkeletonChart, + SkeletonRow, + SkeletonText, +} from "./components/Skeleton"; +export type { + SkeletonCardProps, + SkeletonChartProps, + SkeletonRowProps, + SkeletonTextProps, +} from "./components/Skeleton"; + +// SmoothHeight +export { SmoothHeight } from "./components/SmoothHeight"; +export type { SmoothHeightProps } from "./components/SmoothHeight"; + +// Select +// Deprecated, removed in 0.3: this module. Replaced by Selector. +export { Select } from "./components/Select"; +export type { SelectOption, SelectProps } from "./components/Select"; + +// StatCard +export { StatCard, formatCompactNumber } from "./components/StatCard"; +export type { + StatCardAnimation, + StatCardEmphasis, + StatCardProps, + StatCardTone, + StatCardTrend, + StatCardTrendDirection, +} from "./components/StatCard"; + +// StatusTag +// Deprecated, removed in 0.3: this module. Replaced by StatusDot. +export { StatusTag } from "./components/StatusTag"; +export type { StatusKind, StatusTagProps } from "./components/StatusTag"; + +// Tabs +// Deprecated, removed in 0.3: this module. Replaced by TabList. +export { Tabs } from "./components/Tabs"; +export type { + TabGroupMeta, + TabItem, + TabOverflowMode, + TabVariant, + TabsProps, +} from "./components/Tabs"; + +// usePrefersReducedMotion +export { usePrefersReducedMotion } from "./hooks"; diff --git a/vite.config.ts b/vite.config.ts index 890e26b..e7a0855 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -20,39 +20,63 @@ const root = dirname(fileURLToPath(import.meta.url)); function entryPoints(): Record { const entries: Record = { index: resolve(root, "src/index.ts"), - "hooks/index": resolve(root, "src/hooks/index.ts"), }; - for (const file of globSync("src/components/*/index.ts", { cwd: root })) { + const patterns = [ + "src/components/*/index.ts", + // The generated Astryx mirrors (scripts/gen-exports.mjs). Each one is a + // one-line re-export that stays a one-line re-export in dist, because + // every @astryxdesign/* specifier is external. + "src/astryx/**/*.ts", + ]; + for (const file of globSync(patterns, { cwd: root, ignore: ["**/*.test.*"] })) { entries[file.replace(/^src\//, "").replace(/\.ts$/, "")] = resolve(root, file); } return entries; } +/** Keep stylesheets and walk into directories; nothing else ships. */ +const cssOnly = (source: string) => + statSync(source).isDirectory() || source.endsWith(".css"); + /** - * Design tokens are standalone stylesheets that no component imports, so - * Rollup never sees them. They are copied verbatim so the palette stays an - * opt-in entry point rather than being folded into a single bundle. + * Files no module imports, so Rollup never sees them, copied verbatim. + * + * - `styles/`: the deprecated 0.1 token sheets. + * - `astryx/**.css`: the generated one-line `@import` mirrors of the Astryx + * stylesheets. They stay `@import`s so the consumer's bundler resolves the + * Astryx sheet from this package's install location. + * - `locales/`: Astryx core's own locale catalogs, mirrored 1:1 at + * `@lablup/ui-common/locales/.json`. JSON cannot re-export, so this + * is the one mirror that is a copy. It is taken from the installed, pinned + * core at build time, so it cannot drift from the JS. * - * Stylesheets only. The copy used to take the whole directory, so anything - * that ever landed beside the tokens shipped inside the tarball: a test, a - * script, a note. `check:pack` catches the test case by name, but the general - * one is cheaper to prevent here than to enumerate there. + * Stylesheets only where the source is a source directory. The copy used to + * take the whole of `styles/`, so anything that ever landed beside the tokens + * shipped inside the tarball: a test, a script, a note. */ -function copyStyles(): Plugin { +function copyAssets(): Plugin { + const copies: { from: string; to: string; filter: (source: string) => boolean }[] = [ + { from: "src/styles", to: "dist/styles", filter: cssOnly }, + { from: "src/astryx", to: "dist/astryx", filter: cssOnly }, + { + from: "node_modules/@astryxdesign/core/locales", + to: "dist/locales", + filter: (source) => statSync(source).isDirectory() || source.endsWith(".json"), + }, + ]; return { - name: "ui-common-copy-styles", + name: "ui-common-copy-assets", apply: "build", async closeBundle() { - const from = resolve(root, "src/styles"); - if (!existsSync(from)) return; - const to = resolve(root, "dist/styles"); - await mkdir(to, { recursive: true }); - await cp(from, to, { - recursive: true, - filter: (source) => statSync(source).isDirectory() || source.endsWith(".css"), - }); + for (const { from, to, filter } of copies) { + const source = resolve(root, from); + if (!existsSync(source)) continue; + const target = resolve(root, to); + await mkdir(target, { recursive: true }); + await cp(source, target, { recursive: true, dereference: true, filter }); + } }, }; } @@ -158,7 +182,7 @@ export default defineConfig({ plugins: [ react(), dts({ include: ["src"], exclude: ["src/**/*.test.*", "src/test/**"] }), - copyStyles(), + copyAssets(), linkComponentStyles(), ], build: { @@ -174,9 +198,10 @@ export default defineConfig({ // at the consumer, so nothing from node_modules belongs in dist. With // preserveModules, a bare specifier that is not external gets written // into dist/node_modules as a vendored copy of a package the consumer - // already installs, and the two then drift apart. This package has no - // runtime dependencies today; the rule is here so adding one cannot - // silently start shipping it. + // already installs, and the two then drift apart. For Astryx it would + // be worse than drift: a bundled second copy splits the React contexts + // (Theme, i18n, SizeContext) from the copy the consumer's other code + // sees. Every @astryxdesign/* and @stylexjs/* import stays external. external: (id) => { if (id.startsWith("\0")) return false; // plugin virtual module if (id.startsWith(".") || isAbsolute(id)) return false; From 192b93f8a21b7e2a9c659b312f32eb2c8c6f6684 Mon Sep 17 00:00:00 2001 From: Jongeun Date: Fri, 25 Sep 2026 15:26:40 +0000 Subject: [PATCH 03/87] feat: ship the Lablup brand as an Astryx theme @lablup/ui-common/theme/lablup is a defineTheme source that extends neutral, seeds color.accent with #FF7A00/#DC6B03, carries the 0.1 status hues (info as the theme-local --uic-color-info, since Astryx has no info token) and names the 0.1 font family. astryx theme build compiles it into the committed theme/lablup/built JS and theme.css; pnpm run theme:check (part of verify) fails when they are stale. The public-boundary disclosure scan no longer reads the theme/lablup path as a repository reference. --- .prettierignore | 1 + eslint.config.js | 11 +- package.json | 13 +- scripts/check-boundary.mjs | 4 +- scripts/gen-exports.mjs | 9 + src/theme/lablup/built/lablup.d.ts | 13 + src/theme/lablup/built/lablup.js | 568 ++++++++++++++++ src/theme/lablup/built/lablup.variants.d.ts | 16 + src/theme/lablup/built/theme.css | 715 ++++++++++++++++++++ src/theme/lablup/index.ts | 9 + src/theme/lablup/lablupTheme.test.ts | 70 ++ src/theme/lablup/lablupTheme.ts | 50 ++ vite.config.ts | 15 +- 13 files changed, 1490 insertions(+), 4 deletions(-) create mode 100644 src/theme/lablup/built/lablup.d.ts create mode 100644 src/theme/lablup/built/lablup.js create mode 100644 src/theme/lablup/built/lablup.variants.d.ts create mode 100644 src/theme/lablup/built/theme.css create mode 100644 src/theme/lablup/index.ts create mode 100644 src/theme/lablup/lablupTheme.test.ts create mode 100644 src/theme/lablup/lablupTheme.ts diff --git a/.prettierignore b/.prettierignore index 4af75e3..0efb168 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,3 +2,4 @@ dist/ coverage/ pnpm-lock.yaml LICENSE +src/theme/*/built/ diff --git a/eslint.config.js b/eslint.config.js index 8749a29..274a3c3 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -4,7 +4,16 @@ import reactHooks from "eslint-plugin-react-hooks"; import tseslint from "typescript-eslint"; export default tseslint.config( - { ignores: ["dist", "coverage", "fixture/node_modules", "fixture/dist"] }, + { + ignores: [ + "dist", + "coverage", + "fixture/node_modules", + "fixture/dist", + // `astryx theme build` output, committed as generated. + "src/theme/*/built/**", + ], + }, js.configs.recommended, ...tseslint.configs.recommended, { diff --git a/package.json b/package.json index 88681ea..1d558e0 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,15 @@ "types": "./dist/components/*/index.d.ts", "import": "./dist/components/*/index.js" }, + "./theme/lablup": { + "types": "./dist/theme/lablup/index.d.ts", + "import": "./dist/theme/lablup/index.js" + }, + "./theme/lablup/built": { + "types": "./dist/theme/lablup/built/lablup.d.ts", + "import": "./dist/theme/lablup/built/lablup.js" + }, + "./theme/lablup/theme.css": "./dist/theme/lablup/built/theme.css", "./styles/base.css": "./dist/styles/base.css", "./styles/themes/*.css": "./dist/styles/themes/*.css", "./reset.css": "./dist/astryx/reset.css", @@ -545,9 +554,11 @@ "test": "vitest run", "test:watch": "vitest", "gen:exports": "node scripts/gen-exports.mjs", + "theme:build": "astryx theme build src/theme/lablup/lablupTheme.ts -o src/theme/lablup/built/theme.css --icons-specifier @astryxdesign/theme-neutral", + "theme:check": "astryx theme build -c src/theme/lablup/lablupTheme.ts -o src/theme/lablup/built/theme.css --icons-specifier @astryxdesign/theme-neutral", "check:pack": "node scripts/check-pack.mjs", "check:boundary": "node scripts/check-boundary.mjs", - "verify": "pnpm run typecheck && pnpm run lint && pnpm run format:check && pnpm run check:boundary && pnpm run test && pnpm run build && pnpm run check:pack" + "verify": "pnpm run typecheck && pnpm run lint && pnpm run format:check && pnpm run check:boundary && pnpm run theme:check && pnpm run test && pnpm run build && pnpm run check:pack" }, "dependencies": { "@astryxdesign/cli": "0.6.2", diff --git a/scripts/check-boundary.mjs b/scripts/check-boundary.mjs index 1676832..9275a5f 100644 --- a/scripts/check-boundary.mjs +++ b/scripts/check-boundary.mjs @@ -96,8 +96,10 @@ const ALLOWED_LABLUP_REFERENCES = ["all-smi", "backend.ai", "ui-common", "ui-ai" const DISCLOSURE = [ { + // `theme/lablup/...` is this package's own brand-theme path (source dir + // and subpath), not a repository, so a `theme/` prefix is not a match. pattern: new RegExp( - String.raw`lablup/(?!(?:${ALLOWED_LABLUP_REFERENCES.join("|").replace(/\./g, String.raw`\.`)})(?![\w.-]))[\w.-]+`, + String.raw`(? +import type { DefinedTheme } from '@astryxdesign/core/theme'; +import type { IconRegistry } from '@astryxdesign/core/Icon'; +export declare const neutralIconRegistry: IconRegistry; +export declare const lablupTheme: DefinedTheme; diff --git a/src/theme/lablup/built/lablup.js b/src/theme/lablup/built/lablup.js new file mode 100644 index 0000000..e66f742 --- /dev/null +++ b/src/theme/lablup/built/lablup.js @@ -0,0 +1,568 @@ +/** + * @generated by `astryx theme build` — do not edit manually. + * Source: src/theme/lablup/lablupTheme.ts + * Command: astryx theme build src/theme/lablup/lablupTheme.ts --out src/theme/lablup/built/theme.css + * CLI: @astryxdesign/cli@0.6.2 + * Core: @astryxdesign/core@0.6.2 + */ + +import { neutralIconRegistry } from "@astryxdesign/theme-neutral"; +/** + * lablup theme — built by `pnpm exec astryx theme build` + * Import the CSS file alongside this module: + * + * import { lablupTheme } from './lablup'; + * import './lablup.css'; + */ +export const lablupTheme = { + name: 'lablup', + __built: true, + tokens: { + "--font-size-4xs": "0.375rem", + "--font-size-3xs": "0.4375rem", + "--font-size-2xs": "0.5rem", + "--font-size-xs": "0.625rem", + "--font-size-sm": "0.75rem", + "--font-size-base": "0.875rem", + "--font-size-lg": "1.0625rem", + "--font-size-xl": "1.25rem", + "--font-size-2xl": "1.5rem", + "--font-size-3xl": "1.8125rem", + "--font-size-4xl": "2.1875rem", + "--font-size-5xl": "2.625rem", + "--text-heading-1-size": "var(--font-size-2xl)", + "--text-heading-1-weight": "var(--font-weight-semibold)", + "--text-heading-1-leading": "1.3333", + "--text-heading-2-size": "var(--font-size-xl)", + "--text-heading-2-weight": "var(--font-weight-semibold)", + "--text-heading-2-leading": "1.4", + "--text-heading-3-size": "var(--font-size-lg)", + "--text-heading-3-weight": "var(--font-weight-bold)", + "--text-heading-3-leading": "1.4118", + "--text-heading-4-size": "var(--font-size-base)", + "--text-heading-4-weight": "var(--font-weight-bold)", + "--text-heading-4-leading": "1.4286", + "--text-heading-5-size": "var(--font-size-sm)", + "--text-heading-5-weight": "var(--font-weight-semibold)", + "--text-heading-5-leading": "1.6667", + "--text-heading-6-size": "var(--font-size-xs)", + "--text-heading-6-weight": "var(--font-weight-semibold)", + "--text-heading-6-leading": "1.6", + "--text-body-size": "var(--font-size-base)", + "--text-body-weight": "var(--font-weight-normal)", + "--text-body-leading": "1.4286", + "--text-large-size": "var(--font-size-lg)", + "--text-large-weight": "var(--font-weight-semibold)", + "--text-large-leading": "1.4118", + "--text-label-size": "var(--font-size-base)", + "--text-label-weight": "var(--font-weight-medium)", + "--text-label-leading": "1.4286", + "--text-code-size": "var(--font-size-base)", + "--text-code-weight": "var(--font-weight-normal)", + "--text-code-leading": "1.4286", + "--text-supporting-size": "var(--font-size-sm)", + "--text-supporting-weight": "var(--font-weight-normal)", + "--text-supporting-leading": "1.6667", + "--text-display-1-size": "var(--font-size-5xl)", + "--text-display-1-weight": "var(--font-weight-normal)", + "--text-display-1-leading": "1.2381", + "--text-display-2-size": "var(--font-size-4xl)", + "--text-display-2-weight": "var(--font-weight-normal)", + "--text-display-2-leading": "1.2571", + "--text-display-3-size": "var(--font-size-3xl)", + "--text-display-3-weight": "var(--font-weight-normal)", + "--text-display-3-leading": "1.3793", + "--duration-fast-min": "95ms", + "--duration-fast": "125ms", + "--duration-fast-max": "165ms", + "--duration-medium-min": "225ms", + "--duration-medium": "300ms", + "--duration-medium-max": "400ms", + "--duration-slow-min": "525ms", + "--duration-slow": "700ms", + "--duration-slow-max": "935ms", + "--font-family-body": "\"Ubuntu Sans\", \"Pretendard Variable\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, sans-serif", + "--font-family-heading": "\"Ubuntu Sans\", \"Pretendard Variable\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, sans-serif", + "--font-family-code": "ui-monospace, \"SF Mono\", Monaco, Consolas, \"Liberation Mono\", \"Courier New\", monospace", + "--color-syntax-keyword": "light-dark(#6b187c, #efa8ff)", + "--color-syntax-string": "light-dark(#0b5615, #a4d6a3)", + "--color-syntax-comment": "light-dark(#6a6a6a, #9e9e9e)", + "--color-syntax-number": "light-dark(#733100, #ffb37e)", + "--color-syntax-function": "light-dark(#00458c, #a1caff)", + "--color-syntax-type": "light-dark(#6b187c, #efa8ff)", + "--color-syntax-variable": "light-dark(#111111, #e2e2e2)", + "--color-syntax-operator": "light-dark(#6a6a6a, #9e9e9e)", + "--color-syntax-constant": "light-dark(#733100, #ffb37e)", + "--color-syntax-tag": "light-dark(#8a0011, #ffaea7)", + "--color-syntax-attribute": "light-dark(#584400, #eec448)", + "--color-syntax-property": "light-dark(#005348, #90d7c8)", + "--color-syntax-punctuation": "light-dark(#6a6a6a, #9e9e9e)", + "--color-syntax-background": "light-dark(#ffffff, #111111)", + "--color-background-surface": "light-dark(#FFFBF8, #211A16)", + "--color-background-body": "light-dark(#FAEFE9, #180F08)", + "--color-background-card": "light-dark(#FFFBF8, #211A16)", + "--color-background-popover": "light-dark(#FFFBF8, #372F2A)", + "--color-background-muted": "light-dark(#211A160D, #211A1680)", + "--color-accent": "light-dark(#AC3600, #FFB684)", + "--color-accent-muted": "light-dark(color-mix(in srgb, var(--color-accent) 20%, transparent), color-mix(in srgb, var(--color-accent) 25%, transparent))", + "--color-neutral": "light-dark(#211A161A, #EBE0DA33)", + "--color-overlay": "light-dark(#211A1666, #211A1699)", + "--color-overlay-hover": "light-dark(#211A160D, #FFFFFF0D)", + "--color-overlay-pressed": "light-dark(#211A161A, #FFFFFF1A)", + "--color-text-primary": "light-dark(#211A16, #EBE0DA)", + "--color-text-secondary": "light-dark(#51443C, #B8A89F)", + "--color-text-disabled": "light-dark(#9D8E85, #6A5C53)", + "--color-text-accent": "var(--color-accent)", + "--color-on-dark": "#ffffff", + "--color-on-light": "#111111", + "--color-on-accent": "light-dark(#FFFFFF, #651000)", + "--color-on-success": "light-dark(#ffffff, #111111)", + "--color-on-error": "light-dark(#ffffff, #111111)", + "--color-on-warning": "#111111", + "--color-icon-accent": "var(--color-accent)", + "--color-icon-primary": "light-dark(#211A16, #EBE0DA)", + "--color-icon-secondary": "light-dark(#51443C, #B8A89F)", + "--color-icon-disabled": "light-dark(#9D8E85, #6A5C53)", + "--color-success": "light-dark(#007a63, #03a487)", + "--color-error": "light-dark(#c82333, #dc4446)", + "--color-warning": "light-dark(#9a5d00, #d89614)", + "--color-success-muted": "light-dark(#bce0bb, #90ca903D)", + "--color-error-muted": "light-dark(#ffc4be, #ff98903D)", + "--color-warning-muted": "light-dark(#fae19e, #e2b6233D)", + "--color-border": "light-dark(#211A161A, #F9EFE81A)", + "--color-border-emphasized": "light-dark(#9D8E85, #74655D)", + "--color-skeleton": "light-dark(#B8A89F, #51443C)", + "--color-shadow": "light-dark(#0000001A, #0000004D)", + "--color-tint-hover": "light-dark(black, white)", + "--color-background-red": "light-dark(#ffc4be, #5b2b28)", + "--color-border-red": "light-dark(#ffaea7, #fa6762)", + "--color-icon-red": "light-dark(#76000c, #ff9890)", + "--color-text-red": "light-dark(#76000c, #ffaea7)", + "--color-background-orange": "light-dark(#ffc7a1, #503424)", + "--color-border-orange": "light-dark(#ffc7a1, #df843f)", + "--color-icon-orange": "light-dark(#622700, #ff9e55)", + "--color-text-orange": "light-dark(#622700, #ffb37e)", + "--color-background-yellow": "light-dark(#fae19e, #453a1c)", + "--color-border-yellow": "light-dark(#e3c36c, #c29900)", + "--color-icon-yellow": "light-dark(#4b3900, #e2b623)", + "--color-text-yellow": "light-dark(#4b3900, #eec448)", + "--color-background-green": "light-dark(#bce0bb, #2b422b)", + "--color-border-green": "light-dark(#aad4a9, #6ab26b)", + "--color-icon-green": "light-dark(#00490b, #90ca90)", + "--color-text-green": "light-dark(#00490b, #90ca90)", + "--color-background-teal": "light-dark(#a9e2d6, #28413c)", + "--color-border-teal": "light-dark(#90d7c8, #4fb1a0)", + "--color-icon-teal": "light-dark(#00463d, #81c9bb)", + "--color-text-teal": "light-dark(#00463d, #90d7c8)", + "--color-background-cyan": "light-dark(#9ae2f4, #274046)", + "--color-border-cyan": "light-dark(#85d5e9, #49adc4)", + "--color-icon-cyan": "light-dark(#004351, #71c7dd)", + "--color-text-cyan": "light-dark(#004351, #85d5e9)", + "--color-background-blue": "light-dark(#b9d7ff, #253c5a)", + "--color-border-blue": "light-dark(#a1caff, #5aa0f8)", + "--color-icon-blue": "light-dark(#003978, #88bcff)", + "--color-text-blue": "light-dark(#003978, #a1caff)", + "--color-background-purple": "light-dark(#f7d5ff, #4a2f51)", + "--color-border-purple": "light-dark(#f3bfff, #d885eb)", + "--color-icon-purple": "light-dark(#5c0e6c, #e496f6)", + "--color-text-purple": "light-dark(#5c0e6c, #eaacf8)", + "--color-background-pink": "light-dark(#ffc0d7, #572b3d)", + "--color-border-pink": "light-dark(#ffc0d7, #fc78b1)", + "--color-icon-pink": "light-dark(#70003f, #fd92bd)", + "--color-text-pink": "light-dark(#70003f, #ffa9ca)", + "--color-background-gray": "light-dark(#e2e2e2, #303030)", + "--color-border-gray": "light-dark(#d4d4d4, #262626)", + "--color-icon-gray": "light-dark(#474747, #9e9e9e)", + "--color-text-gray": "light-dark(#1b1b1b, #d4d4d4)", + "--radius-none": "0px", + "--radius-inner": "0.375rem", + "--radius-element": "0.625rem", + "--radius-container": "0.75rem", + "--radius-page": "1.75rem", + "--radius-full": "9999px", + "--shadow-low": "0 2px 4px light-dark(oklch(0 0 0 / 5%), oklch(0 0 0 / 25%)), 0 4px 8px light-dark(oklch(0 0 0 / 10%), oklch(0 0 0 / 40%)), inset 0 0 0 1px light-dark(transparent, oklch(1 0 0 / 8%))", + "--shadow-med": "0 2px 4px light-dark(oklch(0 0 0 / 5%), oklch(0 0 0 / 35%)), 0 4px 12px light-dark(oklch(0 0 0 / 10%), oklch(0 0 0 / 50%)), inset 0 0 0 1px light-dark(transparent, oklch(1 0 0 / 12%))", + "--shadow-high": "0 4px 6px light-dark(oklch(0 0 0 / 10%), oklch(0 0 0 / 50%)), 0 12px 24px light-dark(oklch(0 0 0 / 15%), oklch(0 0 0 / 70%)), inset 0 0 0 1px light-dark(transparent, oklch(1 0 0 / 15%))", + "--shadow-inset-hover": "inset 0px 0px 0px 2px #0074e24D", + "--shadow-inset-selected": "inset 0px 0px 0px 2px #0074e280", + "--shadow-inset-success": "inset 0px 0px 0px 2px #2f7d334D", + "--shadow-inset-warning": "inset 0px 0px 0px 2px #f8d36a4D", + "--shadow-inset-error": "inset 0px 0px 0px 2px #de47454D", + "--color-background-inverted": "light-dark(#211A16, #FFFBF8)", + "--color-track": "light-dark(#B8A89F, #51443C)" + }, + localTokens: { + "--astryx-theme-neutral-color-status-fill-accent": "light-dark(#0074e2, #6d9cfe)", + "--astryx-theme-neutral-color-status-fill-success": "light-dark(#198100, #64af4c)", + "--astryx-theme-neutral-color-status-fill-warning": "#ffce2f", + "--astryx-theme-neutral-color-status-fill-error": "light-dark(#c9303a, #ff705d)", + "--astryx-theme-neutral-color-status-muted-accent": "light-dark(#b9d7ff, #88bcff3D)", + "--astryx-theme-neutral-color-on-tint-neutral": "light-dark(#fafafa4D, #0a0a0a4D)", + "--astryx-theme-neutral-color-on-tint-overlay-hover": "light-dark(#fafafa1A, #0a0a0a1A)", + "--astryx-theme-neutral-color-on-tint-overlay-pressed": "light-dark(#fafafa33, #0a0a0a33)", + "--astryx-theme-neutral-color-destructive-overlay-hover": "light-dark(#ff7f770D, #ee736c0D)", + "--astryx-theme-neutral-color-destructive-overlay-pressed": "light-dark(#ff7f771A, #ee736c1A)", + "--uic-color-info": "light-dark(#0066cc, #009bdd)" + }, + __localTokenOwners: { + "--astryx-theme-neutral-color-status-fill-accent": "neutral", + "--astryx-theme-neutral-color-status-fill-success": "neutral", + "--astryx-theme-neutral-color-status-fill-warning": "neutral", + "--astryx-theme-neutral-color-status-fill-error": "neutral", + "--astryx-theme-neutral-color-status-muted-accent": "neutral", + "--astryx-theme-neutral-color-on-tint-neutral": "neutral", + "--astryx-theme-neutral-color-on-tint-overlay-hover": "neutral", + "--astryx-theme-neutral-color-on-tint-overlay-pressed": "neutral", + "--astryx-theme-neutral-color-destructive-overlay-hover": "neutral", + "--astryx-theme-neutral-color-destructive-overlay-pressed": "neutral", + "--uic-color-info": "lablup" + }, + __localTokenLineage: ["neutral","lablup"], + components: { + "heading": { + "level:1": { + "fontFamily": "var(--font-family-heading)", + "fontSize": "var(--text-heading-1-size)", + "fontWeight": "var(--text-heading-1-weight)", + "lineHeight": "var(--text-heading-1-leading)" + }, + "level:2": { + "fontFamily": "var(--font-family-heading)", + "fontSize": "var(--text-heading-2-size)", + "fontWeight": "var(--text-heading-2-weight)", + "lineHeight": "var(--text-heading-2-leading)" + }, + "level:3": { + "fontFamily": "var(--font-family-heading)", + "fontSize": "var(--text-heading-3-size)", + "fontWeight": "var(--text-heading-3-weight)", + "lineHeight": "var(--text-heading-3-leading)" + }, + "level:4": { + "fontFamily": "var(--font-family-heading)", + "fontSize": "var(--text-heading-4-size)", + "fontWeight": "var(--text-heading-4-weight)", + "lineHeight": "var(--text-heading-4-leading)" + }, + "level:5": { + "fontFamily": "var(--font-family-heading)", + "fontSize": "var(--text-heading-5-size)", + "fontWeight": "var(--text-heading-5-weight)", + "lineHeight": "var(--text-heading-5-leading)" + }, + "level:6": { + "fontFamily": "var(--font-family-heading)", + "fontSize": "var(--text-heading-6-size)", + "fontWeight": "var(--text-heading-6-weight)", + "lineHeight": "var(--text-heading-6-leading)" + }, + "type:display-1": { + "fontFamily": "var(--font-family-heading)", + "fontSize": "var(--text-display-1-size)", + "lineHeight": "var(--text-display-1-leading)" + }, + "type:display-2": { + "fontFamily": "var(--font-family-heading)", + "fontSize": "var(--text-display-2-size)", + "lineHeight": "var(--text-display-2-leading)" + }, + "type:display-3": { + "fontFamily": "var(--font-family-heading)", + "fontSize": "var(--text-display-3-size)", + "lineHeight": "var(--text-display-3-leading)" + } + }, + "text": { + "type:body": { + "fontFamily": "var(--font-family-body)", + "fontSize": "var(--text-body-size)", + "lineHeight": "var(--text-body-leading)" + }, + "type:large": { + "fontFamily": "var(--font-family-body)", + "fontSize": "var(--text-large-size)", + "lineHeight": "var(--text-large-leading)" + }, + "type:label": { + "fontFamily": "var(--font-family-body)", + "fontSize": "var(--text-label-size)", + "lineHeight": "var(--text-label-leading)" + }, + "type:code": { + "fontFamily": "var(--font-family-code)", + "fontSize": "var(--text-code-size)", + "lineHeight": "var(--text-code-leading)" + }, + "type:supporting": { + "fontFamily": "var(--font-family-body)", + "fontSize": "var(--text-supporting-size)", + "lineHeight": "var(--text-supporting-leading)" + }, + "type:display-1": { + "fontFamily": "var(--font-family-heading)", + "fontSize": "var(--text-display-1-size)", + "lineHeight": "var(--text-display-1-leading)" + }, + "type:display-2": { + "fontFamily": "var(--font-family-heading)", + "fontSize": "var(--text-display-2-size)", + "lineHeight": "var(--text-display-2-leading)" + }, + "type:display-3": { + "fontFamily": "var(--font-family-heading)", + "fontSize": "var(--text-display-3-size)", + "lineHeight": "var(--text-display-3-leading)" + } + }, + "button": { + "variant:destructive": { + "backgroundColor": "var(--color-error-muted)", + "color": "var(--color-error)", + "--color-overlay-hover": "var(--astryx-theme-neutral-color-destructive-overlay-hover)", + "--color-overlay-pressed": "var(--astryx-theme-neutral-color-destructive-overlay-pressed)" + } + }, + "badge": { + "variant:info": { + "backgroundColor": "var(--astryx-theme-neutral-color-status-fill-accent)", + "color": "var(--color-on-accent)" + }, + "variant:neutral": { + "backgroundColor": "var(--color-background-gray)", + "color": "var(--color-text-gray)" + }, + "variant:success": { + "backgroundColor": "var(--astryx-theme-neutral-color-status-fill-success)", + "color": "var(--color-on-success)" + }, + "variant:warning": { + "backgroundColor": "var(--astryx-theme-neutral-color-status-fill-warning)", + "color": "var(--color-on-warning)" + }, + "variant:error": { + "backgroundColor": "var(--astryx-theme-neutral-color-status-fill-error)", + "color": "var(--color-on-error)" + }, + "variant:red": { + "backgroundColor": "var(--color-background-red)", + "color": "var(--color-text-red)" + }, + "variant:orange": { + "backgroundColor": "var(--color-background-orange)", + "color": "var(--color-text-orange)" + }, + "variant:yellow": { + "backgroundColor": "var(--color-background-yellow)", + "color": "var(--color-text-yellow)" + }, + "variant:green": { + "backgroundColor": "var(--color-background-green)", + "color": "var(--color-text-green)" + }, + "variant:teal": { + "backgroundColor": "var(--color-background-teal)", + "color": "var(--color-text-teal)" + }, + "variant:cyan": { + "backgroundColor": "var(--color-background-cyan)", + "color": "var(--color-text-cyan)" + }, + "variant:blue": { + "backgroundColor": "var(--color-background-blue)", + "color": "var(--color-text-blue)" + }, + "variant:purple": { + "backgroundColor": "var(--color-background-purple)", + "color": "var(--color-text-purple)" + }, + "variant:pink": { + "backgroundColor": "var(--color-background-pink)", + "color": "var(--color-text-pink)" + }, + "variant:gray": { + "backgroundColor": "var(--color-background-gray)", + "color": "var(--color-text-gray)" + } + }, + "status-dot": { + "variant:success": { + "backgroundColor": "var(--astryx-theme-neutral-color-status-fill-success)" + }, + "variant:warning": { + "backgroundColor": "var(--astryx-theme-neutral-color-status-fill-warning)" + }, + "variant:error": { + "backgroundColor": "var(--astryx-theme-neutral-color-status-fill-error)" + }, + "variant:accent": { + "backgroundColor": "var(--astryx-theme-neutral-color-status-fill-accent)" + } + }, + "avatar-status-dot": { + "variant:success": { + "backgroundColor": "var(--astryx-theme-neutral-color-status-fill-success)" + }, + "variant:error": { + "backgroundColor": "var(--astryx-theme-neutral-color-status-fill-error)" + } + }, + "segmented-control": { + "base": { + "padding": "var(--spacing-1)" + } + }, + "segmented-control-item": { + "size:sm": { + "height": "calc(var(--size-element-sm) - 8px)" + }, + "size:md": { + "height": "calc(var(--size-element-md) - 8px)" + }, + "size:lg": { + "height": "calc(var(--size-element-lg) - 8px)" + }, + "selected": { + "boxShadow": "none" + } + }, + "banner": { + "base": { + "--color-neutral": "var(--astryx-theme-neutral-color-on-tint-neutral)", + "--color-overlay-hover": "var(--astryx-theme-neutral-color-on-tint-overlay-hover)", + "--color-overlay-pressed": "var(--astryx-theme-neutral-color-on-tint-overlay-pressed)" + }, + "status:info": { + "--color-accent-muted": "var(--astryx-theme-neutral-color-status-muted-accent)", + "--color-text-primary": "var(--color-text-blue)", + "--color-text-secondary": "var(--color-text-blue)", + "--color-accent": "var(--color-text-blue)" + }, + "status:success": { + "--color-text-primary": "var(--color-text-green)", + "--color-text-secondary": "var(--color-text-green)", + "--color-success": "var(--color-text-green)" + }, + "status:warning": { + "--color-text-primary": "var(--color-text-yellow)", + "--color-text-secondary": "var(--color-text-yellow)", + "--color-warning": "var(--color-text-yellow)" + }, + "status:error": { + "--color-text-primary": "var(--color-text-red)", + "--color-text-secondary": "var(--color-text-red)", + "--color-error": "var(--color-text-red)" + } + }, + "step-indicator": { + "status:accent": { + "--color-accent": "var(--astryx-theme-neutral-color-status-fill-accent)" + }, + "status:success": { + "--color-success": "var(--astryx-theme-neutral-color-status-fill-success)" + }, + "status:warning": { + "--color-warning": "var(--astryx-theme-neutral-color-status-fill-warning)" + }, + "status:error": { + "--color-error": "var(--astryx-theme-neutral-color-status-fill-error)" + } + }, + "switch": { + "base": { + "--color-background-gray": "var(--color-border-emphasized)" + } + }, + "progress-bar": { + "base": { + "--color-background-muted": "var(--color-border-emphasized)" + }, + "variant:accent": { + "--color-accent": "var(--astryx-theme-neutral-color-status-fill-accent)" + }, + "variant:success": { + "--color-success": "var(--astryx-theme-neutral-color-status-fill-success)" + }, + "variant:warning": { + "--color-warning": "var(--astryx-theme-neutral-color-status-fill-warning)" + }, + "variant:error": { + "--color-error": "var(--astryx-theme-neutral-color-status-fill-error)" + } + }, + "card": { + "base": { + "padding": "var(--spacing-3)" + } + }, + "section": { + "base": { + "padding": "var(--spacing-3)" + } + } + }, + __onDark: { + "tokens": { + "color-scheme": "dark", + "--color-text-primary": "var(--color-on-dark)", + "--color-icon-primary": "var(--color-on-dark)", + "--color-accent": "var(--color-on-dark)" + } + }, + __onLight: { + "tokens": { + "color-scheme": "light", + "--color-text-primary": "var(--color-on-light)", + "--color-icon-primary": "var(--color-on-light)", + "--color-accent": "var(--color-on-light)" + } + }, + __adaptations: { + "widthBreakpoints": { + "sm": 640, + "md": 768, + "lg": 1024, + "xl": 1280, + "2xl": 1536 + }, + "rules": [] + }, + __axes: { + "typography": { + "scale": { + "base": 14, + "ratio": 1.2 + }, + "body": { + "family": "Figtree", + "fallbacks": "-apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif" + }, + "heading": { + "family": "Figtree", + "fallbacks": "-apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "weights": { + "3": "bold", + "4": "bold" + } + }, + "code": { + "family": "ui-monospace", + "fallbacks": "\"SF Mono\", Monaco, Consolas, \"Liberation Mono\", \"Courier New\", monospace" + } + }, + "color": { + "accent": [ + "#FF7A00", + "#DC6B03" + ] + }, + "motion": { + "fast": 125, + "medium": 300, + "slow": 700, + "ratio": 0.75 + } + }, + icons: neutralIconRegistry, +}; + +export { neutralIconRegistry }; diff --git a/src/theme/lablup/built/lablup.variants.d.ts b/src/theme/lablup/built/lablup.variants.d.ts new file mode 100644 index 0000000..cbc9047 --- /dev/null +++ b/src/theme/lablup/built/lablup.variants.d.ts @@ -0,0 +1,16 @@ +/** + * @generated by `astryx theme build` — do not edit manually. + * Source: src/theme/lablup/lablupTheme.ts + * Command: astryx theme build src/theme/lablup/lablupTheme.ts --out src/theme/lablup/built/theme.css + * CLI: @astryxdesign/cli@0.6.2 + * Core: @astryxdesign/core@0.6.2 + */ + +// Generated by astryx theme build +export {}; + +declare module '@astryxdesign/core/Badge' { + interface BadgeVariantMap { + 'gray': true; + } +} diff --git a/src/theme/lablup/built/theme.css b/src/theme/lablup/built/theme.css new file mode 100644 index 0000000..af17aa8 --- /dev/null +++ b/src/theme/lablup/built/theme.css @@ -0,0 +1,715 @@ +/* + * @generated by `astryx theme build` — do not edit manually. + * Source: src/theme/lablup/lablupTheme.ts + * Command: astryx theme build src/theme/lablup/lablupTheme.ts --out src/theme/lablup/built/theme.css + * CLI: @astryxdesign/cli@0.6.2 + * Core: @astryxdesign/core@0.6.2 + */ + +@layer reset { +@scope ([data-astryx-theme="lablup"]) to ([data-astryx-theme]) { + :where(h1, h2, h3, h4, h5, h6) { + font-family: var(--font-family-heading); + color: var(--color-text-primary); + } + + :where(h1) { + font-size: var(--text-heading-1-size); + font-weight: var(--text-heading-1-weight); + line-height: var(--text-heading-1-leading); + } + + :where(h2) { + font-size: var(--text-heading-2-size); + font-weight: var(--text-heading-2-weight); + line-height: var(--text-heading-2-leading); + } + + :where(h3) { + font-size: var(--text-heading-3-size); + font-weight: var(--text-heading-3-weight); + line-height: var(--text-heading-3-leading); + } + + :where(h4) { + font-size: var(--text-heading-4-size); + font-weight: var(--text-heading-4-weight); + line-height: var(--text-heading-4-leading); + } + + :where(h5) { + font-size: var(--text-heading-5-size); + font-weight: var(--text-heading-5-weight); + line-height: var(--text-heading-5-leading); + } + + :where(h6) { + font-size: var(--text-heading-6-size); + font-weight: var(--text-heading-6-weight); + line-height: var(--text-heading-6-leading); + } + + :where(p) { + font-family: var(--font-family-body); + font-size: var(--text-body-size); + font-weight: var(--text-body-weight); + line-height: var(--text-body-leading); + color: var(--color-text-primary); + } + + :where(small) { + font-size: var(--text-supporting-size); + font-weight: var(--text-supporting-weight); + line-height: var(--text-supporting-leading); + color: var(--color-text-secondary); + } + + :where(code, pre) { + font-family: var(--font-family-code); + font-size: var(--text-code-size); + line-height: var(--text-code-leading); + } + + :where(hr) { + border: none; + border-top: 1px solid var(--color-border); + } +} +} + +@layer astryx-base { +:root { + --color-data-categorical-blue: light-dark(#0171E3, #0171E3); + --color-data-categorical-orange: light-dark(#EB6E00, #EB6E00); + --color-data-categorical-purple: light-dark(#6B1EFD, #6B1EFD); + --color-data-categorical-green: light-dark(#0B991F, #0B991F); + --color-data-categorical-pink: light-dark(#F351C0, #F351C0); + --color-data-categorical-cyan: light-dark(#0171A4, #0171A4); + --color-data-categorical-red: light-dark(#F5394F, #F5394F); + --color-data-categorical-teal: light-dark(#08A3A3, #08A3A3); + --color-data-categorical-brown: light-dark(#965E03, #965E03); + --color-data-categorical-indigo: light-dark(#6F8AFF, #6F8AFF); + --color-data-neutral: light-dark(#8494A3, #8C939B); + --color-data-blue-5: light-dark(#02165E, #02165E); + --color-data-blue-4: light-dark(#004CBC, #004CBC); + --color-data-blue-3: light-dark(#2694FE, #2694FE); + --color-data-blue-2: light-dark(#78BEFF, #78BEFF); + --color-data-blue-1: light-dark(#DBECFF, #DBECFF); + --color-data-shamrock-5: light-dark(#0B603D, #0B603D); + --color-data-shamrock-4: light-dark(#138546, #138546); + --color-data-shamrock-3: light-dark(#24BB5E, #24BB5E); + --color-data-shamrock-2: light-dark(#8EF7AA, #8EF7AA); + --color-data-shamrock-1: light-dark(#D6FEE4, #D6FEE4); + --color-data-orange-5: light-dark(#A13F04, #A13F04); + --color-data-orange-4: light-dark(#D66100, #D66100); + --color-data-orange-3: light-dark(#FD9537, #FD9537); + --color-data-orange-2: light-dark(#FDB876, #FDB876); + --color-data-orange-1: light-dark(#FFE6CF, #FFE6CF); + --color-data-pink-5: light-dark(#8E1073, #8E1073); + --color-data-pink-4: light-dark(#D123A1, #D123A1); + --color-data-pink-3: light-dark(#F989D3, #F989D3); + --color-data-pink-2: light-dark(#FEADE3, #FEADE3); + --color-data-pink-1: light-dark(#FCE3F4, #FCE3F4); + --color-data-purple-5: light-dark(#3E0697, #3E0697); + --color-data-purple-4: light-dark(#6B1EFD, #6B1EFD); + --color-data-purple-3: light-dark(#9081FF, #9081FF); + --color-data-purple-2: light-dark(#B3B0FE, #B3B0FE); + --color-data-purple-1: light-dark(#E8E8FB, #E8E8FB); + --color-data-red-5: light-dark(#9D0519, #9D0519); + --color-data-red-4: light-dark(#D31130, #D31130); + --color-data-red-3: light-dark(#FB7D87, #FB7D87); + --color-data-red-2: light-dark(#FFB2B8, #FFB2B8); + --color-data-red-1: light-dark(#FEE4E6, #FEE4E6); + --color-data-teal-5: light-dark(#08767D, #08767D); + --color-data-teal-4: light-dark(#0C9293, #0C9293); + --color-data-teal-3: light-dark(#0DB7AF, #0DB7AF); + --color-data-teal-2: light-dark(#6CE6D8, #6CE6D8); + --color-data-teal-1: light-dark(#D7FCF8, #D7FCF8); + --color-data-yellow-5: light-dark(#8A5001, #8A5001); + --color-data-yellow-4: light-dark(#D69804, #D69804); + --color-data-yellow-3: light-dark(#FBCE03, #FBCE03); + --color-data-yellow-2: light-dark(#FCEC85, #FCEC85); + --color-data-yellow-1: light-dark(#FDF6BA, #FDF6BA); + --color-data-gray-5: light-dark(#25363F, #333338); + --color-data-gray-4: light-dark(#5D6C7B, #666A72); + --color-data-gray-3: light-dark(#AFB9C4, #B2B8BE); + --color-data-gray-2: light-dark(#CCD3DB, #D0D3D6); + --color-data-gray-1: light-dark(#F1F4F7, #F2F4F6); +} +} + +@layer astryx-theme { + :root { color-scheme: light dark; } + html[data-theme="light"] { color-scheme: light; } + html[data-theme="dark"] { color-scheme: dark; } + +@scope ([data-astryx-theme="lablup"]) to ([data-astryx-theme]) { + :scope { + --font-size-4xs: 0.375rem; + --font-size-3xs: 0.4375rem; + --font-size-2xs: 0.5rem; + --font-size-xs: 0.625rem; + --font-size-sm: 0.75rem; + --font-size-base: 0.875rem; + --font-size-lg: 1.0625rem; + --font-size-xl: 1.25rem; + --font-size-2xl: 1.5rem; + --font-size-3xl: 1.8125rem; + --font-size-4xl: 2.1875rem; + --font-size-5xl: 2.625rem; + --text-heading-1-size: var(--font-size-2xl); + --text-heading-1-weight: var(--font-weight-semibold); + --text-heading-1-leading: 1.3333; + --text-heading-2-size: var(--font-size-xl); + --text-heading-2-weight: var(--font-weight-semibold); + --text-heading-2-leading: 1.4; + --text-heading-3-size: var(--font-size-lg); + --text-heading-3-weight: var(--font-weight-bold); + --text-heading-3-leading: 1.4118; + --text-heading-4-size: var(--font-size-base); + --text-heading-4-weight: var(--font-weight-bold); + --text-heading-4-leading: 1.4286; + --text-heading-5-size: var(--font-size-sm); + --text-heading-5-weight: var(--font-weight-semibold); + --text-heading-5-leading: 1.6667; + --text-heading-6-size: var(--font-size-xs); + --text-heading-6-weight: var(--font-weight-semibold); + --text-heading-6-leading: 1.6; + --text-body-size: var(--font-size-base); + --text-body-weight: var(--font-weight-normal); + --text-body-leading: 1.4286; + --text-large-size: var(--font-size-lg); + --text-large-weight: var(--font-weight-semibold); + --text-large-leading: 1.4118; + --text-label-size: var(--font-size-base); + --text-label-weight: var(--font-weight-medium); + --text-label-leading: 1.4286; + --text-code-size: var(--font-size-base); + --text-code-weight: var(--font-weight-normal); + --text-code-leading: 1.4286; + --text-supporting-size: var(--font-size-sm); + --text-supporting-weight: var(--font-weight-normal); + --text-supporting-leading: 1.6667; + --text-display-1-size: var(--font-size-5xl); + --text-display-1-weight: var(--font-weight-normal); + --text-display-1-leading: 1.2381; + --text-display-2-size: var(--font-size-4xl); + --text-display-2-weight: var(--font-weight-normal); + --text-display-2-leading: 1.2571; + --text-display-3-size: var(--font-size-3xl); + --text-display-3-weight: var(--font-weight-normal); + --text-display-3-leading: 1.3793; + --duration-fast-min: 95ms; + --duration-fast: 125ms; + --duration-fast-max: 165ms; + --duration-medium-min: 225ms; + --duration-medium: 300ms; + --duration-medium-max: 400ms; + --duration-slow-min: 525ms; + --duration-slow: 700ms; + --duration-slow-max: 935ms; + --font-family-body: "Ubuntu Sans", "Pretendard Variable", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + --font-family-heading: "Ubuntu Sans", "Pretendard Variable", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + --font-family-code: ui-monospace, "SF Mono", Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + --color-syntax-keyword: light-dark(#6b187c, #efa8ff); + --color-syntax-string: light-dark(#0b5615, #a4d6a3); + --color-syntax-comment: light-dark(#6a6a6a, #9e9e9e); + --color-syntax-number: light-dark(#733100, #ffb37e); + --color-syntax-function: light-dark(#00458c, #a1caff); + --color-syntax-type: light-dark(#6b187c, #efa8ff); + --color-syntax-variable: light-dark(#111111, #e2e2e2); + --color-syntax-operator: light-dark(#6a6a6a, #9e9e9e); + --color-syntax-constant: light-dark(#733100, #ffb37e); + --color-syntax-tag: light-dark(#8a0011, #ffaea7); + --color-syntax-attribute: light-dark(#584400, #eec448); + --color-syntax-property: light-dark(#005348, #90d7c8); + --color-syntax-punctuation: light-dark(#6a6a6a, #9e9e9e); + --color-syntax-background: light-dark(#ffffff, #111111); + --color-background-surface: light-dark(#FFFBF8, #211A16); + --color-background-body: light-dark(#FAEFE9, #180F08); + --color-background-card: light-dark(#FFFBF8, #211A16); + --color-background-popover: light-dark(#FFFBF8, #372F2A); + --color-background-muted: light-dark(#211A160D, #211A1680); + --color-accent: light-dark(#AC3600, #FFB684); + --color-accent-muted: light-dark(color-mix(in srgb, var(--color-accent) 20%, transparent), color-mix(in srgb, var(--color-accent) 25%, transparent)); + --color-neutral: light-dark(#211A161A, #EBE0DA33); + --color-overlay: light-dark(#211A1666, #211A1699); + --color-overlay-hover: light-dark(#211A160D, #FFFFFF0D); + --color-overlay-pressed: light-dark(#211A161A, #FFFFFF1A); + --color-text-primary: light-dark(#211A16, #EBE0DA); + --color-text-secondary: light-dark(#51443C, #B8A89F); + --color-text-disabled: light-dark(#9D8E85, #6A5C53); + --color-text-accent: var(--color-accent); + --color-on-dark: #ffffff; + --color-on-light: #111111; + --color-on-accent: light-dark(#FFFFFF, #651000); + --color-on-success: light-dark(#ffffff, #111111); + --color-on-error: light-dark(#ffffff, #111111); + --color-on-warning: #111111; + --color-icon-accent: var(--color-accent); + --color-icon-primary: light-dark(#211A16, #EBE0DA); + --color-icon-secondary: light-dark(#51443C, #B8A89F); + --color-icon-disabled: light-dark(#9D8E85, #6A5C53); + --color-success: light-dark(#007a63, #03a487); + --color-error: light-dark(#c82333, #dc4446); + --color-warning: light-dark(#9a5d00, #d89614); + --color-success-muted: light-dark(#bce0bb, #90ca903D); + --color-error-muted: light-dark(#ffc4be, #ff98903D); + --color-warning-muted: light-dark(#fae19e, #e2b6233D); + --color-border: light-dark(#211A161A, #F9EFE81A); + --color-border-emphasized: light-dark(#9D8E85, #74655D); + --color-skeleton: light-dark(#B8A89F, #51443C); + --color-shadow: light-dark(#0000001A, #0000004D); + --color-tint-hover: light-dark(black, white); + --color-background-red: light-dark(#ffc4be, #5b2b28); + --color-border-red: light-dark(#ffaea7, #fa6762); + --color-icon-red: light-dark(#76000c, #ff9890); + --color-text-red: light-dark(#76000c, #ffaea7); + --color-background-orange: light-dark(#ffc7a1, #503424); + --color-border-orange: light-dark(#ffc7a1, #df843f); + --color-icon-orange: light-dark(#622700, #ff9e55); + --color-text-orange: light-dark(#622700, #ffb37e); + --color-background-yellow: light-dark(#fae19e, #453a1c); + --color-border-yellow: light-dark(#e3c36c, #c29900); + --color-icon-yellow: light-dark(#4b3900, #e2b623); + --color-text-yellow: light-dark(#4b3900, #eec448); + --color-background-green: light-dark(#bce0bb, #2b422b); + --color-border-green: light-dark(#aad4a9, #6ab26b); + --color-icon-green: light-dark(#00490b, #90ca90); + --color-text-green: light-dark(#00490b, #90ca90); + --color-background-teal: light-dark(#a9e2d6, #28413c); + --color-border-teal: light-dark(#90d7c8, #4fb1a0); + --color-icon-teal: light-dark(#00463d, #81c9bb); + --color-text-teal: light-dark(#00463d, #90d7c8); + --color-background-cyan: light-dark(#9ae2f4, #274046); + --color-border-cyan: light-dark(#85d5e9, #49adc4); + --color-icon-cyan: light-dark(#004351, #71c7dd); + --color-text-cyan: light-dark(#004351, #85d5e9); + --color-background-blue: light-dark(#b9d7ff, #253c5a); + --color-border-blue: light-dark(#a1caff, #5aa0f8); + --color-icon-blue: light-dark(#003978, #88bcff); + --color-text-blue: light-dark(#003978, #a1caff); + --color-background-purple: light-dark(#f7d5ff, #4a2f51); + --color-border-purple: light-dark(#f3bfff, #d885eb); + --color-icon-purple: light-dark(#5c0e6c, #e496f6); + --color-text-purple: light-dark(#5c0e6c, #eaacf8); + --color-background-pink: light-dark(#ffc0d7, #572b3d); + --color-border-pink: light-dark(#ffc0d7, #fc78b1); + --color-icon-pink: light-dark(#70003f, #fd92bd); + --color-text-pink: light-dark(#70003f, #ffa9ca); + --color-background-gray: light-dark(#e2e2e2, #303030); + --color-border-gray: light-dark(#d4d4d4, #262626); + --color-icon-gray: light-dark(#474747, #9e9e9e); + --color-text-gray: light-dark(#1b1b1b, #d4d4d4); + --radius-none: 0px; + --radius-inner: 0.375rem; + --radius-element: 0.625rem; + --radius-container: 0.75rem; + --radius-page: 1.75rem; + --radius-full: 9999px; + --shadow-low: 0 2px 4px light-dark(oklch(0 0 0 / 5%), oklch(0 0 0 / 25%)), 0 4px 8px light-dark(oklch(0 0 0 / 10%), oklch(0 0 0 / 40%)), inset 0 0 0 1px light-dark(transparent, oklch(1 0 0 / 8%)); + --shadow-med: 0 2px 4px light-dark(oklch(0 0 0 / 5%), oklch(0 0 0 / 35%)), 0 4px 12px light-dark(oklch(0 0 0 / 10%), oklch(0 0 0 / 50%)), inset 0 0 0 1px light-dark(transparent, oklch(1 0 0 / 12%)); + --shadow-high: 0 4px 6px light-dark(oklch(0 0 0 / 10%), oklch(0 0 0 / 50%)), 0 12px 24px light-dark(oklch(0 0 0 / 15%), oklch(0 0 0 / 70%)), inset 0 0 0 1px light-dark(transparent, oklch(1 0 0 / 15%)); + --shadow-inset-hover: inset 0px 0px 0px 2px #0074e24D; + --shadow-inset-selected: inset 0px 0px 0px 2px #0074e280; + --shadow-inset-success: inset 0px 0px 0px 2px #2f7d334D; + --shadow-inset-warning: inset 0px 0px 0px 2px #f8d36a4D; + --shadow-inset-error: inset 0px 0px 0px 2px #de47454D; + --color-background-inverted: light-dark(#211A16, #FFFBF8); + --color-track: light-dark(#B8A89F, #51443C); + --astryx-theme-neutral-color-status-fill-accent: light-dark(#0074e2, #6d9cfe); + --astryx-theme-neutral-color-status-fill-success: light-dark(#198100, #64af4c); + --astryx-theme-neutral-color-status-fill-warning: #ffce2f; + --astryx-theme-neutral-color-status-fill-error: light-dark(#c9303a, #ff705d); + --astryx-theme-neutral-color-status-muted-accent: light-dark(#b9d7ff, #88bcff3D); + --astryx-theme-neutral-color-on-tint-neutral: light-dark(#fafafa4D, #0a0a0a4D); + --astryx-theme-neutral-color-on-tint-overlay-hover: light-dark(#fafafa1A, #0a0a0a1A); + --astryx-theme-neutral-color-on-tint-overlay-pressed: light-dark(#fafafa33, #0a0a0a33); + --astryx-theme-neutral-color-destructive-overlay-hover: light-dark(#ff7f770D, #ee736c0D); + --astryx-theme-neutral-color-destructive-overlay-pressed: light-dark(#ff7f771A, #ee736c1A); + --uic-color-info: light-dark(#0066cc, #009bdd); + } + + .astryx-heading[data-level="1"] { + font-family: var(--font-family-heading); + font-size: var(--text-heading-1-size); + font-weight: var(--text-heading-1-weight); + line-height: var(--text-heading-1-leading); + } + + .astryx-heading[data-level="2"] { + font-family: var(--font-family-heading); + font-size: var(--text-heading-2-size); + font-weight: var(--text-heading-2-weight); + line-height: var(--text-heading-2-leading); + } + + .astryx-heading[data-level="3"] { + font-family: var(--font-family-heading); + font-size: var(--text-heading-3-size); + font-weight: var(--text-heading-3-weight); + line-height: var(--text-heading-3-leading); + } + + .astryx-heading[data-level="4"] { + font-family: var(--font-family-heading); + font-size: var(--text-heading-4-size); + font-weight: var(--text-heading-4-weight); + line-height: var(--text-heading-4-leading); + } + + .astryx-heading[data-level="5"] { + font-family: var(--font-family-heading); + font-size: var(--text-heading-5-size); + font-weight: var(--text-heading-5-weight); + line-height: var(--text-heading-5-leading); + } + + .astryx-heading[data-level="6"] { + font-family: var(--font-family-heading); + font-size: var(--text-heading-6-size); + font-weight: var(--text-heading-6-weight); + line-height: var(--text-heading-6-leading); + } + + .astryx-heading[data-type="display-1"] { + font-family: var(--font-family-heading); + font-size: var(--text-display-1-size); + line-height: var(--text-display-1-leading); + } + + .astryx-heading[data-type="display-2"] { + font-family: var(--font-family-heading); + font-size: var(--text-display-2-size); + line-height: var(--text-display-2-leading); + } + + .astryx-heading[data-type="display-3"] { + font-family: var(--font-family-heading); + font-size: var(--text-display-3-size); + line-height: var(--text-display-3-leading); + } + + .astryx-text[data-type="body"] { + font-family: var(--font-family-body); + font-size: var(--text-body-size); + line-height: var(--text-body-leading); + } + + .astryx-text[data-type="large"] { + font-family: var(--font-family-body); + font-size: var(--text-large-size); + line-height: var(--text-large-leading); + } + + .astryx-text[data-type="label"] { + font-family: var(--font-family-body); + font-size: var(--text-label-size); + line-height: var(--text-label-leading); + } + + .astryx-text[data-type="code"] { + font-family: var(--font-family-code); + font-size: var(--text-code-size); + line-height: var(--text-code-leading); + } + + .astryx-text[data-type="supporting"] { + font-family: var(--font-family-body); + font-size: var(--text-supporting-size); + line-height: var(--text-supporting-leading); + } + + .astryx-text[data-type="display-1"] { + font-family: var(--font-family-heading); + font-size: var(--text-display-1-size); + line-height: var(--text-display-1-leading); + } + + .astryx-text[data-type="display-2"] { + font-family: var(--font-family-heading); + font-size: var(--text-display-2-size); + line-height: var(--text-display-2-leading); + } + + .astryx-text[data-type="display-3"] { + font-family: var(--font-family-heading); + font-size: var(--text-display-3-size); + line-height: var(--text-display-3-leading); + } + + .astryx-button[data-variant="destructive"] { + background-color: var(--color-error-muted); + color: var(--color-error); + --color-overlay-hover: var(--astryx-theme-neutral-color-destructive-overlay-hover); + --color-overlay-pressed: var(--astryx-theme-neutral-color-destructive-overlay-pressed); + } + + .astryx-badge[data-variant="info"] { + background-color: var(--astryx-theme-neutral-color-status-fill-accent); + color: var(--color-on-accent); + } + + .astryx-badge[data-variant="neutral"] { + background-color: var(--color-background-gray); + color: var(--color-text-gray); + } + + .astryx-badge[data-variant="success"] { + background-color: var(--astryx-theme-neutral-color-status-fill-success); + color: var(--color-on-success); + } + + .astryx-badge[data-variant="warning"] { + background-color: var(--astryx-theme-neutral-color-status-fill-warning); + color: var(--color-on-warning); + } + + .astryx-badge[data-variant="error"] { + background-color: var(--astryx-theme-neutral-color-status-fill-error); + color: var(--color-on-error); + } + + .astryx-badge[data-variant="red"] { + background-color: var(--color-background-red); + color: var(--color-text-red); + } + + .astryx-badge[data-variant="orange"] { + background-color: var(--color-background-orange); + color: var(--color-text-orange); + } + + .astryx-badge[data-variant="yellow"] { + background-color: var(--color-background-yellow); + color: var(--color-text-yellow); + } + + .astryx-badge[data-variant="green"] { + background-color: var(--color-background-green); + color: var(--color-text-green); + } + + .astryx-badge[data-variant="teal"] { + background-color: var(--color-background-teal); + color: var(--color-text-teal); + } + + .astryx-badge[data-variant="cyan"] { + background-color: var(--color-background-cyan); + color: var(--color-text-cyan); + } + + .astryx-badge[data-variant="blue"] { + background-color: var(--color-background-blue); + color: var(--color-text-blue); + } + + .astryx-badge[data-variant="purple"] { + background-color: var(--color-background-purple); + color: var(--color-text-purple); + } + + .astryx-badge[data-variant="pink"] { + background-color: var(--color-background-pink); + color: var(--color-text-pink); + } + + .astryx-badge[data-variant="gray"] { + background-color: var(--color-background-gray); + color: var(--color-text-gray); + } + + .astryx-status-dot[data-variant="success"] { + background-color: var(--astryx-theme-neutral-color-status-fill-success); + } + + .astryx-status-dot[data-variant="warning"] { + background-color: var(--astryx-theme-neutral-color-status-fill-warning); + } + + .astryx-status-dot[data-variant="error"] { + background-color: var(--astryx-theme-neutral-color-status-fill-error); + } + + .astryx-status-dot[data-variant="accent"] { + background-color: var(--astryx-theme-neutral-color-status-fill-accent); + } + + .astryx-avatar-status-dot[data-variant="success"] { + background-color: var(--astryx-theme-neutral-color-status-fill-success); + } + + .astryx-avatar-status-dot[data-variant="error"] { + background-color: var(--astryx-theme-neutral-color-status-fill-error); + } + + .astryx-segmented-control { + padding: var(--spacing-1); + --_segmented-control-padding: var(--spacing-1); + } + + .astryx-segmented-control-item[data-size="sm"] { + height: calc(var(--size-element-sm) - 8px); + } + + .astryx-segmented-control-item[data-size="md"] { + height: calc(var(--size-element-md) - 8px); + } + + .astryx-segmented-control-item[data-size="lg"] { + height: calc(var(--size-element-lg) - 8px); + } + + .astryx-segmented-control-item[data-selected="selected"] { + box-shadow: none; + } + + .astryx-banner { + --color-neutral: var(--astryx-theme-neutral-color-on-tint-neutral); + --color-overlay-hover: var(--astryx-theme-neutral-color-on-tint-overlay-hover); + --color-overlay-pressed: var(--astryx-theme-neutral-color-on-tint-overlay-pressed); + } + + .astryx-banner[data-status="info"] { + --color-accent-muted: var(--astryx-theme-neutral-color-status-muted-accent); + --color-text-primary: var(--color-text-blue); + --color-text-secondary: var(--color-text-blue); + --color-accent: var(--color-text-blue); + } + + .astryx-banner[data-status="success"] { + --color-text-primary: var(--color-text-green); + --color-text-secondary: var(--color-text-green); + --color-success: var(--color-text-green); + } + + .astryx-banner[data-status="warning"] { + --color-text-primary: var(--color-text-yellow); + --color-text-secondary: var(--color-text-yellow); + --color-warning: var(--color-text-yellow); + } + + .astryx-banner[data-status="error"] { + --color-text-primary: var(--color-text-red); + --color-text-secondary: var(--color-text-red); + --color-error: var(--color-text-red); + } + + .astryx-step-indicator[data-status="accent"] { + --color-accent: var(--astryx-theme-neutral-color-status-fill-accent); + } + + .astryx-step-indicator[data-status="success"] { + --color-success: var(--astryx-theme-neutral-color-status-fill-success); + } + + .astryx-step-indicator[data-status="warning"] { + --color-warning: var(--astryx-theme-neutral-color-status-fill-warning); + } + + .astryx-step-indicator[data-status="error"] { + --color-error: var(--astryx-theme-neutral-color-status-fill-error); + } + + .astryx-switch { + --color-background-gray: var(--color-border-emphasized); + } + + .astryx-progress-bar { + --color-background-muted: var(--color-border-emphasized); + } + + .astryx-progress-bar[data-variant="accent"] { + --color-accent: var(--astryx-theme-neutral-color-status-fill-accent); + } + + .astryx-progress-bar[data-variant="success"] { + --color-success: var(--astryx-theme-neutral-color-status-fill-success); + } + + .astryx-progress-bar[data-variant="warning"] { + --color-warning: var(--astryx-theme-neutral-color-status-fill-warning); + } + + .astryx-progress-bar[data-variant="error"] { + --color-error: var(--astryx-theme-neutral-color-status-fill-error); + } + + .astryx-card { + --astryx-card-padding: var(--spacing-3); + } + + .astryx-section { + --astryx-section-padding: var(--spacing-3); + } + + .astryx-text[data-color="primary"] { color: var(--color-text-primary); } + + .astryx-heading[data-color="primary"] { color: var(--color-text-primary); } + + .astryx-text[data-color="secondary"] { color: var(--color-text-secondary); } + + .astryx-heading[data-color="secondary"] { color: var(--color-text-secondary); } + + .astryx-text[data-color="disabled"] { color: var(--color-text-disabled); } + + .astryx-heading[data-color="disabled"] { color: var(--color-text-disabled); } + + .astryx-text[data-color="placeholder"] { color: var(--color-text-secondary); } + + .astryx-heading[data-color="placeholder"] { color: var(--color-text-secondary); } + + .astryx-text[data-color="accent"] { color: var(--color-text-accent); } + + .astryx-heading[data-color="accent"] { color: var(--color-text-accent); } + + .astryx-text[data-size="4xs"] { font-size: var(--font-size-4xs); } + + .astryx-text[data-size="3xs"] { font-size: var(--font-size-3xs); } + + .astryx-text[data-size="2xs"] { font-size: var(--font-size-2xs); } + + .astryx-text[data-size="xsm"] { font-size: var(--font-size-xs); } + + .astryx-text[data-size="sm"] { font-size: var(--font-size-sm); } + + .astryx-text[data-size="base"] { font-size: var(--font-size-base); } + + .astryx-text[data-size="lg"] { font-size: var(--font-size-lg); } + + .astryx-text[data-size="xl"] { font-size: var(--font-size-xl); } + + .astryx-text[data-size="2xl"] { font-size: var(--font-size-2xl); } + + .astryx-text[data-size="3xl"] { font-size: var(--font-size-3xl); } + + .astryx-text[data-size="4xl"] { font-size: var(--font-size-4xl); } + + .astryx-heading[data-weight="normal"] { font-weight: var(--font-weight-normal); } + + .astryx-heading[data-weight="medium"] { font-weight: var(--font-weight-medium); } + + .astryx-heading[data-weight="semibold"] { font-weight: var(--font-weight-semibold); } + + .astryx-heading[data-weight="bold"] { font-weight: var(--font-weight-bold); } +} +} + +@layer astryx-theme { +@scope ([data-astryx-theme="lablup"]) to ([data-astryx-theme]) { + [data-astryx-media="dark"] { + color-scheme: dark; + --color-text-primary: var(--color-on-dark); + --color-icon-primary: var(--color-on-dark); + --color-accent: var(--color-on-dark); + } + + [data-astryx-media="light"] { + color-scheme: light; + --color-text-primary: var(--color-on-light); + --color-icon-primary: var(--color-on-light); + --color-accent: var(--color-on-light); + } +} +} diff --git a/src/theme/lablup/index.ts b/src/theme/lablup/index.ts new file mode 100644 index 0000000..584d597 --- /dev/null +++ b/src/theme/lablup/index.ts @@ -0,0 +1,9 @@ +/** + * `@lablup/ui-common/theme/lablup`: the Lablup brand theme as Astryx source. + * + * Pass it to `` for runtime style injection. For pre-built CSS, import + * `@lablup/ui-common/theme/lablup/built` together with + * `@lablup/ui-common/theme/lablup/theme.css` instead. Use one or the other, + * not both: they register the same theme name. + */ +export { LABLUP_ACCENT, LABLUP_INFO_TOKEN, lablupTheme } from "./lablupTheme"; diff --git a/src/theme/lablup/lablupTheme.test.ts b/src/theme/lablup/lablupTheme.test.ts new file mode 100644 index 0000000..4ba464d --- /dev/null +++ b/src/theme/lablup/lablupTheme.test.ts @@ -0,0 +1,70 @@ +/** + * Lablup brand theme guards. + * + * The brand values have one source: `lablupTheme.ts`. The built artifacts are + * compiled from it (`pnpm run theme:check` catches staleness), and the status + * hues were carried over from the 0.1 stylesheets. These tests pin the carry + * over, so a later edit to either side is a deliberate visual change. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +import { lablupTheme as builtTheme } from "./built/lablup.js"; +import { LABLUP_ACCENT, LABLUP_INFO_TOKEN, lablupTheme } from "./lablupTheme"; + +const STYLES = join(__dirname, "..", "..", "styles"); + +function tokenValue(file: string, name: string): string { + const css = readFileSync(join(STYLES, file), "utf8"); + const match = css.match(new RegExp(`--token-${name}:\\s*([^;]+);`)); + if (!match?.[1]) throw new Error(`${file} declares no --token-${name}`); + return match[1].trim().toLowerCase(); +} + +/** The `light-dark(a, b)` pair a built theme resolves a token to. */ +function pair(value: unknown): [string, string] { + const match = String(value).match(/^light-dark\(([^,]+),\s*([^)]+)\)$/); + if (!match?.[1] || !match[2]) + throw new Error(`not a light-dark pair: ${String(value)}`); + return [match[1].trim().toLowerCase(), match[2].trim().toLowerCase()]; +} + +describe("lablup theme", () => { + it("is named lablup and seeds the brand accent", () => { + expect(lablupTheme.name).toBe("lablup"); + expect(LABLUP_ACCENT).toEqual(["#FF7A00", "#DC6B03"]); + }); + + it("the built artifact is the same theme, pre-built", () => { + expect(builtTheme.name).toBe("lablup"); + expect((builtTheme as { __built?: boolean }).__built).toBe(true); + }); + + it.each([ + ["--color-error", "colorError"], + ["--color-success", "colorSuccess"], + ["--color-warning", "colorWarning"], + ])("%s carries the 0.1 light and dark %s", (token, legacy) => { + const tokens = builtTheme.tokens as Record; + expect(pair(tokens[token])).toEqual([ + tokenValue("base.css", legacy), + tokenValue("themes/orange-dark.css", legacy), + ]); + }); + + it("declares the info hue as a theme-local token", () => { + const local = (builtTheme as { localTokens?: Record }).localTokens; + expect(pair(local?.[LABLUP_INFO_TOKEN])).toEqual([ + tokenValue("base.css", "colorInfo"), + tokenValue("themes/orange-dark.css", "colorInfo"), + ]); + }); + + it("names the 0.1 font family without loading it", () => { + const tokens = builtTheme.tokens as Record; + expect(tokens["--font-family-body"]).toContain('"Ubuntu Sans"'); + const css = readFileSync(join(__dirname, "built", "theme.css"), "utf8"); + expect(css).not.toMatch(/@font-face/); + }); +}); diff --git a/src/theme/lablup/lablupTheme.ts b/src/theme/lablup/lablupTheme.ts new file mode 100644 index 0000000..6365ddb --- /dev/null +++ b/src/theme/lablup/lablupTheme.ts @@ -0,0 +1,50 @@ +/** + * The Lablup brand theme, as Astryx source. + * + * It extends Astryx's neutral theme and changes only what the brand owns: the + * orange accent, the four status hues, and the font family. Everything else + * keeps the neutral values. + * + * `astryx theme build` compiles this file into `built/` (see the + * `theme:build` script). Rebuild after any change here; `pnpm run + * theme:check` fails when the committed artifacts are stale. + * + * The status and font values come from the 0.1 stylesheets: light from + * `styles/base.css`, dark from `styles/themes/orange-dark.css`. + */ +import { defineTheme } from "@astryxdesign/core/theme"; +import { neutralIconRegistry, neutralTheme } from "@astryxdesign/theme-neutral"; + +/** The brand accent, light and dark. `--color-on-accent` derives from it. */ +export const LABLUP_ACCENT: [string, string] = ["#FF7A00", "#DC6B03"]; + +/** + * Astryx has no info token, so the info hue is a theme-local token. It is + * `uic-` prefixed so it cannot collide with a token Astryx adds later. + */ +export const LABLUP_INFO_TOKEN = "--uic-color-info"; + +export const lablupTheme = defineTheme({ + name: "lablup", + extends: neutralTheme, + // Named so `astryx theme build` emits the registry import. An inherited + // registry is dropped from the built module. + icons: neutralIconRegistry, + color: { accent: LABLUP_ACCENT }, + tokens: { + "--color-error": ["#c82333", "#dc4446"], + "--color-success": ["#007a63", "#03a487"], + "--color-warning": ["#9a5d00", "#d89614"], + // A token override, not `typography`: a partial `typography` block + // replaces the inherited type scale. Loading the font stays the app's job. + "--font-family-body": + '"Ubuntu Sans", "Pretendard Variable", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', + "--font-family-heading": + '"Ubuntu Sans", "Pretendard Variable", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', + }, + localTokens: { + [LABLUP_INFO_TOKEN]: ["#0066cc", "#009bdd"], + }, +}); + +export default lablupTheme; diff --git a/vite.config.ts b/vite.config.ts index e7a0855..b0bb15d 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -28,6 +28,8 @@ function entryPoints(): Record { // one-line re-export that stays a one-line re-export in dist, because // every @astryxdesign/* specifier is external. "src/astryx/**/*.ts", + // The Lablup brand theme, as source. Its pre-built form is copied below. + "src/theme/*/index.ts", ]; for (const file of globSync(patterns, { cwd: root, ignore: ["**/*.test.*"] })) { entries[file.replace(/^src\//, "").replace(/\.ts$/, "")] = resolve(root, file); @@ -51,6 +53,9 @@ const cssOnly = (source: string) => * `@lablup/ui-common/locales/.json`. JSON cannot re-export, so this * is the one mirror that is a copy. It is taken from the installed, pinned * core at build time, so it cannot drift from the JS. + * - `theme/lablup/built/`: the output of `astryx theme build`, committed and + * shipped as is (JS, declarations and `theme.css`). Its staleness gate is + * `pnpm run theme:check`. * * Stylesheets only where the source is a source directory. The copy used to * take the whole of `styles/`, so anything that ever landed beside the tokens @@ -60,6 +65,11 @@ function copyAssets(): Plugin { const copies: { from: string; to: string; filter: (source: string) => boolean }[] = [ { from: "src/styles", to: "dist/styles", filter: cssOnly }, { from: "src/astryx", to: "dist/astryx", filter: cssOnly }, + { + from: "src/theme/lablup/built", + to: "dist/theme/lablup/built", + filter: (source) => !/\.test\./.test(source), + }, { from: "node_modules/@astryxdesign/core/locales", to: "dist/locales", @@ -181,7 +191,10 @@ function linkComponentStyles(): Plugin { export default defineConfig({ plugins: [ react(), - dts({ include: ["src"], exclude: ["src/**/*.test.*", "src/test/**"] }), + dts({ + include: ["src"], + exclude: ["src/**/*.test.*", "src/test/**", "src/theme/*/built/**"], + }), copyAssets(), linkComponentStyles(), ], From 9dab9cbcdf3c25ec0c04d59519ded9f04301d113 Mon Sep 17 00:00:00 2001 From: Jongeun Date: Fri, 25 Sep 2026 15:31:32 +0000 Subject: [PATCH 04/87] feat: add ui-common.css and the deprecated legacy-tokens.css bridge legacy-tokens.css declares all 122 0.1 --token-* names at :root inside @layer ui-common, each as the matching Astryx var() where one exists and as the old base.css literal otherwise, so libraries that only read tokens keep resolving. It is removed in 0.3. ui-common.css is the package's global sheet in @layer ui-common; today it carries the scrollbar rules from base.css, rewritten against Astryx tokens. A test asserts the 122-name coverage, the layering, and that every var() names a real Astryx token. styles/base.css and styles/themes/*.css stay exported and are marked deprecated. --- package.json | 2 + scripts/gen-exports.mjs | 2 + src/globalStyles.test.ts | 108 ++++++++++++++++++ src/legacy-tokens.css | 176 +++++++++++++++++++++++++++++ src/styles/base.css | 4 + src/styles/themes/orange-dark.css | 3 + src/styles/themes/orange-light.css | 3 + src/ui-common.css | 48 ++++++++ vite.config.ts | 7 +- 9 files changed, 351 insertions(+), 2 deletions(-) create mode 100644 src/globalStyles.test.ts create mode 100644 src/legacy-tokens.css create mode 100644 src/ui-common.css diff --git a/package.json b/package.json index 1d558e0..5b509c8 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,8 @@ "import": "./dist/theme/lablup/built/lablup.js" }, "./theme/lablup/theme.css": "./dist/theme/lablup/built/theme.css", + "./ui-common.css": "./dist/ui-common.css", + "./legacy-tokens.css": "./dist/legacy-tokens.css", "./styles/base.css": "./dist/styles/base.css", "./styles/themes/*.css": "./dist/styles/themes/*.css", "./reset.css": "./dist/astryx/reset.css", diff --git a/scripts/gen-exports.mjs b/scripts/gen-exports.mjs index c0fa964..0832931 100644 --- a/scripts/gen-exports.mjs +++ b/scripts/gen-exports.mjs @@ -64,6 +64,8 @@ export const OWN_EXPORTS = { import: "./dist/theme/lablup/built/lablup.js", }, "./theme/lablup/theme.css": "./dist/theme/lablup/built/theme.css", + "./ui-common.css": "./dist/ui-common.css", + "./legacy-tokens.css": "./dist/legacy-tokens.css", "./styles/base.css": "./dist/styles/base.css", "./styles/themes/*.css": "./dist/styles/themes/*.css", }; diff --git a/src/globalStyles.test.ts b/src/globalStyles.test.ts new file mode 100644 index 0000000..308ed7f --- /dev/null +++ b/src/globalStyles.test.ts @@ -0,0 +1,108 @@ +/** + * Guards for the two package-level stylesheets. + * + * `legacy-tokens.css` is the deprecated bridge from the 0.1 `--token-*` + * contract to Astryx's tokens. Its whole job is that no old name stops + * resolving, so it has to declare every one of the 122 names `styles/base.css` + * declares, and each value has to point at something that exists. + * + * `ui-common.css` is ui-common's global sheet. It reads Astryx tokens only. + * + * Both keep every rule inside `@layer ui-common`, so a consumer's unlayered + * rules and its later layers still win. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +const SRC = __dirname; +const ROOT = join(SRC, ".."); + +function withoutComments(source: string): string { + return source.replace(/\/\*[\s\S]*?\*\//g, ""); +} + +const BASE_NAMES = new Set( + [ + ...readFileSync(join(SRC, "styles", "base.css"), "utf8").matchAll( + /(--token-[A-Za-z0-9]+)\s*:/g, + ), + ].map((m) => m[1] ?? ""), +); + +/** Every token Astryx declares, read from core's compiled token module. */ +const ASTRYX_TOKENS = new Set( + [ + ...readFileSync( + join(ROOT, "node_modules/@astryxdesign/core/dist/theme/tokens.stylex.js"), + "utf8", + ).matchAll(/"(--[a-z0-9-]+)":/g), + ].map((m) => m[1] ?? ""), +); + +/** Tokens a ui-common theme declares that Astryx does not. */ +const UI_COMMON_TOKENS = new Set(["--uic-color-info"]); + +/** The body of the single `@layer ui-common { ... }` block, or a failure. */ +function layerBody(source: string): string { + const css = withoutComments(source).trim(); + const match = css.match(/^@layer ui-common\s*\{([\s\S]*)\}$/); + if (!match?.[1]) throw new Error("expected the whole sheet inside @layer ui-common"); + return match[1]; +} + +function referencedVars(source: string): string[] { + return [...withoutComments(source).matchAll(/var\(\s*(--[A-Za-z0-9-]+)/g)].map( + (m) => m[1] ?? "", + ); +} + +describe("legacy-tokens.css", () => { + const source = readFileSync(join(SRC, "legacy-tokens.css"), "utf8"); + const body = layerBody(source); + const declared = new Set( + [...body.matchAll(/(--token-[A-Za-z0-9]+)\s*:/g)].map((m) => m[1] ?? ""), + ); + + it("the 0.1 contract it bridges has 122 names", () => { + expect(BASE_NAMES.size).toBe(122); + }); + + it("declares every 0.1 name, and nothing else", () => { + expect([...BASE_NAMES].filter((n) => !declared.has(n))).toEqual([]); + expect([...declared].filter((n) => !BASE_NAMES.has(n))).toEqual([]); + }); + + it("declares them at :root inside @layer ui-common", () => { + expect(body.trim()).toMatch(/^:root\s*\{[^{}]*\}$/); + }); + + it("every var() names an Astryx token or another legacy name", () => { + const unknown = referencedVars(source).filter( + (name) => + !ASTRYX_TOKENS.has(name) && !UI_COMMON_TOKENS.has(name) && !declared.has(name), + ); + expect(unknown).toEqual([]); + }); + + it("points most names at Astryx rather than restating literals", () => { + const mapped = [...body.matchAll(/--token-[A-Za-z0-9]+\s*:\s*([^;]+);/g)].filter( + (m) => /var\(--(?!token-)/.test(m[1] ?? ""), + ); + expect(mapped.length).toBeGreaterThan(60); + }); +}); + +describe("ui-common.css", () => { + const source = readFileSync(join(SRC, "ui-common.css"), "utf8"); + + it("keeps every rule inside @layer ui-common", () => { + expect(() => layerBody(source)).not.toThrow(); + }); + + it("reads Astryx tokens only", () => { + expect(referencedVars(source).filter((name) => !ASTRYX_TOKENS.has(name))).toEqual( + [], + ); + }); +}); diff --git a/src/legacy-tokens.css b/src/legacy-tokens.css new file mode 100644 index 0000000..0a36e82 --- /dev/null +++ b/src/legacy-tokens.css @@ -0,0 +1,176 @@ +/** + * @lablup/ui-common/legacy-tokens.css + * + * DEPRECATED. Removed in 0.3. + * + * The 0.1 contract was 122 `--token-*` custom properties. From 0.2 the + * contract is Astryx's own token set. This sheet keeps code that still reads + * a `--token-*` name resolving while it moves: every old name is declared + * here as the matching Astryx `var(--...)`, or as its old literal from + * `styles/base.css` where Astryx has no counterpart. + * + * Everything is inside `@layer ui-common`, so a product's own unlayered + * `--token-*` definitions still win. The values follow the active Astryx + * theme wherever they map to a token, and stay light-mode literals where they + * do not, as the 0.1 `base.css` did. + * + * `legacyTokens.test.ts` asserts that all 122 names are declared and that + * every `var()` here names a real Astryx token or another name in this file. + */ +@layer ui-common { + :root { + /* Radius. Astryx has no 4px rung (`--radius-none` is 0). */ + --token-borderRadius: var(--radius-inner); + --token-borderRadiusLG: var(--radius-element); + --token-borderRadiusSM: 0.25rem; + --token-borderRadiusXL: var(--radius-container); + + /* Control heights. Only the 32px rung has an Astryx size. */ + --token-controlHeightSM: var(--size-element-md); + --token-controlHeight: 2.5rem; + --token-controlHeightLG: 2.75rem; + + /* Elevation. */ + --token-boxShadow: var(--shadow-low); + --token-boxShadowSecondary: var(--shadow-med); + --token-boxShadowTertiary: var(--shadow-low); + --token-boxShadowDrawerRight: var(--shadow-high); + + /* Buttons. Fills and text map to the accent and status tokens; hover and + active fills that Astryx derives inside its own Button stay literal. */ + --token-buttonActiveTransform: scale(0.97); + --token-buttonBorderRadius: var(--radius-inner); + --token-buttonHoverTransform: translateY(-1px); + --token-buttonDangerBg: transparent; + --token-buttonDangerBgActive: var(--color-error-muted); + --token-buttonDangerBgHover: var(--color-error-muted); + --token-buttonDangerBorder: 1px solid var(--color-error); + --token-buttonDangerShadow: none; + --token-buttonDangerShadowHover: var(--token-buttonDangerShadow); + --token-buttonDangerText: var(--color-error); + --token-buttonGhostBackdrop: none; + --token-buttonGhostBg: transparent; + --token-buttonGhostBgActive: var(--color-overlay-pressed); + --token-buttonGhostBgHover: var(--color-overlay-hover); + --token-buttonGhostBorder: 1px solid transparent; + --token-buttonGhostBorderHover: 1px solid transparent; + --token-buttonGhostShadow: none; + --token-buttonGhostShadowHover: none; + --token-buttonPrimaryBackdrop: none; + --token-buttonPrimaryBg: var(--color-accent); + --token-buttonPrimaryBgActive: var(--token-colorPrimaryActive); + --token-buttonPrimaryBgHover: #e86e00; + --token-buttonPrimaryBorder: none; + --token-buttonPrimaryShadow: 0 2px 4px rgba(255, 122, 0, 0.2); + --token-buttonPrimaryShadowHover: 0 4px 8px rgba(255, 122, 0, 0.25); + --token-buttonSecondaryBackdrop: none; + --token-buttonSecondaryBg: transparent; + --token-buttonSecondaryBgActive: var(--color-overlay-pressed); + --token-buttonSecondaryBgHover: var(--color-overlay-hover); + --token-buttonSecondaryBorder: 1px solid var(--color-border-emphasized); + --token-buttonSecondaryBorderHover: 1px solid rgba(255, 122, 0, 0.3); + --token-buttonSecondaryShadow: none; + --token-buttonSecondaryShadowHover: + 0 2px 6px rgba(0, 0, 0, 0.06), inset 0 1px 0 rgba(255, 255, 255, 0.8); + --token-buttonSuccessBackdrop: none; + --token-buttonSuccessBg: var(--color-success); + --token-buttonSuccessBgActive: var(--token-colorSuccessActive); + --token-buttonSuccessBgHover: #006654; + --token-buttonSuccessBorder: none; + --token-buttonSuccessShadow: 0 2px 4px rgba(0, 189, 155, 0.2); + --token-buttonSuccessShadowHover: var(--token-buttonSuccessShadow); + + /* Surfaces. */ + --token-colorBgContainer: var(--color-background-surface); + --token-colorBgElevated: var(--color-background-popover); + --token-colorBgLayout: var(--color-background-body); + --token-colorBgMask: var(--color-overlay); + --token-colorBgTextActive: var(--color-overlay-pressed); + --token-colorBgTextHover: var(--color-overlay-hover); + --token-colorBorder: var(--color-border-emphasized); + --token-colorBorderSecondary: var(--color-border); + --token-colorFillQuaternary: #fafafa; + --token-colorFillSecondary: var(--color-background-muted); + --token-colorFillTertiary: #f9fafb; + + /* Accent and status. Astryx has no info token; the Lablup theme declares + one as `--uic-color-info`, and other themes fall back to the literal. */ + --token-colorError: var(--color-error); + --token-colorInfo: var(--uic-color-info, #0066cc); + --token-colorLink: var(--color-text-accent); + --token-colorLinkHover: #7c3aed; + --token-colorPrimary: var(--color-accent); + --token-colorPrimaryActive: #cc6200; + --token-colorPrimaryBg: var(--color-accent-muted); + --token-colorPrimaryBgHover: rgba(255, 122, 0, 0.16); + --token-colorPrimaryBorder: rgba(255, 122, 0, 0.2); + --token-colorSuccess: var(--color-success); + --token-colorSuccessActive: #005244; + --token-colorSuccessBg: var(--color-success-muted); + --token-colorSuccessBorder: rgba(0, 189, 155, 0.4); + --token-colorSuccessBorderHover: rgba(0, 189, 155, 0.6); + --token-colorSuccessBoxShadow: 0 4px 12px rgba(0, 189, 155, 0.15); + --token-colorWarning: var(--color-warning); + + /* Text. Astryx's text tiers stop at secondary and disabled. */ + --token-colorText: var(--color-text-primary); + --token-colorTextDisabled: var(--color-text-disabled); + --token-colorTextPlaceholder: #767676; + --token-colorTextQuaternary: #bfbfbf; + --token-colorTextSecondary: var(--color-text-secondary); + --token-colorTextTertiary: #737373; + + /* Focus ring. */ + --token-focusRingColor: var(--focus-outline-color); + --token-focusRingOffset: var(--focus-outline-offset); + --token-focusRingStyle: var(--focus-outline-style); + --token-focusRingWidth: var(--focus-outline-width); + + /* Type. */ + --token-fontFamily: var(--font-family-body); + --token-fontSize: var(--font-size-base); + --token-fontSizeLG: var(--font-size-lg); + --token-fontSizeSM: var(--font-size-sm); + --token-fontSizeXL: var(--font-size-xl); + --token-fontSizeXXL: var(--font-size-2xl); + --token-fontSizeXXS: var(--font-size-2xs); + --token-fontSizeHeading1: var(--text-heading-1-size); + --token-fontSizeHeading2: var(--text-heading-2-size); + --token-fontSizeHeading3: var(--text-heading-3-size); + --token-fontSizeHeading4: var(--text-heading-4-size); + --token-fontSizeDisplay4: 1.75rem; + + /* Spacing. The 0.1 ladder is a subset of Astryx's 4px ladder. */ + --token-marginXXS: var(--spacing-1); + --token-padding: var(--spacing-3); + --token-paddingLG: var(--spacing-6); + --token-paddingMD: var(--spacing-4); + --token-paddingSM: var(--spacing-2); + --token-paddingXL: var(--spacing-8); + --token-paddingXS: var(--spacing-1); + --token-paddingXXS: var(--spacing-0-5); + + /* Scrollbar. */ + --token-scrollbarSize: 0.5rem; + --token-scrollbarRadius: 0.25rem; + + /* Motion. Each maps to the nearest Astryx duration: 100ms, 200ms and + 300ms land on 95ms, 225ms and 300ms in the neutral scale. */ + --token-motionDurationFast: var(--duration-fast-min); + --token-motionDurationMid: var(--duration-medium-min); + --token-motionDurationSlow: var(--duration-medium); + --token-themeTransitionDuration: 0.25s; + --token-themeTransitionTiming: ease-in-out; + + /* Tabs. */ + --token-tabActiveBg: var(--color-accent-muted); + --token-tabFocusShadow: 0 0 0 3px rgba(255, 122, 0, 0.15); + --token-tabHoverBg: var(--token-colorFillSecondary); + + /* Stacking. Astryx layering is owned by its layer stack, not z-index. */ + --token-zIndexDrawer: 1100; + --token-zIndexDrawerContent: 1101; + --token-zIndexPopover: 1150; + --token-zIndexTooltip: 1300; + } +} diff --git a/src/styles/base.css b/src/styles/base.css index 2f57fca..a442539 100644 --- a/src/styles/base.css +++ b/src/styles/base.css @@ -1,6 +1,10 @@ /** * @lablup/ui-common design tokens * + * DEPRECATED since 0.2, removed in 0.3. The token contract is now Astryx's + * own token set, themed by `@lablup/ui-common/theme/lablup`. Code that still + * reads `--token-*` names can import `@lablup/ui-common/legacy-tokens.css`. + * * The 119 `--token-*` custom properties every component in this package * reads. This file is the token contract: renaming or removing an entry is * a breaking change, exactly like renaming a prop (see CONTRIBUTING.md). diff --git a/src/styles/themes/orange-dark.css b/src/styles/themes/orange-dark.css index ca407ab..e4eb606 100644 --- a/src/styles/themes/orange-dark.css +++ b/src/styles/themes/orange-dark.css @@ -1,6 +1,9 @@ /** * @lablup/ui-common theme tokens: orange-dark * + * DEPRECATED since 0.2, removed in 0.3. Use the Astryx theme + * `@lablup/ui-common/theme/lablup`, which carries light and dark. + * * Overrides for the subset of the 104-token contract (see * ../base.css) that this theme customizes. Import at most one * theme file alongside base.css; components never import a diff --git a/src/styles/themes/orange-light.css b/src/styles/themes/orange-light.css index 98fe826..0d18a2c 100644 --- a/src/styles/themes/orange-light.css +++ b/src/styles/themes/orange-light.css @@ -1,6 +1,9 @@ /** * @lablup/ui-common theme tokens: orange-light * + * DEPRECATED since 0.2, removed in 0.3. Use the Astryx theme + * `@lablup/ui-common/theme/lablup`, which carries light and dark. + * * Overrides for the subset of the 104-token contract (see * ../base.css) that this theme customizes. Import at most one * theme file alongside base.css; components never import a diff --git a/src/ui-common.css b/src/ui-common.css new file mode 100644 index 0000000..1be27bb --- /dev/null +++ b/src/ui-common.css @@ -0,0 +1,48 @@ +/** + * @lablup/ui-common/ui-common.css + * + * ui-common's global stylesheet, in `@layer ui-common`. Component styles do + * not live here: each component imports its own stylesheet. This file holds + * only rules that have no component to hang on. + * + * Declare the layer order once in your app, before any of these imports: + * + * @layer reset, theme, base, astryx-base, astryx-theme, ui-common, + * components, utilities; + * + * `ui-common` sits after Astryx's layers, so ui-common's composite styles beat + * Astryx's base and theme rules for the primitives they wrap, and before + * `components`, so your own component styles beat ui-common. + * + * Rules read Astryx tokens only. + */ +@layer ui-common { + /* + * Scrollbar. A scrollbar is painted on a box no component owns, so a themed + * bar needs a global rule. Carried over from the 0.1 `styles/base.css`. + * Firefox reads the two inherited properties on the root; WebKit and Blink + * read the pseudo-elements. + */ + :root { + scrollbar-width: thin; + scrollbar-color: var(--color-border-emphasized) transparent; + } + + ::-webkit-scrollbar { + width: 0.5rem; + height: 0.5rem; + } + + ::-webkit-scrollbar-track { + background: transparent; + } + + ::-webkit-scrollbar-thumb { + background-color: var(--color-border-emphasized); + border-radius: var(--radius-full); + } + + ::-webkit-scrollbar-thumb:hover { + background-color: var(--color-text-secondary); + } +} diff --git a/vite.config.ts b/vite.config.ts index b0bb15d..1a7496d 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -45,7 +45,8 @@ const cssOnly = (source: string) => /** * Files no module imports, so Rollup never sees them, copied verbatim. * - * - `styles/`: the deprecated 0.1 token sheets. + * - `ui-common.css`: the global sheet, in `@layer ui-common`. + * - `legacy-tokens.css` and `styles/`: the deprecated 0.1 token sheets. * - `astryx/**.css`: the generated one-line `@import` mirrors of the Astryx * stylesheets. They stay `@import`s so the consumer's bundler resolves the * Astryx sheet from this package's install location. @@ -63,6 +64,8 @@ const cssOnly = (source: string) => */ function copyAssets(): Plugin { const copies: { from: string; to: string; filter: (source: string) => boolean }[] = [ + { from: "src/ui-common.css", to: "dist/ui-common.css", filter: cssOnly }, + { from: "src/legacy-tokens.css", to: "dist/legacy-tokens.css", filter: cssOnly }, { from: "src/styles", to: "dist/styles", filter: cssOnly }, { from: "src/astryx", to: "dist/astryx", filter: cssOnly }, { @@ -84,7 +87,7 @@ function copyAssets(): Plugin { const source = resolve(root, from); if (!existsSync(source)) continue; const target = resolve(root, to); - await mkdir(target, { recursive: true }); + await mkdir(dirname(target), { recursive: true }); await cp(source, target, { recursive: true, dereference: true, filter }); } }, From fcf260bb2d40c6d3aaca7791bf7944071d3a548e Mon Sep 17 00:00:00 2001 From: Jongeun Date: Fri, 25 Sep 2026 15:37:14 +0000 Subject: [PATCH 05/87] feat: add the ui-common i18n catalog on Astryx's translator Custom components will resolve built-in strings through Astryx's InternationalizationProvider: an internal useUicTranslator() walks the provider's overrides and messages for the locale chain, like Astryx does, and falls back to the English defaultMessage in ui-common's catalog instead of the raw key. Keys are uic..; English lives in code and translations in src/i18n/locales/.json. Consumers get @lablup/ui-common/i18n-catalog (uiCommonMessages, mergeMessages, uiCommonCatalog) and @lablup/ui-common/ui-common-locales/.json, with en.json written from the code catalog at build time. Astryx's own catalogs stay a 1:1 mirror at ./locales/*.json. The catalog is empty until the first component moves its strings in. intl-messageformat becomes a direct dependency, on the same range core uses, for formatting the English fallback. --- package.json | 8 +- pnpm-lock.yaml | 3 + scripts/gen-exports.mjs | 5 + src/i18n/catalog.ts | 33 +++++++ src/i18n/index.ts | 21 +++++ src/i18n/messages.ts | 46 ++++++++++ src/i18n/useUicTranslator.test.tsx | 143 +++++++++++++++++++++++++++++ src/i18n/useUicTranslator.ts | 84 +++++++++++++++++ tsconfig.json | 3 +- vite.config.ts | 20 +++- 10 files changed, 362 insertions(+), 4 deletions(-) create mode 100644 src/i18n/catalog.ts create mode 100644 src/i18n/index.ts create mode 100644 src/i18n/messages.ts create mode 100644 src/i18n/useUicTranslator.test.tsx create mode 100644 src/i18n/useUicTranslator.ts diff --git a/package.json b/package.json index 5b509c8..6b3cbd8 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,11 @@ "import": "./dist/theme/lablup/built/lablup.js" }, "./theme/lablup/theme.css": "./dist/theme/lablup/built/theme.css", + "./i18n-catalog": { + "types": "./dist/i18n/index.d.ts", + "import": "./dist/i18n/index.js" + }, + "./ui-common-locales/*.json": "./dist/ui-common-locales/*.json", "./ui-common.css": "./dist/ui-common.css", "./legacy-tokens.css": "./dist/legacy-tokens.css", "./styles/base.css": "./dist/styles/base.css", @@ -565,7 +570,8 @@ "dependencies": { "@astryxdesign/cli": "0.6.2", "@astryxdesign/core": "0.6.2", - "@astryxdesign/theme-neutral": "0.6.2" + "@astryxdesign/theme-neutral": "0.6.2", + "intl-messageformat": "^11.2.9" }, "peerDependencies": { "@astryxdesign/lab": "0.6.2-canary.c9fb1ad", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 87224c8..4fe6252 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@astryxdesign/theme-neutral': specifier: 0.6.2 version: 0.6.2(@astryxdesign/core@0.6.2(@stylexjs/stylex@0.19.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8) + intl-messageformat: + specifier: ^11.2.9 + version: 11.2.15 devDependencies: '@astryxdesign/lab': specifier: 0.6.2-canary.c9fb1ad diff --git a/scripts/gen-exports.mjs b/scripts/gen-exports.mjs index 0832931..851aaf0 100644 --- a/scripts/gen-exports.mjs +++ b/scripts/gen-exports.mjs @@ -64,6 +64,11 @@ export const OWN_EXPORTS = { import: "./dist/theme/lablup/built/lablup.js", }, "./theme/lablup/theme.css": "./dist/theme/lablup/built/theme.css", + "./i18n-catalog": { + types: "./dist/i18n/index.d.ts", + import: "./dist/i18n/index.js", + }, + "./ui-common-locales/*.json": "./dist/ui-common-locales/*.json", "./ui-common.css": "./dist/ui-common.css", "./legacy-tokens.css": "./dist/legacy-tokens.css", "./styles/base.css": "./dist/styles/base.css", diff --git a/src/i18n/catalog.ts b/src/i18n/catalog.ts new file mode 100644 index 0000000..041dccb --- /dev/null +++ b/src/i18n/catalog.ts @@ -0,0 +1,33 @@ +/** + * ui-common's message catalog: every built-in string a custom component + * shows, with its English text. + * + * English lives in code, as `defaultMessage`. Translations live in + * `./locales/.json`, one file per Astryx locale name (`ko-KR.json`, + * `ja-JP.json`, ...), holding the same keys. The build writes `en.json` from + * this catalog and ships every file at + * `@lablup/ui-common/ui-common-locales/.json`. + * + * Adding strings for a component: + * + * // src/components/Modal/Modal.messages.ts + * export const modalMessages = defineMessages({ + * "uic.Modal.close": { defaultMessage: "Close", description: "..." }, + * }); + * + * then spread `modalMessages` into `uiCommonCatalog` below. Keep message + * files free of React and CSS imports: the build reads this catalog. + */ +import type { Catalog, MessageEntry } from "@astryxdesign/core/i18n"; + +/** A catalog key: `uic..`. */ +export type UicMessageKey = `uic.${string}.${string}`; + +/** Type-checks a component's messages. Keys must be `uic..`. */ +export function defineMessages>( + messages: T, +): T { + return messages; +} + +export const uiCommonCatalog: Catalog = {}; diff --git a/src/i18n/index.ts b/src/i18n/index.ts new file mode 100644 index 0000000..23253cd --- /dev/null +++ b/src/i18n/index.ts @@ -0,0 +1,21 @@ +/** + * `@lablup/ui-common/i18n-catalog`: ui-common's strings, for the consumer's + * Astryx `InternationalizationProvider`. + * + * Supply them once, at the provider. Astryx's own locale catalogs are + * mirrored at `@lablup/ui-common/locales/.json`; ui-common's are at + * `@lablup/ui-common/ui-common-locales/.json`, and all of them are + * in `uiCommonMessages` below. + * + * import { InternationalizationProvider } from "@lablup/ui-common/i18n"; + * import { mergeMessages, uiCommonMessages } from "@lablup/ui-common/i18n-catalog"; + * import astryxKo from "@lablup/ui-common/locales/ko-KR.json"; + * + * + */ +export { uiCommonCatalog } from "./catalog"; +export type { UicMessageKey } from "./catalog"; +export { mergeMessages, uiCommonMessages } from "./messages"; diff --git a/src/i18n/messages.ts b/src/i18n/messages.ts new file mode 100644 index 0000000..33ed93e --- /dev/null +++ b/src/i18n/messages.ts @@ -0,0 +1,46 @@ +/** + * ui-common's catalog for every locale it ships, as one `MessagesByLocale`. + * + * English is the code catalog. Every other locale is the matching + * `./locales/.json` file, picked up automatically: adding a + * translation needs no code change. + */ +import type { Catalog, MessagesByLocale } from "@astryxdesign/core/i18n"; + +import { uiCommonCatalog } from "./catalog"; + +const translations = import.meta.glob("./locales/*.json", { + eager: true, + import: "default", +}); + +function localeOf(path: string): string { + return path.replace(/^.*\//, "").replace(/\.json$/, ""); +} + +export const uiCommonMessages: MessagesByLocale = { + en: uiCommonCatalog, + ...Object.fromEntries( + Object.entries(translations).map(([path, catalog]) => [localeOf(path), catalog]), + ), +}; + +/** + * Merge several `MessagesByLocale` maps into one, locale by locale. A later + * map wins on the same key. Use it to hand Astryx's provider ui-common's + * catalog together with your own: + * + * + */ +export function mergeMessages(...sources: MessagesByLocale[]): MessagesByLocale { + const merged: MessagesByLocale = {}; + for (const source of sources) { + for (const [locale, catalog] of Object.entries(source)) { + merged[locale] = { ...merged[locale], ...catalog }; + } + } + return merged; +} diff --git a/src/i18n/useUicTranslator.test.tsx b/src/i18n/useUicTranslator.test.tsx new file mode 100644 index 0000000..7abf251 --- /dev/null +++ b/src/i18n/useUicTranslator.test.tsx @@ -0,0 +1,143 @@ +/** + * ui-common's strings resolve through Astryx's own provider (FR-4055), with + * ui-common's English as the fallback rather than the raw key. + */ +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { renderHook } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { describe, expect, it } from "vitest"; +import { + InternationalizationProvider, + type Catalog, + type MessagesByLocale, + type Overrides, +} from "@astryxdesign/core/i18n"; + +import { uiCommonCatalog } from "./catalog"; +import { mergeMessages, uiCommonMessages } from "./messages"; +import { localeChain, useUicTranslator } from "./useUicTranslator"; + +const CATALOG: Catalog = { + "uic.Test.close": { defaultMessage: "Close" }, + "uic.Test.count": { defaultMessage: "{count, plural, one {# item} other {# items}}" }, +}; + +function translate( + key: string, + values?: Record, + provider?: { locale: string; messages?: MessagesByLocale; overrides?: Overrides }, +): string { + const wrapper = provider + ? ({ children }: { children: ReactNode }) => ( + + {children} + + ) + : undefined; + const { result } = renderHook(() => useUicTranslator(CATALOG), { wrapper }); + return result.current(key, values); +} + +describe("useUicTranslator", () => { + it("falls back to the catalog's English with no provider", () => { + expect(translate("uic.Test.close")).toBe("Close"); + }); + + it("formats ICU values in the fallback", () => { + expect(translate("uic.Test.count", { count: 2 })).toBe("2 items"); + }); + + it("uses the consumer's messages for the active locale", () => { + expect( + translate("uic.Test.close", undefined, { + locale: "ko-KR", + messages: { "ko-KR": { "uic.Test.close": { defaultMessage: "닫기" } } }, + }), + ).toBe("닫기"); + }); + + it("walks to the parent locale, as Astryx does", () => { + expect( + translate("uic.Test.close", undefined, { + locale: "pt-BR", + messages: { pt: { "uic.Test.close": { defaultMessage: "Fechar" } } }, + }), + ).toBe("Fechar"); + }); + + it("lets an override win over messages", () => { + expect( + translate("uic.Test.close", undefined, { + locale: "ko-KR", + messages: { "ko-KR": { "uic.Test.close": { defaultMessage: "닫기" } } }, + overrides: { "ko-KR": { "uic.Test.close": "창 닫기" } }, + }), + ).toBe("창 닫기"); + }); + + it("falls back to English when the locale lacks the key", () => { + expect( + translate("uic.Test.close", undefined, { locale: "ja-JP", messages: {} }), + ).toBe("Close"); + }); + + it("returns the key when no catalog knows it", () => { + expect(translate("uic.Test.unknown")).toBe("uic.Test.unknown"); + }); + + it("builds the locale chain from most to least specific", () => { + expect(localeChain("zh-Hans-CN")).toEqual(["zh-Hans-CN", "zh-Hans", "zh"]); + }); +}); + +describe("ui-common catalog", () => { + const LOCALES_DIR = join(__dirname, "locales"); + const ASTRYX_LOCALES = new Set( + readdirSync(join(__dirname, "../../node_modules/@astryxdesign/core/locales")), + ); + const translationFiles = (() => { + try { + return readdirSync(LOCALES_DIR).filter((f) => f.endsWith(".json")); + } catch { + return []; + } + })(); + + it("keys are uic..", () => { + for (const key of Object.keys(uiCommonCatalog)) { + expect(key).toMatch(/^uic\.[A-Z][A-Za-z0-9]*\.[A-Za-z0-9]+$/); + } + }); + + it("translation files use Astryx locale file names and only known keys", () => { + for (const file of translationFiles) { + expect(ASTRYX_LOCALES.has(file), file).toBe(true); + expect(file, "English comes from the code catalog").not.toBe("en.json"); + const catalog = JSON.parse( + readFileSync(join(LOCALES_DIR, file), "utf8"), + ) as Catalog; + const unknown = Object.keys(catalog).filter((k) => !(k in uiCommonCatalog)); + expect(unknown, file).toEqual([]); + } + }); + + it("uiCommonMessages carries English plus every translation file", () => { + expect(uiCommonMessages.en).toBe(uiCommonCatalog); + expect(Object.keys(uiCommonMessages).sort()).toEqual( + ["en", ...translationFiles.map((f) => f.replace(/\.json$/, ""))].sort(), + ); + }); + + it("mergeMessages merges per locale, later sources winning", () => { + expect( + mergeMessages( + { en: { a: { defaultMessage: "A" }, b: { defaultMessage: "B" } } }, + { en: { b: { defaultMessage: "B2" } }, ko: { a: { defaultMessage: "가" } } }, + ), + ).toEqual({ + en: { a: { defaultMessage: "A" }, b: { defaultMessage: "B2" } }, + ko: { a: { defaultMessage: "가" } }, + }); + }); +}); diff --git a/src/i18n/useUicTranslator.ts b/src/i18n/useUicTranslator.ts new file mode 100644 index 0000000..bd1bbf1 --- /dev/null +++ b/src/i18n/useUicTranslator.ts @@ -0,0 +1,84 @@ +/** + * The translator ui-common's own components use for their built-in strings. + * Internal: not exported from the package. + * + * It resolves through Astryx's `InternationalizationProvider`, so a consumer + * supplies ui-common's translations the same way it supplies Astryx's: in the + * provider's `messages` and `overrides`. There is no ui-common context. + * + * Why not Astryx's `useTranslator()` alone: when a key is missing for the + * active locale, Astryx falls back to its own shipped English catalog, which + * has no `uic.*` keys, and returns the key itself. Here the fallback is the + * `defaultMessage` in ui-common's catalog, so a consumer that never passes + * ui-common's messages still gets English. + * + * Lookup order, matching Astryx's for its own keys: + * 1. `overrides` for the locale, then each parent locale (pt-BR, then pt) + * 2. `messages` for the locale, then each parent locale + * 3. the catalog's English `defaultMessage` + * 4. the key itself + * + * A component still lets an explicit string prop win: + * + * const t = useUicTranslator(); + * const label = closeLabel ?? t("uic.Modal.close"); + */ +import { useCallback, useContext } from "react"; +import IntlMessageFormat from "intl-messageformat"; +import { + InternationalizationContext, + type Catalog, + type InternationalizationContextValue, +} from "@astryxdesign/core/i18n"; + +import { uiCommonCatalog } from "./catalog"; + +export type UicTranslate = (key: string, values?: Record) => string; + +/** `pt-BR` → `["pt-BR", "pt"]`, the walk Astryx's resolver uses. */ +export function localeChain(locale: string): string[] { + let canonical: string; + try { + canonical = new Intl.Locale(locale).baseName; + } catch { + canonical = locale; + } + const parts = canonical.split("-"); + const chain: string[] = []; + for (let i = parts.length; i > 0; i--) chain.push(parts.slice(0, i).join("-")); + return chain; +} + +function providedByConsumer( + ctx: InternationalizationContextValue, + key: string, +): boolean { + for (const tag of localeChain(ctx.locale)) { + const override = ctx.overrides?.[tag]?.[key]; + if (override !== undefined && override !== null) return true; + } + for (const tag of localeChain(ctx.locale)) { + const entry = ctx.messages[tag]?.[key]; + if (entry?.defaultMessage !== undefined && entry.defaultMessage !== null) + return true; + } + return false; +} + +export function useUicTranslator(catalog: Catalog = uiCommonCatalog): UicTranslate { + const ctx = useContext(InternationalizationContext); + return useCallback( + (key, values) => { + if (providedByConsumer(ctx, key)) return ctx.translate(key, values); + const fallback = catalog[key]?.defaultMessage; + if (fallback === undefined) return key; + if (values === undefined) return fallback; + return String( + new IntlMessageFormat(fallback, ctx.locale).format( + values as Record, + ), + ); + }, + [ctx, catalog], + ); +} diff --git a/tsconfig.json b/tsconfig.json index 853a325..f6a9428 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,13 +17,14 @@ "declaration": true, "emitDeclarationOnly": false, "noEmit": true, + "allowImportingTsExtensions": true, "isolatedModules": true, "verbatimModuleSyntax": true, "skipLibCheck": true, "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "resolveJsonModule": true, - "types": ["vitest/globals", "@testing-library/jest-dom"] + "types": ["vitest/globals", "@testing-library/jest-dom", "vite/client"] }, "include": ["src", "vite.config.ts"], "exclude": ["dist", "node_modules", "fixture"] diff --git a/vite.config.ts b/vite.config.ts index 1a7496d..eabc1cf 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,4 +1,4 @@ -import { cp, mkdir } from "node:fs/promises"; +import { cp, mkdir, writeFile } from "node:fs/promises"; import { existsSync, statSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { dirname, isAbsolute, posix, relative, resolve, sep } from "node:path"; @@ -9,6 +9,8 @@ import { defineConfig } from "vitest/config"; import dts from "vite-plugin-dts"; import { globSync } from "tinyglobby"; +import { uiCommonCatalog } from "./src/i18n/catalog.ts"; + const root = dirname(fileURLToPath(import.meta.url)); /** @@ -30,6 +32,8 @@ function entryPoints(): Record { "src/astryx/**/*.ts", // The Lablup brand theme, as source. Its pre-built form is copied below. "src/theme/*/index.ts", + // `@lablup/ui-common/i18n-catalog`. + "src/i18n/index.ts", ]; for (const file of globSync(patterns, { cwd: root, ignore: ["**/*.test.*"] })) { entries[file.replace(/^src\//, "").replace(/\.ts$/, "")] = resolve(root, file); @@ -42,6 +46,9 @@ function entryPoints(): Record { const cssOnly = (source: string) => statSync(source).isDirectory() || source.endsWith(".css"); +const jsonOnly = (source: string) => + statSync(source).isDirectory() || source.endsWith(".json"); + /** * Files no module imports, so Rollup never sees them, copied verbatim. * @@ -54,6 +61,8 @@ const cssOnly = (source: string) => * `@lablup/ui-common/locales/.json`. JSON cannot re-export, so this * is the one mirror that is a copy. It is taken from the installed, pinned * core at build time, so it cannot drift from the JS. + * - `ui-common-locales/`: ui-common's own `uic.*` catalog, one JSON file per + * Astryx locale name. `en.json` is written from the code catalog. * - `theme/lablup/built/`: the output of `astryx theme build`, committed and * shipped as is (JS, declarations and `theme.css`). Its staleness gate is * `pnpm run theme:check`. @@ -76,8 +85,9 @@ function copyAssets(): Plugin { { from: "node_modules/@astryxdesign/core/locales", to: "dist/locales", - filter: (source) => statSync(source).isDirectory() || source.endsWith(".json"), + filter: jsonOnly, }, + { from: "src/i18n/locales", to: "dist/ui-common-locales", filter: jsonOnly }, ]; return { name: "ui-common-copy-assets", @@ -90,6 +100,12 @@ function copyAssets(): Plugin { await mkdir(dirname(target), { recursive: true }); await cp(source, target, { recursive: true, dereference: true, filter }); } + + // ui-common's English catalog lives in code; the translations beside it + // are JSON. Ship English as JSON too, so every locale has one file. + const english = resolve(root, "dist/ui-common-locales/en.json"); + await mkdir(dirname(english), { recursive: true }); + await writeFile(english, `${JSON.stringify(uiCommonCatalog, null, 2)}\n`); }, }; } From 07a008c5d76c089adc2da9c2e481bb0b575b91d8 Mon Sep 17 00:00:00 2001 From: Jongeun Date: Fri, 25 Sep 2026 15:42:34 +0000 Subject: [PATCH 06/87] feat: register ui-common as an Astryx CLI integration astryx.integration.mjs ships in the tarball with a ui-common reference topic (astryx docs ui-common) and four agentDocs lines, including "Use Modal, not Dialog". A consumer that depends on @lablup/ui-common picks it up without an astryx.config entry. There is no components root yet: the CLI requires each component doc to sit beside a same-stem .tsx, and the tarball ships no source. pnpm run check:integration runs astryx doctor integration validate and astryx integration pack --check; verify and CI run it, and CI also runs theme:check. A test keeps the agent-doc lines within the CLI's 8-line, 240-character limits. --- .github/workflows/ci.yml | 6 ++++ astryx.integration.d.mts | 5 ++++ astryx.integration.mjs | 30 +++++++++++++++++++ astryx/docs/ui-common.doc.mjs | 54 +++++++++++++++++++++++++++++++++++ package.json | 5 +++- src/astryxIntegration.test.ts | 23 +++++++++++++++ 6 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 astryx.integration.d.mts create mode 100644 astryx.integration.mjs create mode 100644 astryx/docs/ui-common.doc.mjs create mode 100644 src/astryxIntegration.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8284e8e..fe235a1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,6 +40,9 @@ jobs: - name: Public boundary run: pnpm run check:boundary + - name: Built Lablup theme is up to date + run: pnpm run theme:check + - name: Test run: pnpm run test @@ -49,6 +52,9 @@ jobs: - name: Packed artifact and export paths run: pnpm run check:pack + - name: Astryx CLI integration + run: pnpm run check:integration + external-install: # Building green is not the same as being installable. This consumes the # real tarball from a project that shares nothing with this repository, so diff --git a/astryx.integration.d.mts b/astryx.integration.d.mts new file mode 100644 index 0000000..1255b21 --- /dev/null +++ b/astryx.integration.d.mts @@ -0,0 +1,5 @@ +/** Types for the Astryx CLI integration manifest, so tests can import it. */ +import type { AstryxIntegration } from "@astryxdesign/cli/authoring"; + +declare const manifest: AstryxIntegration; +export default manifest; diff --git a/astryx.integration.mjs b/astryx.integration.mjs new file mode 100644 index 0000000..a05bc2f --- /dev/null +++ b/astryx.integration.mjs @@ -0,0 +1,30 @@ +/** + * What @lablup/ui-common contributes to the Astryx CLI (FR-4051). + * + * A consumer that lists @lablup/ui-common as a dependency gets this loaded + * implicitly (Astryx CLI 0.6.1+): `astryx docs ui-common` serves the topic + * below, `astryx search` finds it, and `astryx init --features agents` + * appends the `agentDocs` lines to the managed agent block. + * + * No `components` root yet. The CLI requires each `{Name}.doc.mjs` to sit + * beside a same-stem `{Name}.tsx`, and the tarball ships no source today. + * The component docs arrive with the rebuilt customs and that decision. + * + * Identity (name, version) comes from package.json. Each root is shipped in + * the tarball (see `files` in package.json). `pnpm run check:integration` + * validates the manifest and proves the packed package carries it. + * + * @type {import('@astryxdesign/cli/authoring').AstryxIntegration} + */ +export default { + docs: "./astryx/docs", + agentDocs: { + append: [ + "Import Astryx through @lablup/ui-common, never @astryxdesign/*: same subpaths (@lablup/ui-common/Button, /theme/tokens.stylex, /reset.css, /lab).", + "Use Modal, not Dialog. ui-common hides Astryx Dialog (exports.exclude.json) so every product has one dialog surface.", + "Theme with from @lablup/ui-common/theme/lablup. Declare @layer reset, theme, base, astryx-base, astryx-theme, ui-common, components, utilities; first.", + "ui-common strings resolve through Astryx InternationalizationProvider: pass uiCommonMessages from @lablup/ui-common/i18n-catalog in its messages.", + ], + }, + issuesUrl: "https://github.com/lablup/ui-common/issues", +}; diff --git a/astryx/docs/ui-common.doc.mjs b/astryx/docs/ui-common.doc.mjs new file mode 100644 index 0000000..9aa41d6 --- /dev/null +++ b/astryx/docs/ui-common.doc.mjs @@ -0,0 +1,54 @@ +/** + * `astryx docs ui-common`: how @lablup/ui-common relates to Astryx. + * + * @type {import('@astryxdesign/cli/authoring').ReferenceDoc} + */ +export const docs = { + type: "generic", + name: "ui-common", + title: "Lablup ui-common", + description: + "The Lablup layer over Astryx: the mirrored Astryx surface, the Lablup theme, and ui-common's own components.", + category: "guide", + sections: [ + { + title: "Overview", + content: [ + { + type: "prose", + text: "@lablup/ui-common re-exports Astryx 1:1 and adds the Lablup brand theme and a few components of its own. Products depend on @lablup/ui-common only. Every Astryx subpath exists under the same name: `@astryxdesign/core/Button` is `@lablup/ui-common/Button`, `@astryxdesign/lab` is `@lablup/ui-common/lab`.", + }, + { + type: "list", + style: "do", + items: [ + "Import Astryx components from @lablup/ui-common, at the root or at the same subpath Astryx uses.", + "Import StyleX tokens from `@lablup/ui-common/theme/tokens.stylex`. The `.stylex` suffix is what the StyleX compiler looks for.", + "Wrap the app in `` from `@lablup/ui-common/theme/lablup`.", + ], + }, + { + type: "list", + style: "dont", + items: [ + "Import from @astryxdesign/* directly. ui-common owns the Astryx version.", + "Use Dialog. ui-common hides it; use Modal.", + ], + }, + ], + }, + { + title: "Stylesheets and layers", + content: [ + { + type: "code", + lang: "css", + label: "App entry stylesheet", + code: '@layer reset, theme, base, astryx-base, astryx-theme, ui-common, components, utilities;\n\n@import "@lablup/ui-common/reset.css";\n@import "@lablup/ui-common/astryx.css";\n@import "@lablup/ui-common/theme/lablup/theme.css";\n@import "@lablup/ui-common/ui-common.css";', + }, + ], + }, + ], +}; + +export default docs; diff --git a/package.json b/package.json index 6b3cbd8..b913da7 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,8 @@ "packageManager": "pnpm@11.19.0", "files": [ "dist", + "astryx", + "astryx.integration.mjs", "NOTICE" ], "exports": { @@ -565,7 +567,8 @@ "theme:check": "astryx theme build -c src/theme/lablup/lablupTheme.ts -o src/theme/lablup/built/theme.css --icons-specifier @astryxdesign/theme-neutral", "check:pack": "node scripts/check-pack.mjs", "check:boundary": "node scripts/check-boundary.mjs", - "verify": "pnpm run typecheck && pnpm run lint && pnpm run format:check && pnpm run check:boundary && pnpm run theme:check && pnpm run test && pnpm run build && pnpm run check:pack" + "check:integration": "astryx doctor integration validate && astryx integration pack --check", + "verify": "pnpm run typecheck && pnpm run lint && pnpm run format:check && pnpm run check:boundary && pnpm run theme:check && pnpm run test && pnpm run build && pnpm run check:pack && pnpm run check:integration" }, "dependencies": { "@astryxdesign/cli": "0.6.2", diff --git a/src/astryxIntegration.test.ts b/src/astryxIntegration.test.ts new file mode 100644 index 0000000..e01da45 --- /dev/null +++ b/src/astryxIntegration.test.ts @@ -0,0 +1,23 @@ +/** + * Limits the Astryx CLI enforces on an integration's agent-doc lines, checked + * here because the CLI drops an over-long manifest with only a warning. + */ +import { describe, expect, it } from "vitest"; + +import manifest from "../astryx.integration.mjs"; + +const lines: readonly string[] = manifest.agentDocs?.append ?? []; + +describe("astryx.integration.mjs", () => { + it("stays within the CLI's agent-doc limits", () => { + expect(lines.length).toBeGreaterThan(0); + expect(lines.length).toBeLessThanOrEqual(8); + for (const line of lines) { + expect([...line].length, line).toBeLessThanOrEqual(240); + } + }); + + it("tells agents to use Modal, not Dialog", () => { + expect(lines.some((line) => line.includes("Modal, not Dialog"))).toBe(true); + }); +}); From a11bb4dded21fca0767900dbd428884760be6254 Mon Sep 17 00:00:00 2001 From: Jongeun Date: Fri, 25 Sep 2026 15:46:41 +0000 Subject: [PATCH 07/87] test: move the install fixture onto the Astryx surface The fixture declares the canonical layer order, imports reset.css, astryx.css, the Lablup theme.css and ui-common.css through ui-common, wraps the app in , and renders Astryx Button, Text and Card (root and mirrored subpath) beside the ui-common customs. It adds the @stylexjs/stylex peer. check-fixture-styles now checks PageHeader (root) instead of the legacy Button, whose name is Astryx's now, and asserts that each Astryx sheet reached the consumer bundle. Verified locally against the packed tarball with both npm and a strict pnpm layout, where the fixture has no @astryxdesign/* of its own. --- fixture/package.json | 1 + fixture/src/index.css | 12 ++++++ fixture/src/main.tsx | 66 +++++++++++++++++++------------- scripts/check-fixture-styles.mjs | 32 ++++++++++++++-- 4 files changed, 81 insertions(+), 30 deletions(-) create mode 100644 fixture/src/index.css diff --git a/fixture/package.json b/fixture/package.json index 8634356..72cc5a6 100644 --- a/fixture/package.json +++ b/fixture/package.json @@ -8,6 +8,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@stylexjs/stylex": "^0.19.0", "react": "^19.2.7", "react-dom": "^19.2.7" }, diff --git a/fixture/src/index.css b/fixture/src/index.css new file mode 100644 index 0000000..92ba54c --- /dev/null +++ b/fixture/src/index.css @@ -0,0 +1,12 @@ +/* + * The consumer's layer order, declared once and first. Everything below is + * imported through @lablup/ui-common; this project has no @astryxdesign/* + * dependency of its own, so each @import proves ui-common's one-line mirror + * resolves the Astryx sheet from ui-common's install location. + */ +@layer reset, theme, base, astryx-base, astryx-theme, ui-common, components, utilities; + +@import "@lablup/ui-common/reset.css"; +@import "@lablup/ui-common/astryx.css"; +@import "@lablup/ui-common/theme/lablup/theme.css"; +@import "@lablup/ui-common/ui-common.css"; diff --git a/fixture/src/main.tsx b/fixture/src/main.tsx index 4d11b8f..2d5b436 100644 --- a/fixture/src/main.tsx +++ b/fixture/src/main.tsx @@ -2,45 +2,57 @@ * Install fixture for @lablup/ui-common. * * A minimal external consumer: a clean React project that shares nothing - * with this repository, installs the packed tarball, and renders a handful - * of components imported from the package root plus one component subpath. + * with this repository, installs the packed tarball, and renders against it. + * It exercises each import shape a product uses: + * + * - Astryx components from the package root (`Button`, `Text`, `Theme`) + * - an Astryx component from its mirrored subpath (`@lablup/ui-common/Card`) + * - the pre-built Lablup theme and its stylesheet (see index.css) + * - ui-common customs from the root (`PageLayout`, `PageHeader`) + * - a legacy custom from its component subpath (`components/Drawer`) + * * If this fails to type-check or build, the package is not actually * installable, even when `pnpm run verify` is green. */ import { StrictMode, useState } from "react"; import { createRoot } from "react-dom/client"; -import { Badge, Button, PageHeader, PageLayout, StatusTag } from "@lablup/ui-common"; +import { Button, PageHeader, PageLayout, Text, Theme } from "@lablup/ui-common"; +import { Card } from "@lablup/ui-common/Card"; import { Drawer } from "@lablup/ui-common/components/Drawer"; -import "@lablup/ui-common/styles/base.css"; +import { lablupTheme } from "@lablup/ui-common/theme/lablup/built"; +import "./index.css"; function App() { const [isDrawerOpen, setIsDrawerOpen] = useState(false); return ( - - - -

- Installed{" "} - -

- - - - setIsDrawerOpen(false)} - title="Component subpath import" - > -

Imported from @lablup/ui-common/components/Drawer.

-
-
+ + + + + + Imported from @lablup/ui-common/Card. + + + + label={primaryAction.label} + onClick={() => primaryAction.onClick()} + className="uic-error-state__action uic-error-state__action--primary" + /> )} - {secondaryAction && ( + label={secondaryAction.label} + onClick={() => secondaryAction.onClick()} + className="uic-error-state__action uic-error-state__action--secondary" + /> )} )} diff --git a/src/components/PageHeader/PageHeader.css b/src/components/PageHeader/PageHeader.css index 9f1aa10..68c028d 100644 --- a/src/components/PageHeader/PageHeader.css +++ b/src/components/PageHeader/PageHeader.css @@ -1,149 +1,100 @@ -/** - * PageHeader Component Styles - * - * Standardized header styling with title, description, - * actions area, and error display. - */ - -.page-header { - margin-bottom: var(--token-paddingLG, 1.5rem); -} - -.page-header__content { - display: flex; - /* A wide action row wraps below the title instead of squeezing it. Without - this, a page that passes many actions (a search box plus several buttons) - leaves the text column a few characters wide and the description renders - as a one-word-per-line strip. */ - flex-wrap: wrap; - justify-content: space-between; - align-items: flex-start; - gap: var(--token-paddingMD, 1rem); -} - -.page-header__text { - /* Grows into the free space, but never shrinks below a readable width: the - content row wraps first. `min()` keeps it from overflowing a viewport - narrower than the floor itself. */ - flex: 1 1 20rem; - min-width: min(100%, 12rem); -} - -.page-header__title { - margin: 0 0 var(--token-paddingSM, 0.5rem); - font-size: var(--token-fontSizeDisplay4, 1.75rem); - font-weight: 300; - letter-spacing: 0.02em; - color: var(--token-colorText, #141414); -} - -.page-header__description { - margin: 0; - font-size: var(--token-fontSize, 0.875rem); - color: var(--token-colorTextSecondary, #595959); -} +/* PageHeader: title, description and actions, plus the error banner. */ +@layer ui-common { + .uic-page-header { + margin-bottom: var(--spacing-6); + } -.page-header__actions { - display: flex; - /* Individual actions wrap onto a second row rather than being squashed; - `margin-left: auto` keeps the block right-aligned both when it shares the - line with the text and when it has wrapped onto its own. */ - flex-wrap: wrap; - align-items: flex-start; - justify-content: flex-end; - gap: var(--token-paddingSM, 0.5rem); - flex: 0 1 auto; - min-width: 0; - margin-left: auto; -} + /* A wide action row wraps below the title instead of squeezing it to a + one-word-per-line strip. */ + .uic-page-header__content { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: flex-start; + gap: var(--spacing-4); + } -/* The block may shrink so it can wrap; the controls inside it may not, so a - page that passes its own action wrapper keeps the sizing it had when the - block was `flex-shrink: 0`. */ -.page-header__actions > * { - flex-shrink: 0; -} + /* Grows into the free space but never shrinks below a readable width: the + row wraps first. Literals: a readable measure has no token. */ + .uic-page-header__text { + flex: 1 1 20rem; + min-width: min(100%, 12rem); + } -/* Error display */ -.page-header__error { - display: flex; - justify-content: space-between; - align-items: center; - gap: var(--token-paddingSM, 0.5rem); - margin-top: var(--token-paddingSM, 0.5rem); - padding: var(--token-paddingSM, 0.5rem) var(--token-paddingMD, 1rem); - font-size: var(--token-fontSize, 0.875rem); - color: var(--token-colorError, #c82333); - background-color: rgba(255, 77, 79, 0.08); - border: 1px solid rgba(255, 77, 79, 0.3); - border-radius: var(--token-borderRadiusLG, 0.5rem); -} + .uic-page-header__title { + margin: 0 0 var(--spacing-2); + } -.page-header__error-text { - flex: 1; - word-break: break-word; -} + .uic-page-header__description { + margin: 0; + } -.page-header__error-dismiss { - background: none; - border: none; - font-size: var(--token-fontSizeXL, 1.25rem); - line-height: 1; - color: currentColor; - cursor: pointer; - padding: 0; - width: 24px; - height: 24px; - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - opacity: 0.7; - transition: opacity var(--token-motionDurationFast, 0.1s); -} + /* `margin-left: auto` keeps the block right-aligned both beside the text + and once it has wrapped onto its own row. */ + .uic-page-header__actions { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + justify-content: flex-end; + gap: var(--spacing-2); + flex: 0 1 auto; + min-width: 0; + margin-inline-start: auto; + } -.page-header__error-dismiss:hover { - opacity: 1; -} + /* The block may shrink so it can wrap; the controls inside it may not. */ + .uic-page-header__actions > * { + flex-shrink: 0; + } -/* Responsive adjustments */ -@media (max-width: 767px) { - .page-header__content { - flex-direction: column; - align-items: stretch; + .uic-page-header__error { + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--spacing-2); + margin-top: var(--spacing-2); + padding: var(--spacing-2) var(--spacing-4); + color: var(--color-error); + background-color: var(--color-error-muted); + border: var(--border-width) solid + color-mix(in srgb, var(--color-error) 30%, transparent); + border-radius: var(--radius-element); } - /* The flex shorthand above sets a main-size basis, which becomes a *height* - once the container switches to a column. Reset it here. */ - .page-header__text { - flex: 0 0 auto; + /* The message and its optional detail stack; the actions sit beside them. */ + .uic-page-header__error-body { + flex: 1; min-width: 0; + display: flex; + flex-direction: column; + gap: var(--spacing-0-5); } - .page-header__actions { - justify-content: flex-end; + .uic-page-header__error-text, + .uic-page-header__error-detail { + overflow-wrap: anywhere; } -} - -/* The message and its optional detail stack; the actions sit beside them. */ -.page-header__error-body { - flex: 1; - min-width: 0; - display: flex; - flex-direction: column; - gap: var(--token-paddingXXS, 0.125rem); -} -.page-header__error-detail { - font-size: var(--token-fontSizeSM, 0.75rem); - color: var(--token-colorTextTertiary, #737373); - word-break: break-word; -} + .uic-page-header__error-actions { + display: flex; + align-items: center; + gap: var(--spacing-2); + flex-shrink: 0; + padding-inline-start: var(--spacing-1); + } -.page-header__error-actions { - display: flex; - align-items: center; - gap: var(--token-paddingSM, 0.5rem); - flex-shrink: 0; - padding-left: var(--token-paddingXS, 0.25rem); + /* Literal: a media query cannot read a custom property. */ + @media (max-width: 767px) { + .uic-page-header__content { + flex-direction: column; + align-items: stretch; + } + + /* The flex shorthand above sets a main-size basis, which becomes a height + once the container is a column. */ + .uic-page-header__text { + flex: 0 0 auto; + min-width: 0; + } + } } diff --git a/src/components/PageHeader/PageHeader.messages.ts b/src/components/PageHeader/PageHeader.messages.ts new file mode 100644 index 0000000..e64170a --- /dev/null +++ b/src/components/PageHeader/PageHeader.messages.ts @@ -0,0 +1,13 @@ +import { defineMessages } from "../../i18n/catalog"; + +export const pageHeaderMessages = defineMessages({ + "uic.PageHeader.retry": { + defaultMessage: "Retry", + description: + "Button in the page header's error banner that retries the failed load", + }, + "uic.PageHeader.dismissError": { + defaultMessage: "Dismiss error", + description: "Accessible name of the icon button that closes the error banner", + }, +}); diff --git a/src/components/PageHeader/PageHeader.test.tsx b/src/components/PageHeader/PageHeader.test.tsx index 315896e..cbe555c 100644 --- a/src/components/PageHeader/PageHeader.test.tsx +++ b/src/components/PageHeader/PageHeader.test.tsx @@ -33,7 +33,7 @@ describe("PageHeader", () => { const { container } = render(); expect( - container.querySelector(".page-header__description"), + container.querySelector(".uic-page-header__description"), ).not.toBeInTheDocument(); }); @@ -48,7 +48,9 @@ describe("PageHeader", () => { it("does not render an actions wrapper when omitted", () => { const { container } = render(); - expect(container.querySelector(".page-header__actions")).not.toBeInTheDocument(); + expect( + container.querySelector(".uic-page-header__actions"), + ).not.toBeInTheDocument(); }); }); @@ -124,7 +126,7 @@ describe("PageHeader", () => { ); const header = container.querySelector("header"); - expect(header).toHaveClass("page-header"); + expect(header).toHaveClass("uic-page-header"); expect(header).toHaveClass("custom-header"); }); }); @@ -145,7 +147,7 @@ describe("error detail and retry", () => { it("renders no detail line when none is given", () => { const { container } = render(); - expect(container.querySelector(".page-header__error-detail")).toBeNull(); + expect(container.querySelector(".uic-page-header__error-detail")).toBeNull(); }); it("calls onRetry from the retry button, with a default label", () => { @@ -186,6 +188,6 @@ describe("error detail and retry", () => { it("renders no action row when neither callback is given", () => { const { container } = render(); - expect(container.querySelector(".page-header__error-actions")).toBeNull(); + expect(container.querySelector(".uic-page-header__error-actions")).toBeNull(); }); }); diff --git a/src/components/PageHeader/PageHeader.tsx b/src/components/PageHeader/PageHeader.tsx index 28be86c..5eee44a 100644 --- a/src/components/PageHeader/PageHeader.tsx +++ b/src/components/PageHeader/PageHeader.tsx @@ -1,51 +1,39 @@ /** - * PageHeader Component + * PageHeader * - * Standardized page header with title, description, and optional action buttons. - * Provides consistent styling and layout across all pages. + * A page's title, description and actions, with an optional error banner + * under them. Built on Astryx `Heading`, `Text`, `Button` and `IconButton`. * * @example - * // Basic usage - * + * * * @example - * // With action buttons * - * - * - * - * } + * actions={ + /> )} {onErrorDismiss && ( - + /> )} )} diff --git a/src/components/PageLayout/PageLayout.css b/src/components/PageLayout/PageLayout.css index 7e101c3..3274824 100644 --- a/src/components/PageLayout/PageLayout.css +++ b/src/components/PageLayout/PageLayout.css @@ -1,49 +1,34 @@ -/** - * PageLayout Component Styles - * - * Provides standardized page layout with consistent max-width, - * centering, and responsive behavior. - */ - -/* Base layout styles */ -.page-layout { - display: flex; - flex-direction: column; - min-height: 100%; - margin: 0 auto; - width: 100%; -} +/* PageLayout: a page's width clamp, centering and mobile gutter. */ +@layer ui-common { + .uic-page-layout { + display: flex; + flex-direction: column; + min-height: 100%; + margin: 0 auto; + width: 100%; + } -/* Standard layout: 900px max-width (default for most pages) */ -.page-layout--standard { - max-width: 900px; -} + /* Literals: Astryx has no page-width tokens. These are the 0.1 widths. */ + .uic-page-layout--standard { + max-width: 900px; + } -/* Wide layout: 1400px max-width (for dashboard and complex layouts) */ -.page-layout--wide { - max-width: 1400px; -} + .uic-page-layout--wide { + max-width: 1400px; + } -/* - * Full-width layout: no max-width clamp. - * - * Used by pages that own their own internal layout chrome (e.g. a left - * sidebar via SettingsLayout). The page can span the full viewport width - * without being centered or gaining a right-side gutter. - */ -.page-layout--full { - max-width: none; -} + /* No clamp: for pages that own their internal chrome (a settings sidebar) + and span the whole available width. */ + .uic-page-layout--full { + max-width: none; + } -/* Responsive adjustments - * - * The default mobile fallback adds horizontal padding so content doesn't - * touch the viewport edges. The `--full` variant opts out — pages using - * `variant="full"` apply their own internal padding (e.g. inside a sidebar - * or content panel), and adding outer padding here would double-pad them. - */ -@media (max-width: 767px) { - .page-layout:not(.page-layout--full) { - padding: 0 var(--token-paddingSM, 0.5rem); + /* The mobile gutter keeps content off the viewport edge. `full` opts out: + its page pads itself, and a second gutter would double it. + Literal: a media query cannot read a custom property. */ + @media (max-width: 767px) { + .uic-page-layout:not(.uic-page-layout--full) { + padding: 0 var(--spacing-2); + } } } diff --git a/src/components/PageLayout/PageLayout.test.tsx b/src/components/PageLayout/PageLayout.test.tsx index 8180c55..eae8b32 100644 --- a/src/components/PageLayout/PageLayout.test.tsx +++ b/src/components/PageLayout/PageLayout.test.tsx @@ -14,25 +14,25 @@ import { render, screen } from "@testing-library/react"; import { PageLayout } from "./PageLayout"; describe("PageLayout — variant classes", () => { - it("applies page-layout--standard by default", () => { + it("applies uic-page-layout--standard by default", () => { const { container } = render(content); const el = container.firstElementChild; - expect(el).toHaveClass("page-layout"); - expect(el).toHaveClass("page-layout--standard"); + expect(el).toHaveClass("uic-page-layout"); + expect(el).toHaveClass("uic-page-layout--standard"); }); - it("applies page-layout--wide for the wide variant", () => { + it("applies uic-page-layout--wide for the wide variant", () => { const { container } = render(content); const el = container.firstElementChild; - expect(el).toHaveClass("page-layout--wide"); - expect(el).not.toHaveClass("page-layout--standard"); + expect(el).toHaveClass("uic-page-layout--wide"); + expect(el).not.toHaveClass("uic-page-layout--standard"); }); - it("applies page-layout--full for the full variant", () => { + it("applies uic-page-layout--full for the full variant", () => { const { container } = render(content); const el = container.firstElementChild; - expect(el).toHaveClass("page-layout--full"); - expect(el).not.toHaveClass("page-layout--standard"); + expect(el).toHaveClass("uic-page-layout--full"); + expect(el).not.toHaveClass("uic-page-layout--standard"); }); it("passes additional className alongside the variant class", () => { @@ -40,7 +40,7 @@ describe("PageLayout — variant classes", () => { content, ); const el = container.firstElementChild; - expect(el).toHaveClass("page-layout--standard"); + expect(el).toHaveClass("uic-page-layout--standard"); expect(el).toHaveClass("my-custom-class"); }); }); diff --git a/src/components/PageLayout/PageLayout.tsx b/src/components/PageLayout/PageLayout.tsx index 513c884..576d5e1 100644 --- a/src/components/PageLayout/PageLayout.tsx +++ b/src/components/PageLayout/PageLayout.tsx @@ -49,7 +49,7 @@ export function PageLayout({ className = "", ...props }: PageLayoutProps) { - const classes = ["page-layout", `page-layout--${variant}`, className] + const classes = ["uic-page-layout", `uic-page-layout--${variant}`, className] .filter(Boolean) .join(" "); diff --git a/src/components/Skeleton/Skeleton.messages.ts b/src/components/Skeleton/Skeleton.messages.ts new file mode 100644 index 0000000..90c73cc --- /dev/null +++ b/src/components/Skeleton/Skeleton.messages.ts @@ -0,0 +1,20 @@ +import { defineMessages } from "../../i18n/catalog"; + +export const skeletonMessages = defineMessages({ + "uic.SkeletonCard.loading": { + defaultMessage: "Loading", + description: "Accessible name of a card-shaped loading placeholder", + }, + "uic.SkeletonChart.loading": { + defaultMessage: "Loading chart", + description: "Accessible name of a chart-shaped loading placeholder", + }, + "uic.SkeletonRow.loading": { + defaultMessage: "Loading", + description: "Accessible name of one list-row loading placeholder", + }, + "uic.SkeletonText.loading": { + defaultMessage: "Loading", + description: "Accessible name of a text-block loading placeholder", + }, +}); diff --git a/src/components/Skeleton/SkeletonCard.css b/src/components/Skeleton/SkeletonCard.css index a1faaae..12c5bab 100644 --- a/src/components/Skeleton/SkeletonCard.css +++ b/src/components/Skeleton/SkeletonCard.css @@ -1,77 +1,66 @@ -/** - * SkeletonCard Component Styles - * - * Card skeleton styles matching the BaseCard layout. - */ - -.skeleton-card { - display: flex; - flex-direction: column; - gap: var(--token-paddingSM, 0.5rem); - padding: var(--token-paddingMD, 1rem); - background-color: var(--token-colorFillTertiary, #f9fafb); - border: 1px solid transparent; - border-radius: var(--token-borderRadiusLG, 0.5rem); - transition: - border-color var(--token-motionDurationFast, 0.1s), - box-shadow var(--token-motionDurationMid, 0.2s); -} - -/* Header Section */ -.skeleton-card__header { - display: flex; - align-items: center; - gap: var(--token-paddingSM, 0.5rem); -} +/* SkeletonCard: the placeholder of a card, on Astryx's card surface tokens. */ +@layer ui-common { + .uic-skeleton-card { + display: flex; + flex-direction: column; + gap: var(--spacing-2); + padding: var(--spacing-4); + background-color: var(--color-background-card); + border: var(--border-width) solid var(--color-border); + border-radius: var(--radius-container); + } -.skeleton-card__header-text { - display: flex; - flex-direction: column; - gap: var(--token-paddingXS, 0.25rem); - flex: 1; -} + .uic-skeleton-card__header { + display: flex; + align-items: center; + gap: var(--spacing-2); + } -/* Content Section */ -.skeleton-card__content { - display: flex; - flex-direction: column; - gap: var(--token-paddingXS, 0.25rem); - padding: var(--token-paddingSM, 0.5rem) 0; -} + .uic-skeleton-card__header-text { + display: flex; + flex-direction: column; + gap: var(--spacing-1); + flex: 1; + } -/* Footer Section */ -.skeleton-card__footer { - display: flex; - gap: var(--token-paddingSM, 0.5rem); - padding-top: var(--token-paddingSM, 0.5rem); - border-top: 1px solid var(--token-colorBorderSecondary, #e5e7eb); -} + .uic-skeleton-card__content { + display: flex; + flex-direction: column; + gap: var(--spacing-1); + padding-block: var(--spacing-2); + } -/* Variant: Compact */ -.skeleton-card--compact { - padding: var(--token-paddingSM, 0.5rem); -} + .uic-skeleton-card__footer { + display: flex; + gap: var(--spacing-2); + padding-top: var(--spacing-2); + border-top: var(--border-width) solid var(--color-border); + } -.skeleton-card--compact .skeleton-card__content { - padding: var(--token-paddingXS, 0.25rem) 0; -} + .uic-skeleton-card--compact { + padding: var(--spacing-2); + } -/* Variant: Stat Card */ -.skeleton-card--stat { - padding: var(--token-paddingLG, 1.5rem); -} + .uic-skeleton-card--compact .uic-skeleton-card__content { + padding-block: var(--spacing-1); + } -.skeleton-card--stat .skeleton-card__header { - gap: var(--token-paddingMD, 1rem); -} + .uic-skeleton-card--stat { + padding: var(--spacing-6); + } -/* Mobile Adjustments */ -@media (max-width: 767px) { - .skeleton-card { - padding: var(--token-paddingSM, 0.5rem); + .uic-skeleton-card--stat .uic-skeleton-card__header { + gap: var(--spacing-4); } - .skeleton-card--stat { - padding: var(--token-paddingMD, 1rem); + /* Literal: a media query cannot read a custom property. */ + @media (max-width: 767px) { + .uic-skeleton-card { + padding: var(--spacing-2); + } + + .uic-skeleton-card--stat { + padding: var(--spacing-4); + } } } diff --git a/src/components/Skeleton/SkeletonCard.test.tsx b/src/components/Skeleton/SkeletonCard.test.tsx index 2ee39bb..c82dd34 100644 --- a/src/components/Skeleton/SkeletonCard.test.tsx +++ b/src/components/Skeleton/SkeletonCard.test.tsx @@ -19,29 +19,29 @@ describe("SkeletonCard", () => { it("should render with default variant", () => { const { container } = render(); - const card = container.querySelector(".skeleton-card"); + const card = container.querySelector(".uic-skeleton-card"); expect(card).toBeInTheDocument(); - expect(card).toHaveClass("skeleton-card--default"); + expect(card).toHaveClass("uic-skeleton-card--default"); }); it("should render header section for default variant", () => { const { container } = render(); - const header = container.querySelector(".skeleton-card__header"); + const header = container.querySelector(".uic-skeleton-card__header"); expect(header).toBeInTheDocument(); }); it("should render content section for default variant", () => { const { container } = render(); - const content = container.querySelector(".skeleton-card__content"); + const content = container.querySelector(".uic-skeleton-card__content"); expect(content).toBeInTheDocument(); }); it("should render footer section for default variant", () => { const { container } = render(); - const footer = container.querySelector(".skeleton-card__footer"); + const footer = container.querySelector(".uic-skeleton-card__footer"); expect(footer).toBeInTheDocument(); }); }); @@ -51,31 +51,37 @@ describe("SkeletonCard", () => { it("should have default class", () => { const { container } = render(); - const card = container.querySelector(".skeleton-card"); - expect(card).toHaveClass("skeleton-card--default"); + const card = container.querySelector(".uic-skeleton-card"); + expect(card).toHaveClass("uic-skeleton-card--default"); }); it("should have header, content, and footer", () => { const { container } = render(); - expect(container.querySelector(".skeleton-card__header")).toBeInTheDocument(); - expect(container.querySelector(".skeleton-card__content")).toBeInTheDocument(); - expect(container.querySelector(".skeleton-card__footer")).toBeInTheDocument(); + expect( + container.querySelector(".uic-skeleton-card__header"), + ).toBeInTheDocument(); + expect( + container.querySelector(".uic-skeleton-card__content"), + ).toBeInTheDocument(); + expect( + container.querySelector(".uic-skeleton-card__footer"), + ).toBeInTheDocument(); }); it("should have 3 skeleton lines in content", () => { const { container } = render(); - const content = container.querySelector(".skeleton-card__content"); - const skeletons = content?.querySelectorAll(".skeleton"); + const content = container.querySelector(".uic-skeleton-card__content"); + const skeletons = content?.querySelectorAll(".uic-skeleton-shape"); expect(skeletons).toHaveLength(3); }); it("should have 2 buttons in footer", () => { const { container } = render(); - const footer = container.querySelector(".skeleton-card__footer"); - const skeletons = footer?.querySelectorAll(".skeleton"); + const footer = container.querySelector(".uic-skeleton-card__footer"); + const skeletons = footer?.querySelectorAll(".uic-skeleton-shape"); expect(skeletons).toHaveLength(2); }); }); @@ -84,22 +90,26 @@ describe("SkeletonCard", () => { it("should have compact class", () => { const { container } = render(); - const card = container.querySelector(".skeleton-card"); - expect(card).toHaveClass("skeleton-card--compact"); + const card = container.querySelector(".uic-skeleton-card"); + expect(card).toHaveClass("uic-skeleton-card--compact"); }); it("should not have footer section", () => { const { container } = render(); - const footer = container.querySelector(".skeleton-card__footer"); + const footer = container.querySelector(".uic-skeleton-card__footer"); expect(footer).not.toBeInTheDocument(); }); it("should have header and content sections", () => { const { container } = render(); - expect(container.querySelector(".skeleton-card__header")).toBeInTheDocument(); - expect(container.querySelector(".skeleton-card__content")).toBeInTheDocument(); + expect( + container.querySelector(".uic-skeleton-card__header"), + ).toBeInTheDocument(); + expect( + container.querySelector(".uic-skeleton-card__content"), + ).toBeInTheDocument(); }); }); @@ -107,30 +117,30 @@ describe("SkeletonCard", () => { it("should have stat class", () => { const { container } = render(); - const card = container.querySelector(".skeleton-card"); - expect(card).toHaveClass("skeleton-card--stat"); + const card = container.querySelector(".uic-skeleton-card"); + expect(card).toHaveClass("uic-skeleton-card--stat"); }); it("should have circle avatar in header", () => { const { container } = render(); - const header = container.querySelector(".skeleton-card__header"); - const circle = header?.querySelector(".skeleton--circle"); + const header = container.querySelector(".uic-skeleton-card__header"); + const circle = header?.querySelector(".uic-skeleton-shape--circle"); expect(circle).toBeInTheDocument(); }); it("should have header text section", () => { const { container } = render(); - const headerText = container.querySelector(".skeleton-card__header-text"); + const headerText = container.querySelector(".uic-skeleton-card__header-text"); expect(headerText).toBeInTheDocument(); }); it("should have 2 text lines in header text", () => { const { container } = render(); - const headerText = container.querySelector(".skeleton-card__header-text"); - const skeletons = headerText?.querySelectorAll(".skeleton"); + const headerText = container.querySelector(".uic-skeleton-card__header-text"); + const skeletons = headerText?.querySelectorAll(".uic-skeleton-shape"); expect(skeletons).toHaveLength(2); }); @@ -138,28 +148,28 @@ describe("SkeletonCard", () => { const { container } = render(); expect( - container.querySelector(".skeleton-card__content"), + container.querySelector(".uic-skeleton-card__content"), ).not.toBeInTheDocument(); expect( - container.querySelector(".skeleton-card__footer"), + container.querySelector(".uic-skeleton-card__footer"), ).not.toBeInTheDocument(); }); }); }); describe("CSS Classes", () => { - it("should include base skeleton-card class", () => { + it("should include base uic-skeleton-card class", () => { const { container } = render(); - const card = container.querySelector(".skeleton-card"); - expect(card).toHaveClass("skeleton-card"); + const card = container.querySelector(".uic-skeleton-card"); + expect(card).toHaveClass("uic-skeleton-card"); }); it("should include custom className", () => { const { container } = render(); - const card = container.querySelector(".skeleton-card"); - expect(card).toHaveClass("skeleton-card", "custom-class"); + const card = container.querySelector(".uic-skeleton-card"); + expect(card).toHaveClass("uic-skeleton-card", "custom-class"); }); }); @@ -167,14 +177,14 @@ describe("SkeletonCard", () => { it("should have role status on card container", () => { const { container } = render(); - const card = container.querySelector(".skeleton-card"); + const card = container.querySelector(".uic-skeleton-card"); expect(card).toHaveAttribute("role", "status"); }); it("should have aria-busy set to true on card container", () => { const { container } = render(); - const card = container.querySelector(".skeleton-card"); + const card = container.querySelector(".uic-skeleton-card"); expect(card).toHaveAttribute("aria-busy", "true"); }); }); @@ -190,7 +200,7 @@ describe("SkeletonCard", () => { it("should not render data-testid when not provided", () => { const { container } = render(); - const card = container.querySelector(".skeleton-card"); + const card = container.querySelector(".uic-skeleton-card"); expect(card).not.toHaveAttribute("data-testid"); }); }); @@ -202,7 +212,11 @@ describe("SkeletonCard", () => { ); const card = screen.getByTestId("custom-card"); - expect(card).toHaveClass("skeleton-card", "skeleton-card--compact", "my-card"); + expect(card).toHaveClass( + "uic-skeleton-card", + "uic-skeleton-card--compact", + "my-card", + ); expect(card).toHaveAttribute("role", "status"); expect(card).toHaveAttribute("aria-busy", "true"); }); diff --git a/src/components/Skeleton/SkeletonCard.tsx b/src/components/Skeleton/SkeletonCard.tsx index dd8505c..0a30139 100644 --- a/src/components/Skeleton/SkeletonCard.tsx +++ b/src/components/Skeleton/SkeletonCard.tsx @@ -1,19 +1,19 @@ /** - * SkeletonCard Component + * SkeletonCard * - * Skeleton placeholder for card components (model cards, stat cards, etc.). - * Mimics the structure of a typical card with header, content, and footer areas. + * Loading placeholder shaped like a card: a header line, three body lines and + * two footer buttons. `compact` drops the footer; `stat` is an avatar beside + * two lines, the shape of a `StatCard`. * - * @example - * // Basic usage - * + * One live region: the container is `role="status"` with the name, and every + * shape inside is Astryx's decorative `Skeleton`. * * @example - * // Compact variant + * * */ - -import { Skeleton } from "./Skeleton"; +import { useUicTranslator } from "../../i18n/useUicTranslator"; +import { SkeletonShape } from "./SkeletonShape"; import "./SkeletonCard.css"; export interface SkeletonCardProps { @@ -23,25 +23,21 @@ export interface SkeletonCardProps { className?: string; /** Test ID for testing */ testId?: string; - /** Accessible name announced for the placeholder as a whole. Defaults to "Loading". */ + /** + * Accessible name announced for the placeholder as a whole. Defaults to the + * catalog's `uic.SkeletonCard.loading` ("Loading"). + */ loadingLabel?: string; } -/** - * SkeletonCard provides a loading placeholder for card components. - * - * Variants: - * - default: Full card with header, content, and footer - * - compact: Smaller card with reduced padding - * - stat: Statistics card layout - */ export function SkeletonCard({ variant = "default", className = "", testId, loadingLabel, }: SkeletonCardProps) { - const classNames = ["skeleton-card", `skeleton-card--${variant}`, className] + const t = useUicTranslator(); + const classNames = ["uic-skeleton-card", `uic-skeleton-card--${variant}`, className] .filter(Boolean) .join(" "); @@ -51,32 +47,30 @@ export function SkeletonCard({ data-testid={testId} role="status" aria-busy="true" - aria-label={loadingLabel ?? "Loading"} + aria-label={loadingLabel ?? t("uic.SkeletonCard.loading")} > {variant === "stat" ? ( - <> -
- -
- - -
+
+ +
+ +
- +
) : ( <> -
- +
+
-
- - - +
+ + +
{variant !== "compact" && ( -
- - +
+ +
)} diff --git a/src/components/Skeleton/SkeletonChart.css b/src/components/Skeleton/SkeletonChart.css index 7fe7cf7..1eb8953 100644 --- a/src/components/Skeleton/SkeletonChart.css +++ b/src/components/Skeleton/SkeletonChart.css @@ -1,78 +1,74 @@ -/** - * SkeletonChart Component Styles - * - * Chart skeleton styles for different chart types. - */ - -.skeleton-chart { - display: flex; - align-items: center; - justify-content: center; - padding: var(--token-paddingLG, 1.5rem); - background-color: var(--token-colorBgContainer, #ffffff); - border-radius: var(--token-borderRadiusLG, 0.5rem); - border: 1px solid var(--token-colorBorderSecondary, #e5e7eb); -} - -/* Bar Chart */ -.skeleton-chart__bars { - display: flex; - align-items: flex-end; - justify-content: space-between; - gap: var(--token-paddingSM, 0.5rem); - width: 100%; - height: 100%; -} - -.skeleton-chart__bar { - flex: 1; - min-width: 20px; -} - -/* Line/Area Chart */ -.skeleton-chart__line { - width: 100%; - height: 100%; -} +/* SkeletonChart: a chart's frame on Astryx's surface tokens. */ +@layer ui-common { + .uic-skeleton-chart { + display: flex; + align-items: center; + justify-content: center; + padding: var(--spacing-6); + background-color: var(--color-background-surface); + border: var(--border-width) solid var(--color-border); + border-radius: var(--radius-container); + } -/* Pie Chart */ -.skeleton-chart__pie { - display: flex; - align-items: center; - gap: var(--token-paddingLG, 1.5rem); - width: 100%; - height: 100%; -} + .uic-skeleton-chart__bars { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: var(--spacing-2); + width: 100%; + height: 100%; + } -.skeleton-chart__pie > div:first-child { - width: min(200px, 40%); - aspect-ratio: 1; -} + .uic-skeleton-chart__bar { + flex: 1; + min-width: var(--spacing-5); + } -.skeleton-chart__legend { - display: flex; - flex-direction: column; - gap: var(--token-paddingSM, 0.5rem); - flex: 1; -} + .uic-skeleton-chart__line { + width: 100%; + height: 100%; + } -.skeleton-chart__legend-item { - display: flex; - align-items: center; - gap: var(--token-paddingSM, 0.5rem); -} + .uic-skeleton-chart__pie { + display: flex; + align-items: center; + gap: var(--spacing-6); + width: 100%; + height: 100%; + } -/* Mobile Adjustments */ -@media (max-width: 767px) { - .skeleton-chart { - padding: var(--token-paddingMD, 1rem); + /* Literal: the disc's cap has no token; 12.5rem is the 0.1 size. */ + .uic-skeleton-chart__pie-disc { + width: min(12.5rem, 40%); + height: auto; + aspect-ratio: 1; } - .skeleton-chart__pie { + .uic-skeleton-chart__legend { + display: flex; flex-direction: column; + gap: var(--spacing-2); + flex: 1; + } + + .uic-skeleton-chart__legend-item { + display: flex; + align-items: center; + gap: var(--spacing-2); } - .skeleton-chart__pie > div:first-child { - width: 120px; + /* Literal: a media query cannot read a custom property. */ + @media (max-width: 767px) { + .uic-skeleton-chart { + padding: var(--spacing-4); + } + + .uic-skeleton-chart__pie { + flex-direction: column; + } + + .uic-skeleton-chart__pie-disc { + width: 7.5rem; + } } } diff --git a/src/components/Skeleton/SkeletonChart.test.tsx b/src/components/Skeleton/SkeletonChart.test.tsx index 343ad75..7f31ad5 100644 --- a/src/components/Skeleton/SkeletonChart.test.tsx +++ b/src/components/Skeleton/SkeletonChart.test.tsx @@ -20,21 +20,21 @@ describe("SkeletonChart", () => { it("should render with default props", () => { const { container } = render(); - const chart = container.querySelector(".skeleton-chart"); + const chart = container.querySelector(".uic-skeleton-chart"); expect(chart).toBeInTheDocument(); }); it("should render bar variant by default", () => { const { container } = render(); - const chart = container.querySelector(".skeleton-chart"); - expect(chart).toHaveClass("skeleton-chart--bar"); + const chart = container.querySelector(".uic-skeleton-chart"); + expect(chart).toHaveClass("uic-skeleton-chart--bar"); }); it("should render with default height of 300px", () => { const { container } = render(); - const chart = container.querySelector(".skeleton-chart"); + const chart = container.querySelector(".uic-skeleton-chart"); expect(chart).toHaveStyle({ height: "300px" }); }); }); @@ -44,30 +44,32 @@ describe("SkeletonChart", () => { it("should have bar class", () => { const { container } = render(); - const chart = container.querySelector(".skeleton-chart"); - expect(chart).toHaveClass("skeleton-chart--bar"); + const chart = container.querySelector(".uic-skeleton-chart"); + expect(chart).toHaveClass("uic-skeleton-chart--bar"); }); it("should render bars container", () => { const { container } = render(); - const barsContainer = container.querySelector(".skeleton-chart__bars"); + const barsContainer = container.querySelector(".uic-skeleton-chart__bars"); expect(barsContainer).toBeInTheDocument(); }); it("should render 8 bars with varying heights", () => { const { container } = render(); - const bars = container.querySelectorAll(".skeleton-chart__bar"); + const bars = container.querySelectorAll(".uic-skeleton-chart__bar"); expect(bars).toHaveLength(8); }); it("should not render pie or line containers", () => { const { container } = render(); - expect(container.querySelector(".skeleton-chart__pie")).not.toBeInTheDocument(); expect( - container.querySelector(".skeleton-chart__line"), + container.querySelector(".uic-skeleton-chart__pie"), + ).not.toBeInTheDocument(); + expect( + container.querySelector(".uic-skeleton-chart__line"), ).not.toBeInTheDocument(); }); }); @@ -76,22 +78,22 @@ describe("SkeletonChart", () => { it("should have line class", () => { const { container } = render(); - const chart = container.querySelector(".skeleton-chart"); - expect(chart).toHaveClass("skeleton-chart--line"); + const chart = container.querySelector(".uic-skeleton-chart"); + expect(chart).toHaveClass("uic-skeleton-chart--line"); }); it("should render line container", () => { const { container } = render(); - const lineContainer = container.querySelector(".skeleton-chart__line"); + const lineContainer = container.querySelector(".uic-skeleton-chart__line"); expect(lineContainer).toBeInTheDocument(); }); it("should render a skeleton inside line container", () => { const { container } = render(); - const lineContainer = container.querySelector(".skeleton-chart__line"); - const skeleton = lineContainer?.querySelector(".skeleton"); + const lineContainer = container.querySelector(".uic-skeleton-chart__line"); + const skeleton = lineContainer?.querySelector(".uic-skeleton-shape"); expect(skeleton).toBeInTheDocument(); }); }); @@ -100,47 +102,51 @@ describe("SkeletonChart", () => { it("should have pie class", () => { const { container } = render(); - const chart = container.querySelector(".skeleton-chart"); - expect(chart).toHaveClass("skeleton-chart--pie"); + const chart = container.querySelector(".uic-skeleton-chart"); + expect(chart).toHaveClass("uic-skeleton-chart--pie"); }); it("should render pie container", () => { const { container } = render(); - const pieContainer = container.querySelector(".skeleton-chart__pie"); + const pieContainer = container.querySelector(".uic-skeleton-chart__pie"); expect(pieContainer).toBeInTheDocument(); }); it("should render circle skeleton for pie", () => { const { container } = render(); - const pieContainer = container.querySelector(".skeleton-chart__pie"); - const circle = pieContainer?.querySelector(".skeleton--circle"); + const pieContainer = container.querySelector(".uic-skeleton-chart__pie"); + const circle = pieContainer?.querySelector(".uic-skeleton-shape--circle"); expect(circle).toBeInTheDocument(); }); it("should render legend section", () => { const { container } = render(); - const legend = container.querySelector(".skeleton-chart__legend"); + const legend = container.querySelector(".uic-skeleton-chart__legend"); expect(legend).toBeInTheDocument(); }); it("should render 4 legend items", () => { const { container } = render(); - const legendItems = container.querySelectorAll(".skeleton-chart__legend-item"); + const legendItems = container.querySelectorAll( + ".uic-skeleton-chart__legend-item", + ); expect(legendItems).toHaveLength(4); }); it("should render circle and text skeleton in each legend item", () => { const { container } = render(); - const legendItems = container.querySelectorAll(".skeleton-chart__legend-item"); + const legendItems = container.querySelectorAll( + ".uic-skeleton-chart__legend-item", + ); legendItems.forEach((item) => { - const circle = item.querySelector(".skeleton--circle"); - const skeletons = item.querySelectorAll(".skeleton"); + const circle = item.querySelector(".uic-skeleton-shape--circle"); + const skeletons = item.querySelectorAll(".uic-skeleton-shape"); expect(circle).toBeInTheDocument(); expect(skeletons).toHaveLength(2); // circle + text }); @@ -151,14 +157,14 @@ describe("SkeletonChart", () => { it("should have area class", () => { const { container } = render(); - const chart = container.querySelector(".skeleton-chart"); - expect(chart).toHaveClass("skeleton-chart--area"); + const chart = container.querySelector(".uic-skeleton-chart"); + expect(chart).toHaveClass("uic-skeleton-chart--area"); }); it("should render line container (same as line variant)", () => { const { container } = render(); - const lineContainer = container.querySelector(".skeleton-chart__line"); + const lineContainer = container.querySelector(".uic-skeleton-chart__line"); expect(lineContainer).toBeInTheDocument(); }); }); @@ -168,38 +174,38 @@ describe("SkeletonChart", () => { it("should render with custom height", () => { const { container } = render(); - const chart = container.querySelector(".skeleton-chart"); + const chart = container.querySelector(".uic-skeleton-chart"); expect(chart).toHaveStyle({ height: "400px" }); }); it("should handle percentage height", () => { const { container } = render(); - const chart = container.querySelector(".skeleton-chart"); + const chart = container.querySelector(".uic-skeleton-chart"); expect(chart).toHaveStyle({ height: "100%" }); }); it("should handle rem height", () => { const { container } = render(); - const chart = container.querySelector(".skeleton-chart"); + const chart = container.querySelector(".uic-skeleton-chart"); expect(chart).toHaveStyle({ height: "20rem" }); }); }); describe("CSS Classes", () => { - it("should include base skeleton-chart class", () => { + it("should include base uic-skeleton-chart class", () => { const { container } = render(); - const chart = container.querySelector(".skeleton-chart"); - expect(chart).toHaveClass("skeleton-chart"); + const chart = container.querySelector(".uic-skeleton-chart"); + expect(chart).toHaveClass("uic-skeleton-chart"); }); it("should include custom className", () => { const { container } = render(); - const chart = container.querySelector(".skeleton-chart"); - expect(chart).toHaveClass("skeleton-chart", "custom-class"); + const chart = container.querySelector(".uic-skeleton-chart"); + expect(chart).toHaveClass("uic-skeleton-chart", "custom-class"); }); }); @@ -207,21 +213,21 @@ describe("SkeletonChart", () => { it("should have role status on chart container", () => { const { container } = render(); - const chart = container.querySelector(".skeleton-chart"); + const chart = container.querySelector(".uic-skeleton-chart"); expect(chart).toHaveAttribute("role", "status"); }); it("should have aria-busy set to true on chart container", () => { const { container } = render(); - const chart = container.querySelector(".skeleton-chart"); + const chart = container.querySelector(".uic-skeleton-chart"); expect(chart).toHaveAttribute("aria-busy", "true"); }); it("should have aria-label for screen readers on chart container", () => { const { container } = render(); - const chart = container.querySelector(".skeleton-chart"); + const chart = container.querySelector(".uic-skeleton-chart"); expect(chart).toHaveAttribute("aria-label", "Loading chart"); }); }); @@ -237,7 +243,7 @@ describe("SkeletonChart", () => { it("should not render data-testid when not provided", () => { const { container } = render(); - const chart = container.querySelector(".skeleton-chart"); + const chart = container.querySelector(".uic-skeleton-chart"); expect(chart).not.toHaveAttribute("data-testid"); }); }); @@ -254,7 +260,11 @@ describe("SkeletonChart", () => { ); const chart = screen.getByTestId("custom-chart"); - expect(chart).toHaveClass("skeleton-chart", "skeleton-chart--pie", "my-chart"); + expect(chart).toHaveClass( + "uic-skeleton-chart", + "uic-skeleton-chart--pie", + "my-chart", + ); expect(chart).toHaveStyle({ height: "500px" }); expect(chart).toHaveAttribute("role", "status"); expect(chart).toHaveAttribute("aria-busy", "true"); diff --git a/src/components/Skeleton/SkeletonChart.tsx b/src/components/Skeleton/SkeletonChart.tsx index bea436f..966416f 100644 --- a/src/components/Skeleton/SkeletonChart.tsx +++ b/src/components/Skeleton/SkeletonChart.tsx @@ -1,51 +1,44 @@ /** - * SkeletonChart Component + * SkeletonChart * - * Skeleton placeholder for chart components. - * Shows a simplified chart-like shape with bars or circular elements. + * Loading placeholder for a chart: eight bars, a pie with a four-item legend, + * or one block for line and area charts. * * @example - * // Bar chart skeleton * - * - * @example - * // Pie chart skeleton - * + * */ - -import { Skeleton } from "./Skeleton"; +import { useUicTranslator } from "../../i18n/useUicTranslator"; +import { SkeletonShape } from "./SkeletonShape"; import "./SkeletonChart.css"; export interface SkeletonChartProps { /** Chart type variant */ variant?: "bar" | "line" | "pie" | "area"; - /** Chart height */ + /** Chart height, any CSS length. Default: "300px" */ height?: string; /** Additional CSS class names */ className?: string; /** Test ID for testing */ testId?: string; - /** Accessible label announced while the chart is loading. Default: "Loading chart" */ + /** + * Accessible label announced while the chart is loading. Defaults to the + * catalog's `uic.SkeletonChart.loading` ("Loading chart"). + */ loadingLabel?: string; } -/** - * SkeletonChart provides a loading placeholder for chart components. - * - * Variants: - * - bar: Vertical bars of varying heights - * - line: Line graph placeholder - * - pie: Circular chart placeholder - * - area: Area chart placeholder - */ +const BAR_HEIGHTS = [60, 80, 45, 90, 70, 55, 85, 65]; + export function SkeletonChart({ variant = "bar", height = "300px", className = "", testId, - loadingLabel = "Loading chart", + loadingLabel, }: SkeletonChartProps) { - const classNames = ["skeleton-chart", `skeleton-chart--${variant}`, className] + const t = useUicTranslator(); + const classNames = ["uic-skeleton-chart", `uic-skeleton-chart--${variant}`, className] .filter(Boolean) .join(" "); @@ -56,35 +49,40 @@ export function SkeletonChart({ data-testid={testId} role="status" aria-busy="true" - aria-label={loadingLabel} + aria-label={loadingLabel ?? t("uic.SkeletonChart.loading")} > {variant === "pie" ? ( -
- -
- {Array.from({ length: 4 }).map((_, i) => ( -
- - +
+ +
+ {Array.from({ length: 4 }, (_, i) => ( +
+ +
))}
) : variant === "bar" ? ( -
- {[60, 80, 45, 90, 70, 55, 85, 65].map((barHeight, i) => ( - + {BAR_HEIGHTS.map((barHeight, i) => ( + ))}
) : ( -
- +
+
)}
diff --git a/src/components/Skeleton/SkeletonRow.css b/src/components/Skeleton/SkeletonRow.css index 5f7d658..3c7f7b3 100644 --- a/src/components/Skeleton/SkeletonRow.css +++ b/src/components/Skeleton/SkeletonRow.css @@ -1,59 +1,52 @@ -/** - * SkeletonRow Component Styles - * - * Row skeleton styles for lists and tables. - */ - -.skeleton-row { - display: flex; - align-items: center; - gap: var(--token-paddingMD, 1rem); - padding: var(--token-paddingMD, 1rem); - background-color: var(--token-colorBgContainer, #ffffff); - border-bottom: 1px solid var(--token-colorBorderSecondary, #e5e7eb); - transition: background-color var(--token-motionDurationFast, 0.1s); -} - -.skeleton-row:first-child { - border-top-left-radius: var(--token-borderRadiusSM, 0.25rem); - border-top-right-radius: var(--token-borderRadiusSM, 0.25rem); -} +/* SkeletonRow: one list or table row, divided like Astryx list rows. */ +@layer ui-common { + .uic-skeleton-row { + display: flex; + align-items: center; + gap: var(--spacing-4); + padding: var(--spacing-4); + background-color: var(--color-background-surface); + border-bottom: var(--border-width) solid var(--color-border); + } -.skeleton-row:last-child { - border-bottom: none; - border-bottom-left-radius: var(--token-borderRadiusSM, 0.25rem); - border-bottom-right-radius: var(--token-borderRadiusSM, 0.25rem); -} + .uic-skeleton-row:first-child { + border-start-start-radius: var(--radius-inner); + border-start-end-radius: var(--radius-inner); + } -/* Avatar Section */ -.skeleton-row__avatar { - flex-shrink: 0; -} + .uic-skeleton-row:last-child { + border-bottom: none; + border-end-start-radius: var(--radius-inner); + border-end-end-radius: var(--radius-inner); + } -/* Content Section */ -.skeleton-row__content { - display: flex; - flex-direction: column; - gap: var(--token-paddingXS, 0.25rem); - flex: 1; - min-width: 0; -} + .uic-skeleton-row__avatar { + flex-shrink: 0; + } -/* Actions Section */ -.skeleton-row__actions { - display: flex; - gap: var(--token-paddingSM, 0.5rem); - flex-shrink: 0; -} + .uic-skeleton-row__content { + display: flex; + flex-direction: column; + gap: var(--spacing-1); + flex: 1; + min-width: 0; + } -/* Mobile Adjustments */ -@media (max-width: 767px) { - .skeleton-row { - padding: var(--token-paddingSM, 0.5rem); - gap: var(--token-paddingSM, 0.5rem); + .uic-skeleton-row__actions { + display: flex; + gap: var(--spacing-2); + flex-shrink: 0; } - .skeleton-row__actions { - display: none; + /* Literal: a media query cannot read a custom property. */ + @media (max-width: 767px) { + .uic-skeleton-row { + padding: var(--spacing-2); + gap: var(--spacing-2); + } + + .uic-skeleton-row__actions { + display: none; + } } } diff --git a/src/components/Skeleton/SkeletonRow.test.tsx b/src/components/Skeleton/SkeletonRow.test.tsx index 579b78b..a6a9700 100644 --- a/src/components/Skeleton/SkeletonRow.test.tsx +++ b/src/components/Skeleton/SkeletonRow.test.tsx @@ -20,29 +20,29 @@ describe("SkeletonRow", () => { it("should render with default props", () => { const { container } = render(); - const row = container.querySelector(".skeleton-row"); + const row = container.querySelector(".uic-skeleton-row"); expect(row).toBeInTheDocument(); }); it("should render 1 row by default", () => { const { container } = render(); - const rows = container.querySelectorAll(".skeleton-row"); + const rows = container.querySelectorAll(".uic-skeleton-row"); expect(rows).toHaveLength(1); }); it("should render content section", () => { const { container } = render(); - const content = container.querySelector(".skeleton-row__content"); + const content = container.querySelector(".uic-skeleton-row__content"); expect(content).toBeInTheDocument(); }); it("should render 2 skeletons in content (timestamp and text)", () => { const { container } = render(); - const content = container.querySelector(".skeleton-row__content"); - const skeletons = content?.querySelectorAll(".skeleton"); + const content = container.querySelector(".uic-skeleton-row__content"); + const skeletons = content?.querySelectorAll(".uic-skeleton-shape"); expect(skeletons).toHaveLength(2); }); }); @@ -51,21 +51,21 @@ describe("SkeletonRow", () => { it("should render 1 row when count is 1", () => { const { container } = render(); - const rows = container.querySelectorAll(".skeleton-row"); + const rows = container.querySelectorAll(".uic-skeleton-row"); expect(rows).toHaveLength(1); }); it("should render 5 rows when count is 5", () => { const { container } = render(); - const rows = container.querySelectorAll(".skeleton-row"); + const rows = container.querySelectorAll(".uic-skeleton-row"); expect(rows).toHaveLength(5); }); it("should render 10 rows when count is 10", () => { const { container } = render(); - const rows = container.querySelectorAll(".skeleton-row"); + const rows = container.querySelectorAll(".uic-skeleton-row"); expect(rows).toHaveLength(10); }); }); @@ -74,29 +74,29 @@ describe("SkeletonRow", () => { it("should not show avatar by default", () => { const { container } = render(); - const avatar = container.querySelector(".skeleton-row__avatar"); + const avatar = container.querySelector(".uic-skeleton-row__avatar"); expect(avatar).not.toBeInTheDocument(); }); it("should show avatar when showAvatar is true", () => { const { container } = render(); - const avatar = container.querySelector(".skeleton-row__avatar"); + const avatar = container.querySelector(".uic-skeleton-row__avatar"); expect(avatar).toBeInTheDocument(); }); it("should render circle skeleton in avatar", () => { const { container } = render(); - const avatar = container.querySelector(".skeleton-row__avatar"); - const circle = avatar?.querySelector(".skeleton--circle"); + const avatar = container.querySelector(".uic-skeleton-row__avatar"); + const circle = avatar?.querySelector(".uic-skeleton-shape--circle"); expect(circle).toBeInTheDocument(); }); it("should show avatar on all rows when count > 1", () => { const { container } = render(); - const avatars = container.querySelectorAll(".skeleton-row__avatar"); + const avatars = container.querySelectorAll(".uic-skeleton-row__avatar"); expect(avatars).toHaveLength(3); }); }); @@ -105,29 +105,29 @@ describe("SkeletonRow", () => { it("should not show actions by default", () => { const { container } = render(); - const actions = container.querySelector(".skeleton-row__actions"); + const actions = container.querySelector(".uic-skeleton-row__actions"); expect(actions).not.toBeInTheDocument(); }); it("should show actions when showActions is true", () => { const { container } = render(); - const actions = container.querySelector(".skeleton-row__actions"); + const actions = container.querySelector(".uic-skeleton-row__actions"); expect(actions).toBeInTheDocument(); }); it("should render 2 action button skeletons", () => { const { container } = render(); - const actions = container.querySelector(".skeleton-row__actions"); - const buttons = actions?.querySelectorAll(".skeleton"); + const actions = container.querySelector(".uic-skeleton-row__actions"); + const buttons = actions?.querySelectorAll(".uic-skeleton-shape"); expect(buttons).toHaveLength(2); }); it("should show actions on all rows when count > 1", () => { const { container } = render(); - const actionSections = container.querySelectorAll(".skeleton-row__actions"); + const actionSections = container.querySelectorAll(".uic-skeleton-row__actions"); expect(actionSections).toHaveLength(3); }); }); @@ -136,8 +136,8 @@ describe("SkeletonRow", () => { it("should show both avatar and actions when both are true", () => { const { container } = render(); - const avatar = container.querySelector(".skeleton-row__avatar"); - const actions = container.querySelector(".skeleton-row__actions"); + const avatar = container.querySelector(".uic-skeleton-row__avatar"); + const actions = container.querySelector(".uic-skeleton-row__actions"); expect(avatar).toBeInTheDocument(); expect(actions).toBeInTheDocument(); }); @@ -145,36 +145,36 @@ describe("SkeletonRow", () => { it("should show avatar, actions, and content on all rows", () => { const { container } = render(); - const rows = container.querySelectorAll(".skeleton-row"); + const rows = container.querySelectorAll(".uic-skeleton-row"); rows.forEach((row) => { - expect(row.querySelector(".skeleton-row__avatar")).toBeInTheDocument(); - expect(row.querySelector(".skeleton-row__content")).toBeInTheDocument(); - expect(row.querySelector(".skeleton-row__actions")).toBeInTheDocument(); + expect(row.querySelector(".uic-skeleton-row__avatar")).toBeInTheDocument(); + expect(row.querySelector(".uic-skeleton-row__content")).toBeInTheDocument(); + expect(row.querySelector(".uic-skeleton-row__actions")).toBeInTheDocument(); }); }); }); describe("CSS Classes", () => { - it("should include base skeleton-row class", () => { + it("should include base uic-skeleton-row class", () => { const { container } = render(); - const row = container.querySelector(".skeleton-row"); - expect(row).toHaveClass("skeleton-row"); + const row = container.querySelector(".uic-skeleton-row"); + expect(row).toHaveClass("uic-skeleton-row"); }); it("should include custom className", () => { const { container } = render(); - const row = container.querySelector(".skeleton-row"); - expect(row).toHaveClass("skeleton-row", "custom-class"); + const row = container.querySelector(".uic-skeleton-row"); + expect(row).toHaveClass("uic-skeleton-row", "custom-class"); }); it("should apply custom className to all rows", () => { const { container } = render(); - const rows = container.querySelectorAll(".skeleton-row"); + const rows = container.querySelectorAll(".uic-skeleton-row"); rows.forEach((row) => { - expect(row).toHaveClass("skeleton-row", "custom-class"); + expect(row).toHaveClass("uic-skeleton-row", "custom-class"); }); }); }); @@ -183,7 +183,7 @@ describe("SkeletonRow", () => { it("should have role status on each row", () => { const { container } = render(); - const rows = container.querySelectorAll(".skeleton-row"); + const rows = container.querySelectorAll(".uic-skeleton-row"); rows.forEach((row) => { expect(row).toHaveAttribute("role", "status"); }); @@ -192,7 +192,7 @@ describe("SkeletonRow", () => { it("should have aria-busy set to true on each row", () => { const { container } = render(); - const rows = container.querySelectorAll(".skeleton-row"); + const rows = container.querySelectorAll(".uic-skeleton-row"); rows.forEach((row) => { expect(row).toHaveAttribute("aria-busy", "true"); }); @@ -218,7 +218,7 @@ describe("SkeletonRow", () => { it("should not render data-testid when not provided", () => { const { container } = render(); - const row = container.querySelector(".skeleton-row"); + const row = container.querySelector(".uic-skeleton-row"); expect(row).not.toHaveAttribute("data-testid"); }); }); @@ -239,19 +239,19 @@ describe("SkeletonRow", () => { const row2 = screen.getByTestId("custom-row-1"); [row1, row2].forEach((row) => { - expect(row).toHaveClass("skeleton-row", "my-row"); + expect(row).toHaveClass("uic-skeleton-row", "my-row"); expect(row).toHaveAttribute("role", "status"); expect(row).toHaveAttribute("aria-busy", "true"); - expect(row.querySelector(".skeleton-row__avatar")).toBeInTheDocument(); - expect(row.querySelector(".skeleton-row__content")).toBeInTheDocument(); - expect(row.querySelector(".skeleton-row__actions")).toBeInTheDocument(); + expect(row.querySelector(".uic-skeleton-row__avatar")).toBeInTheDocument(); + expect(row.querySelector(".uic-skeleton-row__content")).toBeInTheDocument(); + expect(row.querySelector(".uic-skeleton-row__actions")).toBeInTheDocument(); }); }); it("should handle 0 count gracefully", () => { const { container } = render(); - const rows = container.querySelectorAll(".skeleton-row"); + const rows = container.querySelectorAll(".uic-skeleton-row"); expect(rows).toHaveLength(0); }); @@ -264,7 +264,7 @@ describe("SkeletonRow", () => { const wrapper = screen.getByTestId("wrapper"); // Direct children should be the skeleton rows - const rows = wrapper.querySelectorAll(":scope > .skeleton-row"); + const rows = wrapper.querySelectorAll(":scope > .uic-skeleton-row"); expect(rows).toHaveLength(2); }); }); diff --git a/src/components/Skeleton/SkeletonRow.tsx b/src/components/Skeleton/SkeletonRow.tsx index b0db8a3..2ae2071 100644 --- a/src/components/Skeleton/SkeletonRow.tsx +++ b/src/components/Skeleton/SkeletonRow.tsx @@ -1,19 +1,16 @@ /** - * SkeletonRow Component + * SkeletonRow * - * Skeleton placeholder for list rows and table rows. - * Mimics the structure of a log entry or table row. + * Loading placeholder for list and table rows: an optional avatar, two lines, + * and optional action buttons. `count` renders that many rows as a fragment, + * so they stay direct children of the caller's list; each row is its own + * placeholder and its own live region. * * @example - * // Basic usage - * - * - * @example - * // With avatar - * + * */ - -import { Skeleton } from "./Skeleton"; +import { useUicTranslator } from "../../i18n/useUicTranslator"; +import { SkeletonShape } from "./SkeletonShape"; import "./SkeletonRow.css"; export interface SkeletonRowProps { @@ -25,20 +22,15 @@ export interface SkeletonRowProps { count?: number; /** Additional CSS class names */ className?: string; - /** Test ID for testing */ + /** Test ID for testing; each row gets `-` */ testId?: string; - /** Accessible name announced for the placeholder as a whole. Defaults to "Loading". */ + /** + * Accessible name announced for each row. Defaults to the catalog's + * `uic.SkeletonRow.loading` ("Loading"). + */ loadingLabel?: string; } -/** - * SkeletonRow provides a loading placeholder for list/table rows. - * - * Features: - * - Optional avatar/icon - * - Optional action buttons - * - Multiple rows for lists - */ export function SkeletonRow({ showAvatar = false, showActions = false, @@ -47,32 +39,34 @@ export function SkeletonRow({ testId, loadingLabel, }: SkeletonRowProps) { - const rows = Array.from({ length: count }); + const t = useUicTranslator(); + const label = loadingLabel ?? t("uic.SkeletonRow.loading"); + const classNames = ["uic-skeleton-row", className].filter(Boolean).join(" "); return ( <> - {rows.map((_, index) => ( + {Array.from({ length: Math.max(0, count) }, (_, index) => (
{showAvatar && ( -
- +
+
)} -
- - +
+ +
{showActions && ( -
- - +
+ +
)}
diff --git a/src/components/Skeleton/SkeletonShape.css b/src/components/Skeleton/SkeletonShape.css new file mode 100644 index 0000000..a39dd11 --- /dev/null +++ b/src/components/Skeleton/SkeletonShape.css @@ -0,0 +1,13 @@ +/* A shape inside a ui-common Skeleton composite. Astryx `Skeleton` draws it. */ +@layer ui-common { + .uic-skeleton-shape { + display: block; + flex-shrink: 0; + } + + /* A line of text: one em tall, drawn at the x-height so stacked lines keep + the gaps a paragraph has. */ + .uic-skeleton-shape--text { + transform: scale(1, 0.6); + } +} diff --git a/src/components/Skeleton/SkeletonShape.tsx b/src/components/Skeleton/SkeletonShape.tsx new file mode 100644 index 0000000..11a3773 --- /dev/null +++ b/src/components/Skeleton/SkeletonShape.tsx @@ -0,0 +1,42 @@ +/** + * One placeholder shape inside a Skeleton composite. Internal: not exported. + * + * A thin layer over Astryx `Skeleton`, which is already decorative + * (`aria-hidden`), so a composite stays one live region however many shapes + * it draws. The composite's container carries `role="status"` and the name. + */ +import { Skeleton } from "@astryxdesign/core/Skeleton"; + +import "./SkeletonShape.css"; + +export type SkeletonShapeKind = "rect" | "circle" | "text"; + +export interface SkeletonShapeProps { + width: string; + height: string; + shape?: SkeletonShapeKind; + /** Position in the composite, for Astryx's staggered pulse. */ + index?: number; + className?: string; +} + +export function SkeletonShape({ + width, + height, + shape = "rect", + index = 0, + className, +}: SkeletonShapeProps) { + const classes = ["uic-skeleton-shape", `uic-skeleton-shape--${shape}`, className] + .filter(Boolean) + .join(" "); + return ( + + ); +} diff --git a/src/components/Skeleton/SkeletonText.css b/src/components/Skeleton/SkeletonText.css index b0766b7..a908ed8 100644 --- a/src/components/Skeleton/SkeletonText.css +++ b/src/components/Skeleton/SkeletonText.css @@ -1,23 +1,19 @@ -/** - * SkeletonText Component Styles - * - * Text skeleton styles with configurable spacing. - */ +/* SkeletonText: stacked text lines, spaced on Astryx's spacing scale. */ +@layer ui-common { + .uic-skeleton-text { + display: flex; + flex-direction: column; + } -.skeleton-text { - display: flex; - flex-direction: column; -} - -/* Spacing Variants */ -.skeleton-text--compact { - gap: var(--token-paddingXS, 0.25rem); -} + .uic-skeleton-text--compact { + gap: var(--spacing-1); + } -.skeleton-text--normal { - gap: var(--token-paddingSM, 0.5rem); -} + .uic-skeleton-text--normal { + gap: var(--spacing-2); + } -.skeleton-text--relaxed { - gap: var(--token-paddingMD, 1rem); + .uic-skeleton-text--relaxed { + gap: var(--spacing-4); + } } diff --git a/src/components/Skeleton/SkeletonText.test.tsx b/src/components/Skeleton/SkeletonText.test.tsx index 03c315a..4b84a1c 100644 --- a/src/components/Skeleton/SkeletonText.test.tsx +++ b/src/components/Skeleton/SkeletonText.test.tsx @@ -15,19 +15,29 @@ import { describe, it, expect } from "vitest"; import { render, screen } from "@testing-library/react"; import { SkeletonText } from "./SkeletonText"; +/** + * The width a line was asked for. Astryx `Skeleton` sets it as its own + * `--x-width` custom property and sizes from that in `astryx.css`, which + * jsdom does not load, so `toHaveStyle({ width })` cannot see it. + */ +function widthOf(line: Element | undefined): string { + if (!(line instanceof HTMLElement)) throw new Error("no line rendered"); + return line.style.getPropertyValue("--x-width"); +} + describe("SkeletonText", () => { describe("Rendering", () => { it("should render with default props", () => { const { container } = render(); - const textContainer = container.querySelector(".skeleton-text"); + const textContainer = container.querySelector(".uic-skeleton-text"); expect(textContainer).toBeInTheDocument(); }); it("should render 3 lines by default", () => { const { container } = render(); - const lines = container.querySelectorAll(".skeleton"); + const lines = container.querySelectorAll(".uic-skeleton-shape"); expect(lines).toHaveLength(3); }); }); @@ -36,21 +46,21 @@ describe("SkeletonText", () => { it("should render 1 line when specified", () => { const { container } = render(); - const lines = container.querySelectorAll(".skeleton"); + const lines = container.querySelectorAll(".uic-skeleton-shape"); expect(lines).toHaveLength(1); }); it("should render 5 lines when specified", () => { const { container } = render(); - const lines = container.querySelectorAll(".skeleton"); + const lines = container.querySelectorAll(".uic-skeleton-shape"); expect(lines).toHaveLength(5); }); it("should render 10 lines when specified", () => { const { container } = render(); - const lines = container.querySelectorAll(".skeleton"); + const lines = container.querySelectorAll(".uic-skeleton-shape"); expect(lines).toHaveLength(10); }); }); @@ -59,39 +69,39 @@ describe("SkeletonText", () => { it("should have last line at 60% width", () => { const { container } = render(); - const lines = container.querySelectorAll(".skeleton"); + const lines = container.querySelectorAll(".uic-skeleton-shape"); const lastLine = lines[lines.length - 1]; - expect(lastLine).toHaveStyle({ width: "60%" }); + expect(widthOf(lastLine)).toBe("60%"); }); it("should have first line at 100% width", () => { const { container } = render(); - const lines = container.querySelectorAll(".skeleton"); - expect(lines[0]).toHaveStyle({ width: "100%" }); + const lines = container.querySelectorAll(".uic-skeleton-shape"); + expect(widthOf(lines[0])).toBe("100%"); }); it("should alternate between 100% and 95% for middle lines", () => { const { container } = render(); - const lines = container.querySelectorAll(".skeleton"); + const lines = container.querySelectorAll(".uic-skeleton-shape"); // Line 0: 100% (even index) // Line 1: 95% (odd index) // Line 2: 100% (even index) // Line 3: 95% (odd index) // Line 4: 60% (last line) - expect(lines[0]).toHaveStyle({ width: "100%" }); - expect(lines[1]).toHaveStyle({ width: "95%" }); - expect(lines[2]).toHaveStyle({ width: "100%" }); - expect(lines[3]).toHaveStyle({ width: "95%" }); - expect(lines[4]).toHaveStyle({ width: "60%" }); + expect(widthOf(lines[0])).toBe("100%"); + expect(widthOf(lines[1])).toBe("95%"); + expect(widthOf(lines[2])).toBe("100%"); + expect(widthOf(lines[3])).toBe("95%"); + expect(widthOf(lines[4])).toBe("60%"); }); it("should handle single line (which is also last line)", () => { const { container } = render(); - const lines = container.querySelectorAll(".skeleton"); - expect(lines[0]).toHaveStyle({ width: "60%" }); + const lines = container.querySelectorAll(".uic-skeleton-shape"); + expect(widthOf(lines[0])).toBe("60%"); }); }); @@ -99,44 +109,44 @@ describe("SkeletonText", () => { it("should render with normal spacing by default", () => { const { container } = render(); - const textContainer = container.querySelector(".skeleton-text"); - expect(textContainer).toHaveClass("skeleton-text--normal"); + const textContainer = container.querySelector(".uic-skeleton-text"); + expect(textContainer).toHaveClass("uic-skeleton-text--normal"); }); it("should render with compact spacing", () => { const { container } = render(); - const textContainer = container.querySelector(".skeleton-text"); - expect(textContainer).toHaveClass("skeleton-text--compact"); + const textContainer = container.querySelector(".uic-skeleton-text"); + expect(textContainer).toHaveClass("uic-skeleton-text--compact"); }); it("should render with relaxed spacing", () => { const { container } = render(); - const textContainer = container.querySelector(".skeleton-text"); - expect(textContainer).toHaveClass("skeleton-text--relaxed"); + const textContainer = container.querySelector(".uic-skeleton-text"); + expect(textContainer).toHaveClass("uic-skeleton-text--relaxed"); }); }); describe("CSS Classes", () => { - it("should include base skeleton-text class", () => { + it("should include base uic-skeleton-text class", () => { const { container } = render(); - const textContainer = container.querySelector(".skeleton-text"); - expect(textContainer).toHaveClass("skeleton-text"); + const textContainer = container.querySelector(".uic-skeleton-text"); + expect(textContainer).toHaveClass("uic-skeleton-text"); }); it("should include custom className", () => { const { container } = render(); - const textContainer = container.querySelector(".skeleton-text"); - expect(textContainer).toHaveClass("skeleton-text", "custom-class"); + const textContainer = container.querySelector(".uic-skeleton-text"); + expect(textContainer).toHaveClass("uic-skeleton-text", "custom-class"); }); it("should render skeleton lines with text variant", () => { const { container } = render(); - const lines = container.querySelectorAll(".skeleton--text"); + const lines = container.querySelectorAll(".uic-skeleton-shape--text"); expect(lines.length).toBeGreaterThan(0); }); }); @@ -145,14 +155,14 @@ describe("SkeletonText", () => { it("should have role status on container", () => { const { container } = render(); - const textContainer = container.querySelector(".skeleton-text"); + const textContainer = container.querySelector(".uic-skeleton-text"); expect(textContainer).toHaveAttribute("role", "status"); }); it("should have aria-busy set to true on container", () => { const { container } = render(); - const textContainer = container.querySelector(".skeleton-text"); + const textContainer = container.querySelector(".uic-skeleton-text"); expect(textContainer).toHaveAttribute("aria-busy", "true"); }); }); @@ -168,7 +178,7 @@ describe("SkeletonText", () => { it("should not render data-testid when not provided", () => { const { container } = render(); - const textContainer = container.querySelector(".skeleton-text"); + const textContainer = container.querySelector(".uic-skeleton-text"); expect(textContainer).not.toHaveAttribute("data-testid"); }); }); @@ -186,21 +196,21 @@ describe("SkeletonText", () => { const textContainer = screen.getByTestId("custom-text"); expect(textContainer).toHaveClass( - "skeleton-text", - "skeleton-text--compact", + "uic-skeleton-text", + "uic-skeleton-text--compact", "my-text", ); expect(textContainer).toHaveAttribute("role", "status"); expect(textContainer).toHaveAttribute("aria-busy", "true"); - const lines = textContainer.querySelectorAll(".skeleton"); + const lines = textContainer.querySelectorAll(".uic-skeleton-shape"); expect(lines).toHaveLength(4); }); it("should handle 0 lines gracefully", () => { const { container } = render(); - const lines = container.querySelectorAll(".skeleton"); + const lines = container.querySelectorAll(".uic-skeleton-shape"); expect(lines).toHaveLength(0); }); }); diff --git a/src/components/Skeleton/SkeletonText.tsx b/src/components/Skeleton/SkeletonText.tsx index ffabf0c..4350772 100644 --- a/src/components/Skeleton/SkeletonText.tsx +++ b/src/components/Skeleton/SkeletonText.tsx @@ -1,19 +1,15 @@ /** - * SkeletonText Component + * SkeletonText * - * Skeleton placeholder for text content. - * Provides multiple lines with varying widths for natural appearance. + * Loading placeholder for a block of text: `lines` lines, alternating full and + * slightly short, with a shorter last line. * * @example - * // Basic usage (3 lines) * - * - * @example - * // Custom number of lines - * + * */ - -import { Skeleton } from "./Skeleton"; +import { useUicTranslator } from "../../i18n/useUicTranslator"; +import { SkeletonShape } from "./SkeletonShape"; import "./SkeletonText.css"; export interface SkeletonTextProps { @@ -25,18 +21,19 @@ export interface SkeletonTextProps { className?: string; /** Test ID for testing */ testId?: string; - /** Accessible name announced for the placeholder as a whole. Defaults to "Loading". */ + /** + * Accessible name announced for the placeholder as a whole. Defaults to the + * catalog's `uic.SkeletonText.loading` ("Loading"). + */ loadingLabel?: string; } -/** - * SkeletonText provides a loading placeholder for text content. - * - * Features: - * - Multiple lines with varying widths - * - Configurable line spacing - * - Accessible via aria-busy - */ +/** The width of line `i` of `lines`: the last is short, the rest alternate. */ +function lineWidth(i: number, lines: number): string { + if (i === lines - 1) return "60%"; + return i % 2 === 0 ? "100%" : "95%"; +} + export function SkeletonText({ lines = 3, spacing = "normal", @@ -44,30 +41,27 @@ export function SkeletonText({ testId, loadingLabel, }: SkeletonTextProps) { - const classNames = ["skeleton-text", `skeleton-text--${spacing}`, className] + const t = useUicTranslator(); + const classNames = ["uic-skeleton-text", `uic-skeleton-text--${spacing}`, className] .filter(Boolean) .join(" "); - // Generate varying widths for natural appearance - const widths = Array.from({ length: lines }, (_, i) => { - if (i === lines - 1) { - // Last line is shorter - return "60%"; - } - // Alternate between full and slightly shorter widths - return i % 2 === 0 ? "100%" : "95%"; - }); - return (
- {widths.map((width, index) => ( - + {Array.from({ length: Math.max(0, lines) }, (_, index) => ( + ))}
); diff --git a/src/components/Skeleton/index.ts b/src/components/Skeleton/index.ts index 04ff584..2b5930c 100644 --- a/src/components/Skeleton/index.ts +++ b/src/components/Skeleton/index.ts @@ -1,7 +1,8 @@ /** * Skeleton Components * - * Loading state components with shimmer animations. + * Loading state components. The composites are drawn with Astryx `Skeleton`; + * the 0.1 base `Skeleton` is deprecated. */ export { Skeleton } from "./Skeleton"; diff --git a/src/components/Skeleton/liveRegions.test.tsx b/src/components/Skeleton/liveRegions.test.tsx index d58584d..2e7a948 100644 --- a/src/components/Skeleton/liveRegions.test.tsx +++ b/src/components/Skeleton/liveRegions.test.tsx @@ -17,7 +17,8 @@ import { describe, expect, it } from "vitest"; import { render, screen } from "@testing-library/react"; -import { Skeleton } from "./Skeleton"; +import { InternationalizationProvider } from "@astryxdesign/core/i18n"; + import { SkeletonCard } from "./SkeletonCard"; import { SkeletonChart } from "./SkeletonChart"; import { SkeletonRow } from "./SkeletonRow"; @@ -72,21 +73,60 @@ describe("the container carries the name", () => { }); }); -describe("the standalone primitive is unchanged", () => { - it("is its own live region when nothing wraps it", () => { - render(); +describe("the shapes inside are decoration", () => { + // Astryx `Skeleton` is `aria-hidden` by design; the composites rely on it + // rather than on a flag of their own, so this pins the assumption. + it.each([ + ["SkeletonCard", ], + ["SkeletonChart", ], + ["SkeletonText", ], + ["SkeletonRow", ], + ])("%s draws every shape with Astryx Skeleton, hidden", (_name, element) => { + const { container } = render(element); + + const shapes = container.querySelectorAll(".uic-skeleton-shape"); + expect(shapes.length).toBeGreaterThan(0); + for (const shape of shapes) { + expect(shape).toHaveClass("astryx-skeleton"); + expect(shape).toHaveAttribute("aria-hidden", "true"); + } + }); +}); + +describe("default labels come from the catalog", () => { + it("resolves a translated name through Astryx's provider", () => { + render( + + + , + ); - expect(screen.getByRole("status", { name: "Loading avatar" })).toBeInTheDocument(); + expect( + screen.getByRole("status", { name: "차트 불러오는 중" }), + ).toBeInTheDocument(); }); - it("leaves the tree only when asked", () => { - const { container } = render(); + it("lets an explicit label win over the catalog", () => { + render( + + + , + ); - expect(screen.queryByRole("status")).not.toBeInTheDocument(); - const shape = container.querySelector('[data-testid="shape"]'); - expect(shape).toHaveAttribute("aria-hidden", "true"); - // Still a shimmer of the right size: decoration, not absence. - expect(shape).toHaveClass("skeleton"); - expect(shape?.querySelector(".skeleton__shimmer")).not.toBeNull(); + expect(screen.getByRole("status", { name: "Fetching series" })).toBeInTheDocument(); }); }); diff --git a/src/components/SmoothHeight/SmoothHeight.css b/src/components/SmoothHeight/SmoothHeight.css index a3b3543..3f83e99 100644 --- a/src/components/SmoothHeight/SmoothHeight.css +++ b/src/components/SmoothHeight/SmoothHeight.css @@ -1,24 +1,22 @@ -/** - * SmoothHeight Component Styles - */ - -/* While active, the wrapper carries an explicit pixel height (set from - the measured content) and animates toward it. Content beyond the - animated edge is clipped until the height catches up. */ -.smooth-height--active { - overflow: hidden; - transition: height var(--token-motionDurationMid, 0.2s) ease-out; -} +/* SmoothHeight: animates a wrapper toward its content's measured height. */ +@layer ui-common { + /* While active, the wrapper carries an explicit pixel height (set from the + measured content) and animates toward it. Content beyond the animated + edge is clipped until the height catches up. */ + .uic-smooth-height--active { + overflow: hidden; + transition: height var(--duration-fast-max) var(--ease-standard); + } -/* flow-root contains the children's margins, so the measured content - height matches the rendered height exactly. */ -.smooth-height__content { - display: flow-root; -} + /* flow-root contains the children's margins, so the measured content + height matches the rendered height exactly. */ + .uic-smooth-height__content { + display: flow-root; + } -/* Accessibility: no height animation for reduced motion */ -@media (prefers-reduced-motion: reduce) { - .smooth-height--active { - transition: none; + @media (prefers-reduced-motion: reduce) { + .uic-smooth-height--active { + transition: none; + } } } diff --git a/src/components/SmoothHeight/SmoothHeight.test.tsx b/src/components/SmoothHeight/SmoothHeight.test.tsx index 0db050c..8172636 100644 --- a/src/components/SmoothHeight/SmoothHeight.test.tsx +++ b/src/components/SmoothHeight/SmoothHeight.test.tsx @@ -67,17 +67,17 @@ describe("SmoothHeight", () => { const { container, rerender } = render( content, ); - const outer = container.querySelector(".smooth-height"); - expect(outer).not.toHaveClass("smooth-height--active"); + const outer = container.querySelector(".uic-smooth-height"); + expect(outer).not.toHaveClass("uic-smooth-height--active"); rerender(content); - expect(outer).toHaveClass("smooth-height--active"); + expect(outer).toHaveClass("uic-smooth-height--active"); }); it("tracks the measured content height while active", () => { const { container } = render(content); - const outer = container.querySelector(".smooth-height"); - const inner = container.querySelector(".smooth-height__content"); + const outer = container.querySelector(".uic-smooth-height"); + const inner = container.querySelector(".uic-smooth-height__content"); expect(outer?.style.height).toBe("0px"); // Content grows: the observer fires and the wrapper height follows. @@ -93,8 +93,8 @@ describe("SmoothHeight", () => { const { container, rerender } = render( content, ); - const outer = container.querySelector(".smooth-height"); - const inner = container.querySelector(".smooth-height__content"); + const outer = container.querySelector(".uic-smooth-height"); + const inner = container.querySelector(".uic-smooth-height__content"); setOffsetHeight(inner as Element, 80); act(() => { MockResizeObserver.instances[0]?.trigger(); @@ -114,7 +114,7 @@ describe("SmoothHeight", () => { it("renders plain wrappers when ResizeObserver is unavailable", () => { vi.stubGlobal("ResizeObserver", undefined); const { container } = render(content); - const outer = container.querySelector(".smooth-height"); + const outer = container.querySelector(".uic-smooth-height"); expect(outer?.style.height).toBe(""); }); }); diff --git a/src/components/SmoothHeight/SmoothHeight.tsx b/src/components/SmoothHeight/SmoothHeight.tsx index 59ea981..e9287d7 100644 --- a/src/components/SmoothHeight/SmoothHeight.tsx +++ b/src/components/SmoothHeight/SmoothHeight.tsx @@ -36,7 +36,7 @@ export interface SmoothHeightProps { /** * Delay before releasing the explicit height after deactivation, covering - * the height transition duration (--token-motionDurationMid = 0.2s). + * the height transition (--duration-fast-max, 230ms by default). */ const RELEASE_DELAY_MS = 250; @@ -87,9 +87,9 @@ export function SmoothHeight({ active, children, className }: SmoothHeightProps) return (
-
+
{children}
diff --git a/src/components/StatCard/StatCard.css b/src/components/StatCard/StatCard.css index 69ab40f..0c0bbca 100644 --- a/src/components/StatCard/StatCard.css +++ b/src/components/StatCard/StatCard.css @@ -1,252 +1,195 @@ -/** - * StatCard styles - * - * Uses design tokens from src/styles/tokens.css. Wraps BaseCard so motion, - * hover lift, focus ring, and reduced-motion handling are inherited. Only - * StatCard-specific surface details (tone accents, value typography) live - * here. +/* + * StatCard: the metric layout inside an Astryx Card. The card itself owns the + * surface, border, padding and, for ClickableCard, hover and focus. */ +@layer ui-common { + .uic-stat-card { + /* The tone colour, read by the corner accent, the value and the icon. */ + --uic-stat-card-tone: transparent; + --uic-stat-card-tone-muted: var(--color-background-muted); + position: relative; + display: flex; + flex-direction: column; + gap: var(--spacing-2); + min-width: 0; + /* Clips the corner accent to the card's rounded corner. */ + overflow: hidden; + } -.stat-card { - /* BaseCard already handles padding/border/background. We only adjust - vertical rhythm so the label/value/hint stack reads cleanly. */ - gap: var(--token-paddingSM, 0.5rem); - min-width: 0; - /* Clip the shared corner-accent triangle to the card's rounded corner. */ - overflow: hidden; -} - -.stat-card__header { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--token-paddingXS, 0.25rem); -} - -.stat-card__label { - font-size: var(--token-fontSizeSM, 0.75rem); - font-weight: 600; - color: var(--token-colorTextSecondary, rgba(255, 255, 255, 0.45)); - text-transform: uppercase; - letter-spacing: 0.04em; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.stat-card__icon { - display: inline-flex; - align-items: center; - justify-content: center; - width: 28px; - height: 28px; - border-radius: var(--token-borderRadiusSM, 0.25rem); - background: var(--token-colorFillSecondary, rgba(255, 255, 255, 0.04)); - color: var(--token-colorTextSecondary, rgba(255, 255, 255, 0.6)); - flex-shrink: 0; -} - -.stat-card__body { - display: flex; - flex-direction: column; - gap: 4px; -} - -.stat-card__value-row { - display: inline-flex; - align-items: baseline; - gap: 0.3em; - flex-wrap: wrap; -} - -/* Present only when a `sparkline` slot is passed, so cards without one keep - the value row as a direct child of the body. */ -.stat-card__value-line { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--token-paddingSM, 0.5rem); - min-width: 0; -} - -.stat-card__sparkline { - display: inline-flex; - align-items: center; - flex-shrink: 0; -} - -.stat-card__value { - /* A numeric display, not a heading. Reading a heading token made the value - follow whatever heading ladder the consuming product chose, so a product - with larger headings got a larger stat value it never asked for. The two - `--emphasis` variants below still read heading tokens; see the note there. */ - font-size: var(--token-fontSizeXXL, 1.5rem); - font-weight: 600; - color: var(--token-colorText, #141414); - line-height: 1.1; - font-variant-numeric: tabular-nums; - /* The card sets `overflow: hidden` above so the corner accent follows the - rounded corner, which means a value wider than the card is cut off with no - sign that anything was cut. The card cannot widen to fix it either: a grid - track with a px floor, `minmax(180px, 1fr)`, keeps its floor no matter how - long the content is. So the value truncates itself. `min-width: 0` is what - lets it: as a flex item its automatic minimum size is its content, and - without this it refuses to shrink and overflows instead of ellipsing. */ - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -/* `animate="digits"`: the characters rise into place from below the line - (DigitPopIn), so the value clips only sideways here. A vertical clip would - cut them off as they come up. */ -.stat-card__value--digits { - overflow-x: clip; - overflow-y: visible; -} - -.stat-card__value-suffix { - font-size: var(--token-fontSize, 0.875rem); - font-weight: 400; - color: var(--token-colorTextSecondary, rgba(255, 255, 255, 0.45)); -} - -.stat-card__trend { - display: inline-flex; - align-items: center; - gap: 4px; - font-size: var(--token-fontSizeSM, 0.75rem); - font-weight: 500; -} - -.stat-card__trend--up { - color: var(--token-colorSuccess, #52c41a); -} - -.stat-card__trend--down { - color: var(--token-colorError, #f5222d); -} + /* The tone at a glance, visible even when the value is zero. */ + .uic-stat-card::before { + content: ""; + position: absolute; + inset-block-start: 0; + inset-inline-start: 0; + width: var(--spacing-3); + height: var(--spacing-3); + background-color: var(--uic-stat-card-tone); + clip-path: polygon(0 0, 100% 0, 0 100%); + pointer-events: none; + } -.stat-card__trend--flat { - color: var(--token-colorTextSecondary, rgba(255, 255, 255, 0.45)); -} + .uic-stat-card__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--spacing-1); + } -.stat-card__hint { - font-size: var(--token-fontSizeSM, 0.75rem); - color: var(--token-colorTextSecondary, rgba(255, 255, 255, 0.45)); - line-height: 1.4; -} + .uic-stat-card__label { + text-transform: uppercase; + letter-spacing: 0.04em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } -/* ── Emphasis ───────────────────────────────────────────────────────────── */ - -/* `prominent` is the shared opt-in treatment for a dashboard's hero metrics. - It carries the primary-tinted surface the Statistics page used to fork into - its own card, so the emphasis lives in one place instead of two. */ -/* Padding is doubled up on the block class deliberately. `.base-card` sets - `padding` at the same single-class specificity and ships in a different CSS - chunk, so a one-class selector here would win or lose on chunk load order. - `.stat-card.stat-card--*` settles it regardless of bundler chunking. */ -.stat-card.stat-card--prominent { - padding: var(--token-paddingLG, 1.5rem); -} + .uic-stat-card__icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: var(--size-element-sm); + height: var(--size-element-sm); + border-radius: var(--radius-inner); + background: var(--uic-stat-card-tone-muted); + color: var(--color-icon-secondary); + flex-shrink: 0; + } -.stat-card--prominent { - background: linear-gradient( - 135deg, - var(--token-colorBgContainer, #ffffff) 0%, - color-mix(in srgb, var(--token-colorPrimary, #8b5cf6) 4%, transparent) 100% - ); -} + .uic-stat-card__body { + display: flex; + flex-direction: column; + gap: var(--spacing-1); + } -.stat-card--prominent:hover { - border-color: color-mix(in srgb, var(--token-colorPrimary, #8b5cf6) 30%, transparent); -} + .uic-stat-card__value-row { + display: inline-flex; + align-items: baseline; + gap: 0.3em; + flex-wrap: wrap; + min-width: 0; + } -/* The two emphasis variants still read heading tokens. Moving them needs a - numeric ladder above XXL that neither consuming product has agreed on yet, - and inventing one here would be a token contract nobody asked for. Tracked - rather than fixed. */ -.stat-card--prominent .stat-card__value { - font-size: var(--token-fontSizeHeading1, 2.25rem); -} + /* Present only when a `sparkline` slot is passed. */ + .uic-stat-card__value-line { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--spacing-2); + min-width: 0; + } -.stat-card--prominent .stat-card__icon { - width: 34px; - height: 34px; -} + .uic-stat-card__sparkline { + display: inline-flex; + align-items: center; + flex-shrink: 0; + } -.stat-card.stat-card--compact { - padding: var(--token-paddingSM, 0.5rem); -} + /* A numeric display, not a heading: sized from the numeric ladder so a + product's heading scale does not resize it. The card clips (see above) + and a grid track keeps its floor, so the value truncates itself; + `min-width: 0` is what lets a flex item shrink below its content. */ + .uic-stat-card__value { + font-size: var(--font-size-2xl); + font-weight: var(--font-weight-semibold); + color: var(--color-text-primary); + line-height: 1.1; + font-variant-numeric: tabular-nums; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } -.stat-card--compact .stat-card__value { - font-size: var(--token-fontSizeHeading3, 1.5rem); -} + /* `animate="digits"`: characters rise from below the line, so the value + clips only sideways. */ + .uic-stat-card__value--digits { + overflow-x: clip; + overflow-y: visible; + } -/* ── Tone accents ───────────────────────────────────────────────────────── */ + .uic-stat-card__value-suffix { + font-size: var(--font-size-base); + color: var(--color-text-secondary); + } -.stat-card--tone-success .stat-card__value { - color: var(--token-colorSuccess, #52c41a); -} + .uic-stat-card__trend { + display: inline-flex; + align-items: center; + gap: var(--spacing-1); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + } -.stat-card--tone-success .stat-card__icon { - color: var(--token-colorSuccess, #52c41a); - background: rgba(82, 196, 26, 0.12); -} + .uic-stat-card__trend--up { + color: var(--color-success); + } -.stat-card--tone-warning .stat-card__value { - color: var(--token-colorWarning, #faad14); -} + .uic-stat-card__trend--down { + color: var(--color-error); + } -.stat-card--tone-warning .stat-card__icon { - color: var(--token-colorWarning, #faad14); - background: rgba(250, 173, 20, 0.14); -} + .uic-stat-card__trend--flat { + color: var(--color-text-secondary); + } -.stat-card--tone-danger .stat-card__value { - color: var(--token-colorError, #f5222d); -} + .uic-stat-card__hint { + font-size: var(--font-size-sm); + color: var(--color-text-secondary); + line-height: 1.4; + } -.stat-card--tone-danger .stat-card__icon { - color: var(--token-colorError, #f5222d); - background: rgba(245, 34, 45, 0.12); -} + /* Emphasis. Padding comes from the Card's `padding` prop; these change the + surface and the value size only. */ + .uic-stat-card--prominent { + background: linear-gradient( + 135deg, + var(--color-background-card) 0%, + color-mix(in srgb, var(--color-accent) 4%, transparent) 100% + ); + } -.stat-card--tone-info .stat-card__value { - color: var(--token-colorPrimary, #8b5cf6); -} + .uic-stat-card--prominent .uic-stat-card__value { + font-size: var(--text-heading-1-size); + } -.stat-card--tone-info .stat-card__icon { - color: var(--token-colorPrimary, #8b5cf6); - background: rgba(106, 53, 252, 0.12); -} + .uic-stat-card--compact .uic-stat-card__value { + font-size: var(--text-heading-3-size); + } -/* Tone is flagged at a glance by the shared top-left corner-accent triangle - (see src/styles/corner-accent.css). Each tone supplies its color via the - `--corner-accent-color` custom property so the accent stays visible even - when the value is zero (and thus rendered in the default text color). The - default tone leaves the color unset, so its triangle stays transparent. */ -.stat-card--tone-success { - --corner-accent-color: var(--token-colorSuccess, #52c41a); -} + .uic-stat-card--prominent .uic-stat-card__icon { + width: var(--size-element-md); + height: var(--size-element-md); + } -.stat-card--tone-warning { - --corner-accent-color: var(--token-colorWarning, #faad14); -} + /* Tones. */ + .uic-stat-card--tone-success { + --uic-stat-card-tone: var(--color-success); + --uic-stat-card-tone-muted: var(--color-success-muted); + } -.stat-card--tone-danger { - --corner-accent-color: var(--token-colorError, #f5222d); -} + .uic-stat-card--tone-warning { + --uic-stat-card-tone: var(--color-warning); + --uic-stat-card-tone-muted: var(--color-warning-muted); + } -.stat-card--tone-info { - --corner-accent-color: var(--token-colorPrimary, #8b5cf6); -} + .uic-stat-card--tone-danger { + --uic-stat-card-tone: var(--color-error); + --uic-stat-card-tone-muted: var(--color-error-muted); + } -/* ── Reduced motion (BaseCard already handles transform suppression) ───── */ + /* Info has no Astryx token; the Lablup theme declares `--uic-color-info`. */ + .uic-stat-card--tone-info { + --uic-stat-card-tone: var(--uic-color-info, var(--color-accent)); + --uic-stat-card-tone-muted: color-mix( + in srgb, + var(--uic-color-info, var(--color-accent)) 12%, + transparent + ); + } -@media (prefers-reduced-motion: reduce) { - .stat-card__trend { - transition: none; + .uic-stat-card:not(.uic-stat-card--tone-default) .uic-stat-card__value, + .uic-stat-card:not(.uic-stat-card--tone-default) .uic-stat-card__icon { + color: var(--uic-stat-card-tone); } } diff --git a/src/components/StatCard/StatCard.test.tsx b/src/components/StatCard/StatCard.test.tsx index e955a69..6929cac 100644 --- a/src/components/StatCard/StatCard.test.tsx +++ b/src/components/StatCard/StatCard.test.tsx @@ -6,6 +6,8 @@ * the public contract used by SquadDashboardStats and other dashboards. */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { act, fireEvent, render, screen } from "@testing-library/react"; import "@testing-library/jest-dom/vitest"; @@ -36,7 +38,7 @@ describe("StatCard", () => { , ); const root = container.querySelector('[data-testid="approvals"]'); - expect(root).toHaveClass("stat-card--tone-warning"); + expect(root).toHaveClass("uic-stat-card--tone-warning"); }); it("renders a trend indicator with direction-specific class", () => { @@ -46,14 +48,14 @@ describe("StatCard", () => { expect(screen.getByText("+12%")).toBeInTheDocument(); // Trend wrapper has direction class const trend = screen.getByText("+12%").parentElement; - expect(trend).toHaveClass("stat-card__trend--up"); + expect(trend).toHaveClass("uic-stat-card__trend--up"); }); it("renders a skeleton placeholder when loading", () => { const { container } = render(); expect(screen.queryByText("99")).not.toBeInTheDocument(); - // Skeleton component renders a div with class "skeleton" - expect(container.querySelector(".skeleton")).toBeInTheDocument(); + // Astryx Skeleton renders a div with class "astryx-skeleton" + expect(container.querySelector(".astryx-skeleton")).toBeInTheDocument(); expect(screen.getByRole("group", { name: "Loading" })).toBeInTheDocument(); expect(screen.queryByLabelText("Loading: 99")).not.toBeInTheDocument(); }); @@ -108,9 +110,9 @@ describe("StatCard", () => { describe("emphasis", () => { it("adds no modifier class for the default emphasis", () => { const { container } = render(); - const card = container.querySelector(".stat-card"); - expect(card).not.toHaveClass("stat-card--prominent"); - expect(card).not.toHaveClass("stat-card--compact"); + const card = container.querySelector(".uic-stat-card"); + expect(card).not.toHaveClass("uic-stat-card--prominent"); + expect(card).not.toHaveClass("uic-stat-card--compact"); }); it.each(["prominent", "compact"] as const)( @@ -119,8 +121,8 @@ describe("StatCard", () => { const { container } = render( , ); - expect(container.querySelector(".stat-card")).toHaveClass( - `stat-card--${emphasis}`, + expect(container.querySelector(".uic-stat-card")).toHaveClass( + `uic-stat-card--${emphasis}`, ); }, ); @@ -128,44 +130,42 @@ describe("StatCard", () => { describe("long value truncation", () => { /** - * `.stat-card` sets `overflow: hidden` so the corner accent follows the + * `.uic-stat-card` sets `overflow: hidden` so the corner accent follows the * rounded corner, which means a value wider than the card is cut off with * no sign that anything was cut. The card cannot grow out of it either: a * grid track with a px floor keeps its floor whatever the content length. * - * jsdom does no layout, so there is no width to assert on. It does apply - * the stylesheet, though, which is enough: these read the value back - * through the cascade, so they fail if the declarations are removed from - * `StatCard.css` and also if a later rule overrides them. `overflow` is - * read as the shorthand deliberately, because jsdom does not expand it - * into `overflow-x` and `overflow-y`. + * jsdom does no layout, and its CSS parser drops `@layer` blocks, so the + * cascade cannot be read back here. These read `StatCard.css` itself: the + * value rule must truncate, and no emphasis rule may touch those + * properties. */ - const TRUNCATION = { - minWidth: "0px", - overflow: "hidden", - textOverflow: "ellipsis", - whiteSpace: "nowrap", - } as const; - - function truncationOf(container: HTMLElement) { - const value = container.querySelector(".stat-card__value"); - if (!(value instanceof HTMLElement)) { - throw new Error("no .stat-card__value rendered"); - } - const style = getComputedStyle(value); - return { - minWidth: style.minWidth, - overflow: style.overflow, - textOverflow: style.textOverflow, - whiteSpace: style.whiteSpace, - }; + const TRUNCATION = [ + "min-width: 0", + "overflow: hidden", + "text-overflow: ellipsis", + "white-space: nowrap", + ]; + const CSS = readFileSync(join(__dirname, "StatCard.css"), "utf8").replace( + /\/\*[\s\S]*?\*\//g, + "", + ); + + /** Declarations of every rule whose selector list matches `test`. */ + function declarationsFor(test: (selector: string) => boolean): string[] { + return [...CSS.matchAll(/([^{}]+)\{([^{}]*)\}/g)] + .filter((m) => test((m[1] ?? "").trim())) + .flatMap((m) => + (m[2] ?? "") + .split(";") + .map((d) => d.trim().replace(/\s+/g, " ")) + .filter(Boolean), + ); } it("ellipsises a value too long for its card", () => { - const { container } = render( - , - ); - expect(truncationOf(container)).toEqual(TRUNCATION); + const own = declarationsFor((s) => s === ".uic-stat-card__value"); + for (const declaration of TRUNCATION) expect(own).toContain(declaration); }); // `prominent` enlarges the value, so it reaches the card edge sooner than @@ -174,10 +174,15 @@ describe("StatCard", () => { it.each(["prominent", "compact"] as const)( "keeps the truncation under emphasis=%s", (emphasis) => { - const { container } = render( - , + const overrides = declarationsFor( + (s) => + s.includes(`.uic-stat-card--${emphasis}`) && + s.includes(".uic-stat-card__value"), ); - expect(truncationOf(container)).toEqual(TRUNCATION); + expect(overrides.length).toBeGreaterThan(0); + for (const declaration of overrides) { + expect(declaration).toMatch(/^font-size:/); + } }, ); }); @@ -192,7 +197,9 @@ describe("StatCard", () => { it("omits the wrapper entirely when no sparkline is passed", () => { const { container } = render(); - expect(container.querySelector(".stat-card__value-line")).not.toBeInTheDocument(); + expect( + container.querySelector(".uic-stat-card__value-line"), + ).not.toBeInTheDocument(); }); it("is not rendered while loading", () => { @@ -240,7 +247,7 @@ describe("StatCard", () => { }); describe('animate="digits"', () => { function digits(container: HTMLElement): string[] { - return Array.from(container.querySelectorAll(".digit-pop-in__digit")).map( + return Array.from(container.querySelectorAll(".uic-digit-pop-in__digit")).map( (digit) => digit.textContent ?? "", ); } @@ -255,8 +262,8 @@ describe("StatCard", () => { />, ); expect(digits(container)).toEqual(["9", "8", ".", "5", "%"]); - expect(container.querySelector(".stat-card__value")).toHaveClass( - "stat-card__value--digits", + expect(container.querySelector(".uic-stat-card__value")).toHaveClass( + "uic-stat-card__value--digits", ); expect(screen.getByLabelText("Rate: 98.5%")).toBeInTheDocument(); }); @@ -265,13 +272,13 @@ describe("StatCard", () => { const { container } = render( , ); - expect(container.querySelector(".digit-pop-in")).toBeNull(); + expect(container.querySelector(".uic-digit-pop-in")).toBeNull(); expect(screen.getByText("Idle")).toBeInTheDocument(); }); it("keeps `animate` as the count-up it always was", () => { const { container } = render(); - expect(container.querySelector(".digit-pop-in")).toBeNull(); + expect(container.querySelector(".uic-digit-pop-in")).toBeNull(); }); }); @@ -290,7 +297,7 @@ describe("StatCard", () => { />, ); - const label = document.querySelector(".stat-card__label"); + const label = document.querySelector(".uic-stat-card__label"); expect(label?.querySelector("abbr")).toHaveAttribute( "title", "Time to first token", @@ -312,7 +319,7 @@ describe("StatCard", () => { it("falls back to the label text when no node is given", () => { render(); - expect(screen.getByText("TTFT p50")).toHaveClass("stat-card__label"); + expect(screen.getByText("TTFT p50")).toHaveClass("uic-stat-card__label"); }); }); }); diff --git a/src/components/StatCard/StatCard.tsx b/src/components/StatCard/StatCard.tsx index 1ba35ce..24c50e1 100644 --- a/src/components/StatCard/StatCard.tsx +++ b/src/components/StatCard/StatCard.tsx @@ -1,21 +1,20 @@ /** - * StatCard Component + * StatCard * - * Compact metric card for dashboards. Renders a label, a large numeric - * value (with optional unit/suffix), and optional icon, hint, trend, or - * accent tone. The label can be a node (`labelNode`) while the accessible name - * stays the plain `label` string. Wraps `BaseCard` so it inherits hover/focus/click affordances - * and design tokens consistently with the rest of the common library. - * - * Designed for cross-page reuse: Squad dashboard, Statistics page, Cowork - * dashboard, etc. + * Compact metric card for dashboards: a label, a large value with an optional + * suffix, and optional icon, hint, trend, sparkline or accent tone. Built on + * Astryx `Card` (or `ClickableCard` when it has an `onClick`), `Text` and + * `Skeleton`. The label can be a node (`labelNode`) while the accessible name + * stays the plain `label` string. */ +import { memo, useEffect, useRef, useState, type JSX, type ReactNode } from "react"; +import { Card } from "@astryxdesign/core/Card"; +import { ClickableCard } from "@astryxdesign/core/ClickableCard"; +import { Skeleton } from "@astryxdesign/core/Skeleton"; +import { Text } from "@astryxdesign/core/Text"; -import { memo, useEffect, useRef, useState, type ReactNode, type JSX } from "react"; import { usePrefersReducedMotion } from "../../hooks/usePrefersReducedMotion"; -import { BaseCard } from "../BaseCard"; import { DigitPopIn } from "../DigitPopIn"; -import { Skeleton } from "../Skeleton"; import "./StatCard.css"; export type StatCardEmphasis = "compact" | "default" | "prominent"; @@ -41,32 +40,31 @@ export interface StatCardTrend { } export interface StatCardProps { - /** Short label rendered above the value (UPPERCASE styled) */ + /** Short label rendered above the value (uppercase) */ label: string; /** * Rendered in place of `label` when the label is not plain text: a glossary * term carrying its definition, a unit badge, an info affordance. * - * `label` stays required and stays the accessible name, so the name is a - * string the consumer wrote rather than whatever text happens to fall out of - * a node. Pass the same words in both. + * `label` stays required and stays the accessible name. Pass the same words + * in both. */ labelNode?: ReactNode; - /** Primary metric value (number is coerced to localised string) */ + /** Primary metric value (a number is formatted with `toLocaleString`) */ value: string | number; /** Optional suffix appended to the value (e.g. "/ 4", "GB") */ valueSuffix?: string; - /** Optional helper line under the value (e.g. context, secondary metric) */ + /** Optional helper line under the value */ hint?: ReactNode; /** Optional leading icon node, rendered inside an accent badge */ icon?: ReactNode; - /** Visual tone, mirrors Badge variants. Default: "default" */ + /** Visual tone. Default: "default" */ tone?: StatCardTone; /** Optional trend indicator under the value */ trend?: StatCardTrend; - /** Click handler. Renders the card as keyboard-accessible button. */ + /** Click handler. Renders the card as Astryx `ClickableCard`. */ onClick?: () => void; - /** Render skeletons instead of value/label content while loading */ + /** Render skeletons instead of the value while loading */ loading?: boolean; /** Optional aria label override (defaults to label + value) */ ariaLabel?: string; @@ -82,16 +80,11 @@ export interface StatCardProps { /** * Animate a numeric value on mount and on change. `true` or `"count"` * counts up to it; `"digits"` pops each character of the formatted value - * in, staggered, rising into place from a light blur. Off by default so - * existing consumers render the final value immediately; opt in for - * dashboard metrics. Suppressed under `prefers-reduced-motion`, and ignored - * for string values and while loading. + * in. Suppressed under `prefers-reduced-motion`, and ignored for string + * values and while loading. */ animate?: boolean | StatCardAnimation; - /** - * Optional trailing visual on the value line (e.g. a sparkline). Kept as a - * slot so the card does not depend on any particular chart component. - */ + /** Optional trailing visual on the value line (e.g. a sparkline) */ sparkline?: ReactNode; /** Visual weight. Default: "default" */ emphasis?: StatCardEmphasis; @@ -149,6 +142,13 @@ const TREND_GLYPH: Record = { flat: "•", }; +/** Card padding per emphasis, on Astryx's spacing scale. */ +const PADDING: Record = { + compact: 2, + default: 4, + prominent: 6, +}; + function formatValue( value: string | number, format?: (value: number) => string, @@ -197,8 +197,8 @@ function StatCardComponent({ displayValue ); const valueClass = shouldPopDigits - ? "stat-card__value stat-card__value--digits" - : "stat-card__value"; + ? "uic-stat-card__value uic-stat-card__value--digits" + : "uic-stat-card__value"; // The aria-label always describes the settled value, never an in-flight // animation frame, so assistive tech is not read a counting-up number. @@ -207,59 +207,53 @@ function StatCardComponent({ (loading ? label : `${label}: ${formatValue(value, format)}${valueSuffix ?? ""}`); const rootClass = [ - "stat-card", - "corner-accent", - `stat-card--tone-${tone}`, - emphasis !== "default" ? `stat-card--${emphasis}` : "", + "uic-stat-card", + `uic-stat-card--tone-${tone}`, + emphasis !== "default" ? `uic-stat-card--${emphasis}` : "", className, ] .filter(Boolean) .join(" "); - return ( - -
- {labelNode ?? label} + const valueRow = ( + + {valueContent} + {valueSuffix && ( + {valueSuffix} + )} + + ); + + const body = ( + <> +
+ + {labelNode ?? label} + {icon && ( -
-
+
{loading ? ( ) : sparkline ? ( // Only wrap when a sparkline is present, so cards without one keep - // the exact DOM they had before the slot existed. - - - {valueContent} - {valueSuffix && ( - {valueSuffix} - )} - - {sparkline} + // the value row as a direct child of the body. + + {valueRow} + {sparkline} ) : ( - - {valueContent} - {valueSuffix && ( - {valueSuffix} - )} - + valueRow )} {trend && !loading && ( @@ -269,11 +263,37 @@ function StatCardComponent({
{hint !== undefined && hint !== null && ( -
- {loading ? : hint} +
+ {loading ? : hint}
)} - + + ); + + if (onClick) { + return ( + onClick()} + padding={PADDING[emphasis]} + className={rootClass} + data-testid={testId} + > + {body} + + ); + } + + return ( + + {body} + ); } diff --git a/src/i18n/catalog.ts b/src/i18n/catalog.ts index 041dccb..7b3661c 100644 --- a/src/i18n/catalog.ts +++ b/src/i18n/catalog.ts @@ -11,15 +11,18 @@ * Adding strings for a component: * * // src/components/Modal/Modal.messages.ts - * export const modalMessages = defineMessages({ * "uic.Modal.close": { defaultMessage: "Close", description: "..." }, * }); * - * then spread `modalMessages` into `uiCommonCatalog` below. Keep message * files free of React and CSS imports: the build reads this catalog. */ import type { Catalog, MessageEntry } from "@astryxdesign/core/i18n"; +// These modules import `defineMessages` back from here. The cycle is safe: +// it is a function declaration, so it is bound before either module runs. +import { pageHeaderMessages } from "../components/PageHeader/PageHeader.messages"; +import { skeletonMessages } from "../components/Skeleton/Skeleton.messages"; + /** A catalog key: `uic..`. */ export type UicMessageKey = `uic.${string}.${string}`; @@ -30,4 +33,7 @@ export function defineMessages block.replace(/[^\n]/g, " ")); -} - -function cssFiles(dir: string): string[] { - const out: string[] = []; - for (const entry of readdirSync(dir, { withFileTypes: true })) { - const path = join(dir, entry.name); - if (entry.isDirectory()) out.push(...cssFiles(path)); - else if (entry.name.endsWith(".css")) out.push(path); - } - return out; -} - -const DECLARED = new Set( - [...BASE_CSS.matchAll(/(--token-[A-Za-z0-9]+)\s*:/g)].map((m) => m[1] ?? ""), -); - -describe("token contract", () => { - it("declares a substantial token set", () => { - expect(DECLARED.size).toBeGreaterThan(100); - }); - - it("every token a component reads is declared in styles/base.css", () => { - const orphans: string[] = []; - for (const file of cssFiles(COMPONENTS_DIR)) { - const source = withoutComments(readFileSync(file, "utf8")); - for (const match of source.matchAll(/var\(\s*(--token-[A-Za-z0-9]+)/g)) { - const name = match[1] ?? ""; - if (!DECLARED.has(name)) orphans.push(`${file}: ${name}`); - } - } - expect( - [...new Set(orphans)].sort(), - "a token declared only in a product's theme file renders correctly there " + - "and nowhere else (see CONTRIBUTING.md)", - ).toEqual([]); - }); -}); - -describe("StatCard value sizing", () => { - const STAT_CARD_CSS = withoutComments( - readFileSync(join(COMPONENTS_DIR, "StatCard", "StatCard.css"), "utf8"), - ); - - /** The declarations of one rule, by exact selector. */ - function ruleBody(selector: string): string { - const match = [...STAT_CARD_CSS.matchAll(/([^{}]+)\{([^{}]*)\}/g)].find( - (m) => (m[1] ?? "").trim() === selector, - ); - if (match === undefined) throw new Error(`no rule for "${selector}"`); - return match[2] ?? ""; - } - - // A stat value is a numeric display. Sizing it from a heading token hands - // control of the number to the consumer's heading ladder, which is a - // decision about prose, not about data. - it("sizes the value from the numeric ladder, not a heading token", () => { - const body = ruleBody(".stat-card__value"); - expect(body).toMatch(/font-size:\s*var\(--token-fontSizeXXL/); - expect(body).not.toMatch(/font-size:\s*var\(--token-fontSizeHeading/); - }); - - it("gives the token the fallback base.css declares for it", () => { - const declared = /--token-fontSizeXXL:\s*([^;}]+)/.exec(BASE_CSS)?.[1]?.trim(); - expect(declared).toBe("1.5rem"); - expect(ruleBody(".stat-card__value")).toMatch( - /var\(--token-fontSizeXXL,\s*1\.5rem\)/, - ); - }); -}); From 20dfb96fea5da06afa67339508a5f52369df68ef Mon Sep 17 00:00:00 2001 From: Jongeun Date: Fri, 25 Sep 2026 16:35:14 +0000 Subject: [PATCH 10/87] feat!: remove the 0.1 customs that Astryx replaces Badge, BaseCard, Button, DataTable, Drawer, EmptyState, ProgressBar, Select, the base Skeleton, StatusTag, Tabs and Tooltip are removed: source, tests, styles and their ./components/ subpaths. Each has an Astryx replacement reached through ui-common (Badge/Token, Card/ClickableCard, Button/IconButton, Table, lab Drawer, EmptyState, ProgressBar, Selector, Skeleton, StatusDot, TabList, Tooltip). The Skeleton composites stay. migration/0.1-to-0.2.json records, for the upgrade tool, every removed import with its replacement, the prop renames and value maps a codemod can apply, what needs manual review, the kept components' class renames, and the stylesheet entry points. src/migrationMap.test.ts keeps it honest against the package. The component style checks move to src/components/componentStyles.test.ts: one @layer ui-common per sheet, uic- classes, Astryx tokens only, no colour literal, no focus rule. The focus-contrast and scrollbar tests keep checking the deprecated styles/base.css and styles/themes sheets, which stay until 0.3 because existing imports of them must keep resolving. BREAKING CHANGE: the twelve components and their @lablup/ui-common/components/ subpaths are gone. 0.2.0-alpha.0 had announced their removal for 0.3. --- exports.customs.json | 61 +- migration/0.1-to-0.2.json | 626 +++++++++ src/components/Badge/Badge.css | 75 -- src/components/Badge/Badge.test.tsx | 95 -- src/components/Badge/Badge.tsx | 28 - src/components/Badge/index.ts | 2 - src/components/BaseCard/BaseCard.css | 202 --- src/components/BaseCard/BaseCard.test.tsx | 312 ----- src/components/BaseCard/BaseCard.tsx | 155 --- src/components/BaseCard/index.ts | 2 - src/components/Button/Button.css | 585 --------- src/components/Button/Button.test.tsx | 574 --------- src/components/Button/Button.tsx | 196 --- src/components/Button/index.ts | 6 - src/components/DataTable/DataTable.css | 267 ---- .../DataTable/DataTable.styles.test.ts | 109 -- src/components/DataTable/DataTable.test.tsx | 1140 ----------------- src/components/DataTable/DataTable.tsx | 941 -------------- src/components/DataTable/index.ts | 7 - src/components/Drawer/Drawer.css | 266 ---- src/components/Drawer/Drawer.test.tsx | 558 -------- src/components/Drawer/Drawer.tsx | 276 ---- src/components/Drawer/index.ts | 2 - src/components/EmptyState/EmptyState.css | 341 ----- .../EmptyState/EmptyState.example.tsx | 193 --- src/components/EmptyState/EmptyState.test.tsx | 227 ---- src/components/EmptyState/EmptyState.tsx | 152 --- src/components/EmptyState/index.ts | 15 - src/components/ProgressBar/ProgressBar.css | 106 -- .../ProgressBar/ProgressBar.test.tsx | 192 --- src/components/ProgressBar/ProgressBar.tsx | 92 -- src/components/ProgressBar/index.ts | 6 - src/components/Select/Select.css | 356 ----- src/components/Select/Select.test.tsx | 234 ---- src/components/Select/Select.tsx | 519 -------- src/components/Select/index.ts | 2 - src/components/Skeleton/Skeleton.css | 63 - src/components/Skeleton/Skeleton.test.tsx | 176 --- src/components/Skeleton/Skeleton.tsx | 89 -- src/components/Skeleton/index.ts | 11 +- src/components/StatusTag/StatusTag.css | 46 - src/components/StatusTag/StatusTag.test.tsx | 34 - src/components/StatusTag/StatusTag.tsx | 125 -- src/components/StatusTag/index.ts | 2 - src/components/Tabs/Tabs.css | 763 ----------- src/components/Tabs/Tabs.test.tsx | 777 ----------- src/components/Tabs/Tabs.tsx | 889 ------------- src/components/Tabs/index.ts | 8 - src/components/Tooltip/Tooltip.css | 50 - src/components/Tooltip/Tooltip.css.test.ts | 34 - src/components/Tooltip/Tooltip.test.tsx | 528 -------- src/components/Tooltip/Tooltip.tsx | 306 ----- src/components/Tooltip/index.ts | 2 - src/components/componentStyles.test.ts | 107 ++ src/exports.test.ts | 10 +- src/icons/AlertCircleIcon.tsx | 34 - src/index.ts | 65 +- src/migrationMap.test.ts | 109 ++ src/styles/focusIndicator.test.ts | 124 +- src/styles/scrollbar.test.ts | 29 +- src/test/setup.ts | 5 +- 61 files changed, 862 insertions(+), 12444 deletions(-) create mode 100644 migration/0.1-to-0.2.json delete mode 100644 src/components/Badge/Badge.css delete mode 100644 src/components/Badge/Badge.test.tsx delete mode 100644 src/components/Badge/Badge.tsx delete mode 100644 src/components/Badge/index.ts delete mode 100644 src/components/BaseCard/BaseCard.css delete mode 100644 src/components/BaseCard/BaseCard.test.tsx delete mode 100644 src/components/BaseCard/BaseCard.tsx delete mode 100644 src/components/BaseCard/index.ts delete mode 100644 src/components/Button/Button.css delete mode 100644 src/components/Button/Button.test.tsx delete mode 100644 src/components/Button/Button.tsx delete mode 100644 src/components/Button/index.ts delete mode 100644 src/components/DataTable/DataTable.css delete mode 100644 src/components/DataTable/DataTable.styles.test.ts delete mode 100644 src/components/DataTable/DataTable.test.tsx delete mode 100644 src/components/DataTable/DataTable.tsx delete mode 100644 src/components/DataTable/index.ts delete mode 100644 src/components/Drawer/Drawer.css delete mode 100644 src/components/Drawer/Drawer.test.tsx delete mode 100644 src/components/Drawer/Drawer.tsx delete mode 100644 src/components/Drawer/index.ts delete mode 100644 src/components/EmptyState/EmptyState.css delete mode 100644 src/components/EmptyState/EmptyState.example.tsx delete mode 100644 src/components/EmptyState/EmptyState.test.tsx delete mode 100644 src/components/EmptyState/EmptyState.tsx delete mode 100644 src/components/EmptyState/index.ts delete mode 100644 src/components/ProgressBar/ProgressBar.css delete mode 100644 src/components/ProgressBar/ProgressBar.test.tsx delete mode 100644 src/components/ProgressBar/ProgressBar.tsx delete mode 100644 src/components/ProgressBar/index.ts delete mode 100644 src/components/Select/Select.css delete mode 100644 src/components/Select/Select.test.tsx delete mode 100644 src/components/Select/Select.tsx delete mode 100644 src/components/Select/index.ts delete mode 100644 src/components/Skeleton/Skeleton.css delete mode 100644 src/components/Skeleton/Skeleton.test.tsx delete mode 100644 src/components/Skeleton/Skeleton.tsx delete mode 100644 src/components/StatusTag/StatusTag.css delete mode 100644 src/components/StatusTag/StatusTag.test.tsx delete mode 100644 src/components/StatusTag/StatusTag.tsx delete mode 100644 src/components/StatusTag/index.ts delete mode 100644 src/components/Tabs/Tabs.css delete mode 100644 src/components/Tabs/Tabs.test.tsx delete mode 100644 src/components/Tabs/Tabs.tsx delete mode 100644 src/components/Tabs/index.ts delete mode 100644 src/components/Tooltip/Tooltip.css delete mode 100644 src/components/Tooltip/Tooltip.css.test.ts delete mode 100644 src/components/Tooltip/Tooltip.test.tsx delete mode 100644 src/components/Tooltip/Tooltip.tsx delete mode 100644 src/components/Tooltip/index.ts create mode 100644 src/components/componentStyles.test.ts delete mode 100644 src/icons/AlertCircleIcon.tsx create mode 100644 src/migrationMap.test.ts diff --git a/exports.customs.json b/exports.customs.json index 51b819a..841f7d0 100644 --- a/exports.customs.json +++ b/exports.customs.json @@ -1,69 +1,10 @@ [ - { - "name": "BaseCard", - "source": "components/BaseCard/index.ts", - "legacy": { "replacedBy": "Card" } - }, - { - "name": "Badge", - "source": "components/Badge/index.ts", - "legacy": { "replacedBy": "Badge" } - }, - { - "name": "Button", - "source": "components/Button/index.ts", - "legacy": { "replacedBy": "Button" } - }, - { - "name": "DataTable", - "source": "components/DataTable/index.ts", - "legacy": { "replacedBy": "Table" } - }, { "name": "DigitPopIn", "source": "components/DigitPopIn/index.ts" }, - { - "name": "Drawer", - "source": "components/Drawer/index.ts", - "legacy": { "replacedBy": "lab/Drawer" } - }, - { - "name": "EmptyState", - "source": "components/EmptyState/index.ts", - "legacy": { "replacedBy": "EmptyState" } - }, { "name": "ErrorState", "source": "components/ErrorState/index.ts" }, { "name": "PageHeader", "source": "components/PageHeader/index.ts" }, { "name": "PageLayout", "source": "components/PageLayout/index.ts" }, - { - "name": "ProgressBar", - "source": "components/ProgressBar/index.ts", - "legacy": { "replacedBy": "ProgressBar" } - }, - { - "name": "Skeleton", - "source": "components/Skeleton/index.ts", - "legacy": { "replacedBy": "Skeleton", "names": ["Skeleton", "SkeletonProps"] } - }, + { "name": "Skeleton composites", "source": "components/Skeleton/index.ts" }, { "name": "SmoothHeight", "source": "components/SmoothHeight/index.ts" }, - { - "name": "Select", - "source": "components/Select/index.ts", - "legacy": { "replacedBy": "Selector" } - }, { "name": "StatCard", "source": "components/StatCard/index.ts" }, - { - "name": "StatusTag", - "source": "components/StatusTag/index.ts", - "legacy": { "replacedBy": "StatusDot" } - }, - { - "name": "Tabs", - "source": "components/Tabs/index.ts", - "legacy": { "replacedBy": "TabList" } - }, - { - "name": "Tooltip", - "source": "components/Tooltip/index.ts", - "legacy": { "replacedBy": "Tooltip" } - }, { "name": "usePrefersReducedMotion", "source": "hooks/index.ts" } ] diff --git a/migration/0.1-to-0.2.json b/migration/0.1-to-0.2.json new file mode 100644 index 0000000..9de201c --- /dev/null +++ b/migration/0.1-to-0.2.json @@ -0,0 +1,626 @@ +{ + "$comment": "Machine-readable map of what 0.2 removed, renamed or restyled relative to 0.1, for `ui-common upgrade --from 0.1`. Each removedComponents entry names every 0.1 import that reached the component, its Astryx replacement, and the prop changes: `propRenames` a codemod can apply, `valueMaps` for renamed enum values, and `manual` for what it cannot prove (leave a `// TODO(ui-common-upgrade):` marker and a report entry). In 0.2 the root barrel name of a removed component that Astryx also exports (Badge, Button, EmptyState, ProgressBar, Skeleton, Tooltip) already resolves to Astryx's component, so a root import keeps its specifier and only needs the prop changes.", + "from": "0.1", + "to": "0.2", + "removedComponents": [ + { + "name": "Badge", + "oldImports": [ + { "specifier": "@lablup/ui-common", "names": ["Badge", "BadgeProps"] }, + { + "specifier": "@lablup/ui-common/components/Badge", + "names": ["Badge", "BadgeProps"] + } + ], + "replacement": { + "specifier": "@lablup/ui-common/Badge", + "names": { "Badge": "Badge", "BadgeProps": "BadgeProps" } + }, + "alternatives": [ + { + "specifier": "@lablup/ui-common/Token", + "names": { "Badge": "Token", "BadgeProps": "TokenProps" }, + "when": "the badge is a removable or clickable chip rather than a status label" + } + ], + "propRenames": [ + { "from": "children", "to": "label", "kind": "children-to-prop" } + ], + "valueMaps": { + "variant": { + "default": "neutral", + "success": "success", + "warning": "warning", + "danger": "error", + "info": "info", + "primary": null + } + }, + "manual": [ + "variant=\"primary\" has no semantic Badge variant; pick `info` or a colour variant such as `orange`.", + "size (small | medium) has no Badge counterpart; drop it.", + "The .badge / .badge--* classes are gone; Astryx's stable class is .astryx-badge." + ] + }, + { + "name": "BaseCard", + "oldImports": [ + { + "specifier": "@lablup/ui-common", + "names": ["BaseCard", "BaseCardProps", "BaseCardVariant", "BaseCardState"] + }, + { + "specifier": "@lablup/ui-common/components/BaseCard", + "names": [ + "BaseCard", + "BaseCardProps", + "BaseCardVariant", + "BaseCardState", + "default" + ] + } + ], + "replacement": { + "specifier": "@lablup/ui-common/Card", + "names": { "BaseCard": "Card", "BaseCardProps": "CardProps" } + }, + "alternatives": [ + { + "specifier": "@lablup/ui-common/ClickableCard", + "names": { + "BaseCard": "ClickableCard", + "BaseCardProps": "ClickableCardProps" + }, + "when": "the card has onClick or clickable; ClickableCard requires a `label` (take it from ariaLabel)" + } + ], + "propRenames": [ + { "from": "testId", "to": "data-testid" }, + { "from": "ariaLabel", "to": "aria-label" }, + { "from": "ariaChecked", "to": "aria-checked" } + ], + "valueMaps": {}, + "manual": [ + "variant (default | installed | available) maps to Card variant by intent: default -> \"muted\", installed -> \"default\", available -> \"muted\".", + "state (loading | active | disabled | warning) has no Card counterpart; express it in the content, or use ClickableCard isDisabled for disabled.", + "direction=\"row\" has no Card counterpart; wrap the children in an HStack.", + "hoverable, clickable, onKeyDown, role and tabIndex: ClickableCard owns hover, focus and keyboard activation.", + "The .base-card classes and the --corner-accent-color property are gone." + ] + }, + { + "name": "Button", + "oldImports": [ + { "specifier": "@lablup/ui-common", "names": ["Button", "ButtonProps"] }, + { + "specifier": "@lablup/ui-common/components/Button", + "names": ["Button", "ButtonProps"] + } + ], + "replacement": { + "specifier": "@lablup/ui-common/Button", + "names": { "Button": "Button", "ButtonProps": "ButtonProps" } + }, + "alternatives": [ + { + "specifier": "@lablup/ui-common/IconButton", + "names": { "Button": "IconButton", "ButtonProps": "IconButtonProps" }, + "when": "iconOnly is set; the accessible name moves from ariaLabel to label" + } + ], + "propRenames": [ + { + "from": "children", + "to": "label", + "kind": "children-to-prop", + "onlyWhen": "children is a string literal or expression of type string" + }, + { "from": "disabled", "to": "isDisabled" }, + { "from": "loading", "to": "isLoading" }, + { "from": "iconOnly", "to": "isIconOnly" }, + { "from": "ariaLabel", "to": "aria-label" }, + { "from": "fullWidth", "to": "width", "value": "100%" } + ], + "valueMaps": { + "variant": { + "primary": "primary", + "secondary": "secondary", + "danger": "destructive", + "ghost": "ghost", + "text": "ghost", + "outline": "secondary", + "success": null + }, + "size": { "xsmall": "sm", "small": "sm", "medium": "md", "large": "lg" } + }, + "manual": [ + "`label` is required. A non-string child needs `label` for the accessible name and the node as children.", + "variant=\"success\" has no Button variant; use primary.", + "iconPosition=\"right\" becomes `endContent` (an Icon or Badge element only).", + "shape=\"circle\", inline and active have no counterpart.", + "The .button / .button--* classes are gone; Astryx's stable class is .astryx-button." + ] + }, + { + "name": "DataTable", + "oldImports": [ + { + "specifier": "@lablup/ui-common", + "names": [ + "DataTable", + "DataTableColumn", + "DataTableProps", + "DataTablePersistedState", + "SortDirection" + ] + }, + { + "specifier": "@lablup/ui-common/components/DataTable", + "names": [ + "DataTable", + "DataTableColumn", + "DataTableProps", + "DataTablePersistedState", + "SortDirection", + "default" + ] + } + ], + "replacement": { + "specifier": "@lablup/ui-common/Table", + "names": { + "DataTable": "Table", + "DataTableProps": "TableProps", + "DataTableColumn": "TableColumn" + } + }, + "alternatives": [], + "propRenames": [ + { "from": "rows", "to": "data" }, + { + "from": "getRowKey", + "to": "idKey", + "kind": "signature-change", + "note": "idKey takes (item) or a property name; the index argument is gone" + } + ], + "columnRenames": [ + { "from": "id", "to": "key" }, + { "from": "render", "to": "renderCell", "kind": "signature-change" }, + { "from": "initialWidth", "to": "width" } + ], + "valueMaps": {}, + "manual": [ + "Sorting, column resizing, column visibility and persisted column state are Table plugins (`plugins`), not props.", + "loading, loadingState and emptyState: render them around the Table.", + "onRowClick, isRowClickable and rowClassName: use the row-interaction plugin or children mode.", + "The .data-table classes are gone." + ] + }, + { + "name": "Drawer", + "oldImports": [ + { "specifier": "@lablup/ui-common", "names": ["Drawer", "DrawerProps"] }, + { + "specifier": "@lablup/ui-common/components/Drawer", + "names": ["Drawer", "DrawerProps"] + } + ], + "replacement": { + "specifier": "@lablup/ui-common/lab", + "names": { "Drawer": "Drawer", "DrawerProps": "DrawerProps" } + }, + "alternatives": [], + "requiresPackages": { "@astryxdesign/lab": "0.6.2-canary.c9fb1ad" }, + "propRenames": [ + { + "from": "onClose", + "to": "onOpenChange", + "kind": "signature-change", + "note": "called with false; wrap as (open) => { if (!open) onClose(); }" + }, + { "from": "title", "to": "label", "onlyWhen": "title is a string" } + ], + "valueMaps": { "width": { "narrow": 400, "medium": 520, "wide": 900 } }, + "manual": [ + "lab Drawer renders no header: render the title, subtitle and footer inside children.", + "closeLabel, ariaLabelledBy and ariaDescribedBy have no counterpart.", + "preventDismiss and onDismissAttempt: decline the close in onOpenChange.", + "The .drawer classes are gone." + ] + }, + { + "name": "EmptyState", + "oldImports": [ + { + "specifier": "@lablup/ui-common", + "names": [ + "EmptyState", + "EmptyStateProps", + "EmptyStateAction", + "EmptyStateSecondaryAction" + ] + }, + { + "specifier": "@lablup/ui-common/components/EmptyState", + "names": [ + "EmptyState", + "EmptyStateProps", + "EmptyStateAction", + "EmptyStateSecondaryAction" + ] + } + ], + "replacement": { + "specifier": "@lablup/ui-common/EmptyState", + "names": { "EmptyState": "EmptyState", "EmptyStateProps": "EmptyStateProps" } + }, + "alternatives": [], + "propRenames": [{ "from": "illustration", "to": "icon" }], + "valueMaps": {}, + "manual": [ + "primaryAction and secondaryAction become `actions`, a node: , - ); - - const iconSpan = container.querySelector(".button__icon"); - expect(iconSpan).toBeInTheDocument(); - expect(iconSpan).toHaveAttribute("aria-hidden", "true"); - expect(screen.getByTestId("test-icon")).toBeInTheDocument(); - }); - - it("renders icon on the right when iconPosition is right", () => { - const { container } = render( - , - ); - - const icons = container.querySelectorAll(".button__icon"); - expect(icons).toHaveLength(1); - expect(screen.getByTestId("right-icon")).toBeInTheDocument(); - - // Verify icon appears after text in DOM order - const button = container.querySelector(".button")!; - const children = Array.from(button.children); - const textIndex = children.findIndex((el) => - el.classList.contains("button__text"), - ); - const iconIndex = children.findIndex((el) => - el.classList.contains("button__icon"), - ); - expect(iconIndex).toBeGreaterThan(textIndex); - }); - - it("hides icon when loading", () => { - const { container } = render( - , - ); - - const iconSpan = container.querySelector(".button__icon"); - expect(iconSpan).not.toBeInTheDocument(); - }); - }); - - // ============================================= - // Icon-only mode - // ============================================= - - describe("iconOnly", () => { - it("applies button--icon-only class", () => { - const { container } = render( - , - ); - - const iconSpan = container.querySelector(".button__icon"); - expect(iconSpan).toBeInTheDocument(); - expect(screen.getByTestId("child-icon")).toBeInTheDocument(); - }); - - it("generates aria-label from string children when iconOnly", () => { - render(); - - const button = screen.getByRole("button"); - expect(button).toHaveAttribute("aria-label", "Close"); - }); - - it("prefers explicit ariaLabel over children string", () => { - render( - , - ); - - const button = screen.getByRole("button"); - expect(button).toHaveAttribute("aria-label", "Explicit label"); - }); - }); - - // ============================================= - // Loading state - // ============================================= - - describe("loading", () => { - it("applies button--loading class", () => { - const { container } = render(); - - expect(container.querySelector(".button--loading")).toBeInTheDocument(); - }); - - it("renders loading spinner", () => { - const { container } = render(); - - const spinner = container.querySelector(".button__loading-spinner"); - expect(spinner).toBeInTheDocument(); - expect(spinner).toHaveAttribute("aria-hidden", "true"); - }); - - it("disables the button when loading", () => { - render(); - - expect(screen.getByRole("button")).toBeDisabled(); - }); - - it("does not call onClick when loading", async () => { - const user = userEvent.setup({ pointerEventsCheck: 0 }); - const handleClick = vi.fn(); - - render( - , - ); - - // Button is disabled so click will not fire the handler - await user.click(screen.getByRole("button")); - expect(handleClick).not.toHaveBeenCalled(); - }); - - it("does not show loading spinner when not loading", () => { - const { container } = render(); - - expect( - container.querySelector(".button__loading-spinner"), - ).not.toBeInTheDocument(); - }); - }); - - // ============================================= - // Active state - // ============================================= - - describe("active", () => { - it("applies button--active class when active", () => { - const { container } = render(); - - expect(container.querySelector(".button--active")).toBeInTheDocument(); - }); - - it("does not apply button--active class by default", () => { - const { container } = render(); - - expect(container.querySelector(".button--active")).not.toBeInTheDocument(); - }); - }); - - // ============================================= - // Disabled state - // ============================================= - - describe("disabled", () => { - it("disables the button", () => { - render(); - - expect(screen.getByRole("button")).toBeDisabled(); - }); - - it("does not call onClick when disabled", async () => { - const user = userEvent.setup(); - const handleClick = vi.fn(); - - render( - , - ); - - await user.click(screen.getByRole("button")); - expect(handleClick).not.toHaveBeenCalled(); - }); - }); - - // ============================================= - // Event handlers - // ============================================= - - describe("event handlers", () => { - it("calls onClick when clicked", async () => { - const user = userEvent.setup(); - const handleClick = vi.fn(); - - render(); - - await user.click(screen.getByRole("button")); - expect(handleClick).toHaveBeenCalledTimes(1); - }); - - it("calls onDoubleClick on double click", async () => { - const user = userEvent.setup(); - const handleDoubleClick = vi.fn(); - - render(); - - await user.dblClick(screen.getByRole("button")); - expect(handleDoubleClick).toHaveBeenCalledTimes(1); - }); - - it("calls onKeyDown on key press", async () => { - const user = userEvent.setup(); - const handleKeyDown = vi.fn(); - - render(); - - const button = screen.getByRole("button"); - button.focus(); - await user.keyboard("{Enter}"); - expect(handleKeyDown).toHaveBeenCalled(); - }); - - it("calls onMouseEnter on hover", async () => { - const user = userEvent.setup(); - const handleMouseEnter = vi.fn(); - - render(); - - await user.hover(screen.getByRole("button")); - expect(handleMouseEnter).toHaveBeenCalledTimes(1); - }); - - it("calls onMouseDown on mouse down", async () => { - const user = userEvent.setup(); - const handleMouseDown = vi.fn(); - - render(); - - const button = screen.getByRole("button"); - await user.pointer({ keys: "[MouseLeft>]", target: button }); - expect(handleMouseDown).toHaveBeenCalled(); - }); - }); - - // ============================================= - // Accessibility attributes - // ============================================= - - describe("accessibility", () => { - it("passes aria-label via ariaLabel prop", () => { - render(); - - expect(screen.getByRole("button")).toHaveAttribute("aria-label", "Custom label"); - }); - - it("passes title attribute", () => { - render(); - - expect(screen.getByRole("button")).toHaveAttribute("title", "Tooltip text"); - }); - - it("passes role attribute", () => { - render(); - - expect(screen.getByRole("tab")).toBeInTheDocument(); - }); - - it("passes aria-pressed", () => { - render(); - - expect(screen.getByRole("button")).toHaveAttribute("aria-pressed", "true"); - }); - - it("passes aria-selected", () => { - render(); - - expect(screen.getByRole("button")).toHaveAttribute("aria-selected", "true"); - }); - - it("passes aria-expanded", () => { - render(); - - expect(screen.getByRole("button")).toHaveAttribute("aria-expanded", "true"); - }); - - it("passes aria-haspopup", () => { - render(); - - expect(screen.getByRole("button")).toHaveAttribute("aria-haspopup", "menu"); - }); - - it("passes aria-controls", () => { - render(); - - expect(screen.getByRole("button")).toHaveAttribute("aria-controls", "panel-1"); - }); - - it("passes aria-describedby", () => { - render(); - - expect(screen.getByRole("button")).toHaveAttribute( - "aria-describedby", - "help-text", - ); - }); - - it("passes aria-busy", () => { - render(); - - expect(screen.getByRole("button")).toHaveAttribute("aria-busy", "true"); - }); - - it("passes tabIndex", () => { - render(); - - expect(screen.getByRole("button", { hidden: true })).toHaveAttribute( - "tabIndex", - "-1", - ); - }); - - it("has focus-visible outline support (no visual regression)", () => { - render(); - - const button = screen.getByRole("button"); - expect(button).toHaveClass("button"); - }); - }); - - // ============================================= - // Additional props - // ============================================= - - describe("additional props", () => { - it("passes id attribute", () => { - render(); - - expect(screen.getByRole("button")).toHaveAttribute("id", "my-button"); - }); - - it("passes data-testid attribute", () => { - render(); - - expect(screen.getByTestId("submit-btn")).toBeInTheDocument(); - }); - - it("passes style object", () => { - render(); - - expect(screen.getByRole("button")).toHaveStyle({ - color: "rgb(255, 0, 0)", - }); - }); - - it("appends custom className", () => { - const { container } = render(); - - const button = container.querySelector(".button.custom-class"); - expect(button).toBeInTheDocument(); - }); - - it("sets button type attribute", () => { - render(); - - expect(screen.getByRole("button")).toHaveAttribute("type", "submit"); - }); - - it("defaults type to button", () => { - render(); - - expect(screen.getByRole("button")).toHaveAttribute("type", "button"); - }); - }); - - // ============================================= - // Ref forwarding - // ============================================= - - describe("ref forwarding", () => { - it("forwards ref to the button element", () => { - const ref = createRef(); - - render(); - - expect(ref.current).toBeInstanceOf(HTMLButtonElement); - expect(ref.current!.textContent).toContain("Ref"); - }); - }); - - // ============================================= - // Class name composition - // ============================================= - - describe("class name composition", () => { - it("composes multiple classes correctly", () => { - const { container } = render( - , - ); - - const button = container.querySelector(".button")!; - expect(button).toHaveClass("button"); - expect(button).toHaveClass("button--primary"); - expect(button).toHaveClass("button--large"); - expect(button).toHaveClass("button--circle"); - expect(button).toHaveClass("button--full-width"); - expect(button).toHaveClass("button--icon-only"); - expect(button).toHaveClass("button--loading"); - expect(button).toHaveClass("button--active"); - expect(button).toHaveClass("extra"); - }); - - it("excludes falsy modifier classes", () => { - const { container } = render(); - - const button = container.querySelector(".button")!; - expect(button.className).not.toContain("button--icon-only"); - expect(button.className).not.toContain("button--loading"); - expect(button.className).not.toContain("button--active"); - expect(button.className).not.toContain("button--full-width"); - expect(button.className).not.toContain("button--circle"); - }); - }); -}); diff --git a/src/components/Button/Button.tsx b/src/components/Button/Button.tsx deleted file mode 100644 index d09f2c0..0000000 --- a/src/components/Button/Button.tsx +++ /dev/null @@ -1,196 +0,0 @@ -/** - * Button Component - * - * Shared button component that consolidates all button styles across the application. - * Supports multiple variants, sizes, shapes, icons, loading and active states. - */ - -import { forwardRef, useCallback } from "react"; -import "./Button.css"; - -export interface ButtonProps { - children?: React.ReactNode; - onClick?: (e: React.MouseEvent) => void; - onDoubleClick?: (e: React.MouseEvent) => void; - onMouseDown?: (e: React.MouseEvent) => void; - onMouseEnter?: (e: React.MouseEvent) => void; - onKeyDown?: (e: React.KeyboardEvent) => void; - type?: "button" | "submit" | "reset"; - disabled?: boolean; - - // Styling variants - variant?: - "primary" | "secondary" | "danger" | "success" | "ghost" | "text" | "outline"; - size?: "xsmall" | "small" | "medium" | "large"; - shape?: "default" | "circle"; - fullWidth?: boolean; - - // Icon support - icon?: React.ReactNode; - iconPosition?: "left" | "right"; - iconOnly?: boolean; - - /** - * Inline mode (default: false). When true, the Button acts as a thin - * styling shell over a consumer-owned layout: - * - * - `min-height` is reset to `auto` so the host CSS controls height - * - the ghost-variant hover/active translate + box-shadow are - * suppressed (no jitter) - * - children render directly inside the ` - const iconContent = icon ?? (iconOnly ? children : undefined); - const hasText = !iconOnly && children; - const showIcon = iconContent && !loading; - const showLoadingSpinner = loading; - - return ( - - ); -}); diff --git a/src/components/Button/index.ts b/src/components/Button/index.ts deleted file mode 100644 index a51ca95..0000000 --- a/src/components/Button/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -/** - * Button Component - */ - -export { Button } from "./Button"; -export type { ButtonProps } from "./Button"; diff --git a/src/components/DataTable/DataTable.css b/src/components/DataTable/DataTable.css deleted file mode 100644 index 6559b37..0000000 --- a/src/components/DataTable/DataTable.css +++ /dev/null @@ -1,267 +0,0 @@ -/** - * DataTable styles - * - * Theme-aware via design tokens. Header cells use a subtle background - * to delineate them from rows, and rows hover-highlight when they are - * clickable. - * - * Sortable column affordances (added epic #2859 / issue #2861): - * - .data-table__cell--sortable — cursor affordance + focus ring - * - .data-table__cell--sort-active — accent colour on active column - * - .data-table__sort-icon — the inline SVG sort indicator - */ - -.data-table { - position: relative; - width: 100%; - overflow-x: auto; - border: 1px solid var(--token-colorBorderSecondary, var(--token-colorBorder, #d9d9d9)); - border-radius: var(--token-borderRadiusLG, 8px); - background-color: var(--token-colorBgContainer, #ffffff); -} - -.data-table__table { - /* - * A user-resized column is an explicit layout contract. `width: 100%` - * lets the automatic table layout algorithm shrink that contract back to - * the scroll container, leaving a 600px header rendered at a much smaller - * width. Size to the content/declared columns instead, while retaining a - * full-width table when those columns fit the container. - */ - width: max-content; - min-width: 100%; - border-collapse: collapse; - table-layout: auto; -} - -.data-table__head { - position: sticky; - top: 0; - z-index: 1; - background-color: var( - --token-colorFillQuaternary, - var(--token-colorBgLayout, #f5f5f5) - ); -} - -.data-table__row { - border-bottom: 1px solid var(--token-colorBorderSecondary, transparent); -} - -.data-table__row:last-of-type { - border-bottom: none; -} - -.data-table__row--clickable { - cursor: pointer; - transition: background-color var(--token-motionDurationFast, 0.12s) ease; -} - -.data-table__row--clickable:hover { - background-color: var(--token-colorFillQuaternary, rgba(0, 0, 0, 0.04)); -} - -.data-table__row--clickable:focus-visible { - outline: var(--token-focusRingWidth, 2px) var(--token-focusRingStyle, solid) - var(--token-focusRingColor, var(--token-colorPrimary)); - /* Inset, not --token-focusRingOffset. A row sits flush against its - * neighbours, so an outward ring would be overlapped by the next row. This - * is a layout constraint of the table, not a theme preference. */ - outline-offset: -2px; -} - -.data-table__cell { - padding: var(--token-paddingSM, 0.5rem) var(--token-paddingMD, 1rem); - font-size: var(--token-fontSize, 0.875rem); - color: var(--token-colorText, #141414); - vertical-align: middle; - text-align: start; - /* - * Allow long text (model paths, agent ids) to wrap so a single long - * cell does not blow the table out horizontally. - */ - word-break: break-word; -} - -.data-table__cell--align-left { - text-align: left; -} - -.data-table__cell--align-right { - text-align: right; -} - -.data-table__cell--align-center { - text-align: center; -} - -.data-table__cell--head { - position: relative; - font-weight: 600; - font-size: var(--token-fontSizeSM, 0.75rem); - text-transform: uppercase; - letter-spacing: 0.05em; - color: var(--token-colorTextSecondary, #595959); - background-color: var( - --token-colorFillQuaternary, - var(--token-colorBgLayout, #f5f5f5) - ); - border-bottom: 1px solid - var(--token-colorBorderSecondary, var(--token-colorBorder, #d9d9d9)); - user-select: none; - /* - * Header labels are short and must never break mid-word or push the - * sort indicator onto a second line. Override the body cell's - * `word-break: break-word` so the header's min-content width stays the - * full label width — table-layout: auto then sizes the column wide - * enough for the label + inline sort icon. - */ - white-space: nowrap; - word-break: normal; - overflow-wrap: normal; -} - -.data-table__cell--resizable { - /* Reserve the whole touch target so the grip cannot sit over a label or a - * sortable header's icon. */ - padding-inline-end: calc( - var(--token-paddingMD, 1rem) + var(--token-controlHeightSM, 2rem) - ); -} - -.data-table__head-label { - display: inline-block; - vertical-align: middle; -} - -/* ---- Sortable header cells ------------------------------------------------ */ - -/** - * Sortable column headers are interactive. We lay them out with flexbox - * so the label and the sort icon sit side-by-side and the resize handle - * can still hang off the right edge. - */ -.data-table__cell--sortable { - cursor: pointer; - /* Prevent accidental text selection while clicking quickly. */ - user-select: none; -} - -.data-table__cell--sortable:hover { - background-color: var(--token-colorFillTertiary, rgba(0, 0, 0, 0.06)); -} - -.data-table__cell--sortable:focus-visible { - outline: var(--token-focusRingWidth, 2px) var(--token-focusRingStyle, solid) - var(--token-focusRingColor, var(--token-colorPrimary)); - /* Inset for the same reason as the row ring above. */ - outline-offset: -2px; -} - -/** - * When a sort is active on this column, tint the header text to signal - * the active sort key. - */ -.data-table__cell--sort-active { - color: var(--token-colorPrimary, #8b5cf6); -} - -/* ---- Sort indicator icon -------------------------------------------------- */ - -.data-table__sort-icon { - display: inline-flex; - align-items: center; - margin-inline-start: var(--token-marginXXS, 4px); - vertical-align: middle; - color: var(--token-colorTextSecondary, #595959); - flex-shrink: 0; - line-height: 0; -} - -.data-table__cell--sort-active .data-table__sort-icon { - color: var(--token-colorPrimary, #8b5cf6); -} - -.data-table__resize-handle { - position: absolute; - right: 0; - top: 0; - bottom: 0; - width: var(--token-controlHeightSM, 2rem); - cursor: col-resize; - touch-action: none; - outline: none; -} - -.data-table__resize-handle::before { - position: absolute; - top: var(--token-paddingXS, 0.25rem); - right: calc(50% - 1px); - bottom: var(--token-paddingXS, 0.25rem); - width: 2px; - content: ""; - border-radius: var(--token-borderRadiusSM, 0.25rem); - /* Text tertiary clears 3:1 against the shipped light and dark header - * surfaces. Border tokens are deliberately too quiet for a discoverable - * separator. */ - background-color: var(--token-colorTextTertiary, #737373); - opacity: 1; - transition: - background-color var(--token-motionDurationFast, 0.12s) ease, - opacity var(--token-motionDurationFast, 0.12s) ease; -} - -.data-table__resize-handle:hover::before, -.data-table__resize-handle:focus-visible::before { - background-color: var(--token-focusRingColor); - opacity: 1; -} - -.data-table__resize-handle:focus-visible { - outline: var(--token-focusRingWidth, 2px) var(--token-focusRingStyle, solid) - var(--token-focusRingColor); - outline-offset: -2px; -} - -.data-table__row--state { - /* No hover effect on placeholder rows. */ -} - -.data-table__state-cell { - padding: var(--token-paddingLG, 16px); - text-align: center; - color: var(--token-colorTextSecondary, #595959); -} - -.data-table__loading { - width: 100%; - height: 24px; - background: linear-gradient( - 90deg, - var(--token-colorFillQuaternary, #fafafa) 25%, - var(--token-colorFillTertiary, #f9fafb) 50%, - var(--token-colorFillQuaternary, #fafafa) 75% - ); - background-size: 200% 100%; - animation: data-table-shimmer 1.4s ease-in-out infinite; - border-radius: var(--token-borderRadiusSM, 0.25rem); -} - -@keyframes data-table-shimmer { - 0% { - background-position: 200% 0; - } - 100% { - background-position: -200% 0; - } -} - -@media (prefers-reduced-motion: reduce) { - .data-table__loading { - animation: none; - } - - .data-table__resize-handle::before { - transition: none; - } -} diff --git a/src/components/DataTable/DataTable.styles.test.ts b/src/components/DataTable/DataTable.styles.test.ts deleted file mode 100644 index 6ca0360..0000000 --- a/src/components/DataTable/DataTable.styles.test.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { readFileSync } from "node:fs"; -import { join } from "node:path"; -import { describe, expect, it } from "vitest"; - -const COMPONENT_CSS = readFileSync(join(__dirname, "DataTable.css"), "utf8"); -const STYLES_DIR = join(__dirname, "..", "..", "styles"); - -interface Rgba { - r: number; - g: number; - b: number; - a: number; -} - -function token(source: string, name: string): string { - const match = source.match(new RegExp(`--token-${name}:\\s*([^;]+)`)); - if (!match?.[1]) throw new Error(`Missing --token-${name}`); - return match[1].trim(); -} - -function color(value: string): Rgba { - const hex = /^#([0-9a-f]{6})$/i.exec(value); - if (hex?.[1]) { - const digits = hex[1]; - return { - r: Number.parseInt(digits.slice(0, 2), 16), - g: Number.parseInt(digits.slice(2, 4), 16), - b: Number.parseInt(digits.slice(4, 6), 16), - a: 1, - }; - } - const rgba = /^rgba?\(([^)]+)\)$/i.exec(value); - if (!rgba?.[1]) throw new Error(`Unsupported color: ${value}`); - const values = rgba[1].split(",").map((part) => Number.parseFloat(part.trim())); - const [r, g, b, a = 1] = values; - if (r === undefined || g === undefined || b === undefined) { - throw new Error(`Unsupported color: ${value}`); - } - return { r, g, b, a }; -} - -function composite(foreground: Rgba, background: Rgba): Rgba { - return { - r: Math.round(foreground.r * foreground.a + background.r * (1 - foreground.a)), - g: Math.round(foreground.g * foreground.a + background.g * (1 - foreground.a)), - b: Math.round(foreground.b * foreground.a + background.b * (1 - foreground.a)), - a: 1, - }; -} - -function luminance(value: Rgba): number { - const channel = (channelValue: number) => { - const normalized = channelValue / 255; - return normalized <= 0.04045 - ? normalized / 12.92 - : Math.pow((normalized + 0.055) / 1.055, 2.4); - }; - return ( - 0.2126 * channel(value.r) + 0.7152 * channel(value.g) + 0.0722 * channel(value.b) - ); -} - -function contrast(a: Rgba, b: Rgba): number { - const first = luminance(a); - const second = luminance(b); - return (Math.max(first, second) + 0.05) / (Math.min(first, second) + 0.05); -} - -describe("DataTable resize grip styles", () => { - it("keeps declared column widths when the table overflows its local scroll container", () => { - expect(COMPONENT_CSS).toMatch( - /\.data-table__table\s*{[\s\S]*width:\s*max-content;/, - ); - expect(COMPONENT_CSS).toMatch(/\.data-table__table\s*{[\s\S]*min-width:\s*100%;/); - expect(COMPONENT_CSS).toMatch(/\.data-table\s*{[\s\S]*overflow-x:\s*auto;/); - }); - - it("reserves a token-sized 24px-or-larger target outside header content", () => { - expect(COMPONENT_CSS).toMatch( - /\.data-table__cell--resizable\s*{[\s\S]*padding-inline-end/, - ); - expect(COMPONENT_CSS).toMatch( - /\.data-table__resize-handle\s*{[\s\S]*width:\s*var\(--token-controlHeightSM,\s*2rem\)/, - ); - const base = readFileSync(join(STYLES_DIR, "base.css"), "utf8"); - expect(token(base, "controlHeightSM")).toBe("2rem"); - }); - - it("uses semantic grip and focus tokens", () => { - expect(COMPONENT_CSS).toMatch( - /\.data-table__resize-handle::before\s*{[\s\S]*background-color:\s*var\(--token-colorTextTertiary,\s*#737373\)/, - ); - expect(COMPONENT_CSS).toMatch( - /\.data-table__resize-handle:focus-visible\s*{[\s\S]*--token-focusRingColor/, - ); - }); - - it.each([ - ["orange-light", "orange-light.css"], - ["orange-dark", "orange-dark.css"], - ])("keeps %s grip contrast at or above 3:1", (_name, file) => { - const source = readFileSync(join(STYLES_DIR, "themes", file), "utf8"); - const grip = color(token(source, "colorTextTertiary")); - const layout = color(token(source, "colorBgLayout")); - const header = composite(color(token(source, "colorFillQuaternary")), layout); - - expect(contrast(grip, header)).toBeGreaterThanOrEqual(3); - }); -}); diff --git a/src/components/DataTable/DataTable.test.tsx b/src/components/DataTable/DataTable.test.tsx deleted file mode 100644 index 1b4f3e7..0000000 --- a/src/components/DataTable/DataTable.test.tsx +++ /dev/null @@ -1,1140 +0,0 @@ -/** - * Tests for `DataTable` (epic #2730 / issue #2738 · sort: epic #2859 / issue #2861). - * - * Covers: - * - Renders header + body rows - * - Empty state slot when `rows` is empty - * - Loading state slot when `loading` is true - * - Persists column widths to localStorage under the namespaced key - * - Honors `alwaysVisible` so always-on columns can never be hidden - * - Calls `onRowClick` for clickable rows - * - * Sorting (epic #2859 / issue #2861): - * - Ascending sort (sortValueAccessor) - * - Descending sort (sortValueAccessor) - * - Sort toggle cycle: asc → desc → clear - * - Keyboard activation (Enter and Space) of sortable headers - * - aria-sort attribute on sorted and unsorted-but-sortable columns - * - Stable sort: equal-valued rows retain original relative order - * - Table without any sortable columns still renders correctly - * - sortComparator overrides sortValueAccessor when both are present - * - Controlled sort mode (sortColumnId + sortDirection + onSortChange) - * - Resizing still works alongside sortable columns - * - Original rows prop is not mutated by sorting - */ - -import type React from "react"; -import { describe, it, expect, afterEach, beforeEach, vi } from "vitest"; -import { act, fireEvent, render, screen } from "@testing-library/react"; -import { DataTable, type DataTableColumn, type SortDirection } from "./DataTable"; - -interface Row { - id: string; - name: string; - score: number; -} - -const ROWS: Row[] = [ - { id: "a", name: "Alpha", score: 3 }, - { id: "b", name: "Bravo", score: 1 }, - { id: "c", name: "Charlie", score: 2 }, -]; - -const COLUMNS: DataTableColumn[] = [ - { - id: "name", - header: "Name", - minWidth: 100, - initialWidth: 200, - render: (r) => {r.name}, - }, - { - id: "id", - header: "ID", - minWidth: 80, - render: (r) => {r.id}, - }, -]; - -type ResizeObserverCallback = ( - entries: ResizeObserverEntry[], - observer: ResizeObserver, -) => void; - -class MockResizeObserver { - static instances: MockResizeObserver[] = []; - readonly observed: Element[] = []; - - constructor(private readonly callback: ResizeObserverCallback) { - MockResizeObserver.instances.push(this); - } - - observe(element: Element) { - this.observed.push(element); - } - - disconnect() { - this.observed.length = 0; - } - - trigger(target: Element, width: number) { - this.callback( - [{ target, contentRect: { width } } as ResizeObserverEntry], - this as unknown as ResizeObserver, - ); - } -} - -const SORTABLE_COLUMNS: DataTableColumn[] = [ - { - id: "name", - header: "Name", - minWidth: 100, - initialWidth: 200, - sortable: true, - sortValueAccessor: (r) => r.name, - render: (r) => {r.name}, - }, - { - id: "score", - header: "Score", - minWidth: 80, - sortable: true, - sortValueAccessor: (r) => r.score, - defaultSortDirection: "asc", - render: (r) => {r.score}, - }, - { - id: "id", - header: "ID", - minWidth: 80, - render: (r) => {r.id}, - }, -]; - -describe("DataTable", () => { - beforeEach(() => { - window.localStorage.clear(); - MockResizeObserver.instances = []; - }); - - afterEach(() => vi.unstubAllGlobals()); - - // ---- Existing baseline tests ------------------------------------------- - - it("renders header and body cells", () => { - render( r.id} />); - expect(screen.getByText("Name")).toBeInTheDocument(); - expect(screen.getByText("Alpha")).toBeInTheDocument(); - expect(screen.getByText("Bravo")).toBeInTheDocument(); - }); - - it("renders the empty state when rows is empty", () => { - render( - r.id} - emptyState={
nothing here
} - />, - ); - expect(screen.getByText("nothing here")).toBeInTheDocument(); - }); - - it("renders the loading state when loading is true", () => { - render( - r.id} - loading - loadingState={
loading rows
} - />, - ); - expect(screen.getByText("loading rows")).toBeInTheDocument(); - }); - - it("invokes onRowClick when a row is clicked", () => { - const onClick = vi.fn(); - render( - r.id} - onRowClick={onClick} - />, - ); - fireEvent.click(screen.getByText("Alpha")); - expect(onClick).toHaveBeenCalledWith({ id: "a", name: "Alpha", score: 3 }); - }); - - it("applies the column widths it is given", () => { - const { container } = render( - r.id} - columnState={{ widths: { name: 60 }, visibility: {} }} - />, - ); - const headers = container.querySelectorAll("th"); - const cells = container.querySelectorAll("tbody td"); - expect(headers[0]).toHaveStyle({ width: "100px", minWidth: "100px" }); - expect(cells[0]).toHaveStyle({ minWidth: "100px" }); - }); - - it("clamps initial widths while leaving unconstrained columns fluid", () => { - const columns: DataTableColumn[] = [ - { - id: "name", - header: "Name", - initialWidth: 40, - minWidth: 100, - render: (row) => row.name, - }, - { id: "id", header: "ID", render: (row) => row.id }, - ]; - const { container } = render( - row.id} />, - ); - const headers = container.querySelectorAll("th"); - const cells = container.querySelectorAll("tbody tr:first-child td"); - - expect(headers[0]).toHaveStyle({ width: "100px", minWidth: "100px" }); - expect(cells[0]).toHaveStyle({ minWidth: "100px" }); - expect((headers[1] as HTMLElement).style.width).toBe(""); - expect((headers[1] as HTMLElement).style.minWidth).toBe(""); - expect((cells[1] as HTMLElement).style.minWidth).toBe(""); - }); - - it("preserves wide noResize widths unless a maximum is explicitly declared", () => { - const columns: DataTableColumn[] = [ - { - id: "legacy", - header: "Legacy", - initialWidth: 1600, - noResize: true, - render: (row) => row.name, - }, - { - id: "bounded", - header: "Bounded", - initialWidth: 1600, - maxWidth: 900, - noResize: true, - render: (row) => row.id, - }, - ]; - const { container } = render( - row.id} />, - ); - const headers = container.querySelectorAll("th"); - - expect(headers[0]).toHaveStyle({ width: "1600px" }); - expect(headers[0]).not.toHaveStyle({ maxWidth: "1200px" }); - expect(headers[1]).toHaveStyle({ width: "900px", maxWidth: "900px" }); - expect(headers[0]?.querySelector("[role='separator']")).toBeNull(); - expect(headers[1]?.querySelector("[role='separator']")).toBeNull(); - }); - - it("keeps default logical alignment and explicit physical alignment", () => { - const columns: DataTableColumn[] = [ - { id: "default", header: "Default", render: (row) => row.name }, - { id: "left", header: "Left", align: "left", render: (row) => row.name }, - { id: "right", header: "Right", align: "right", render: (row) => row.name }, - { id: "center", header: "Center", align: "center", render: (row) => row.name }, - ]; - const { container } = render( - row.id} - />, - ); - const headers = [...container.querySelectorAll("th")]; - const cells = [...container.querySelectorAll("tbody td")]; - - expect(getComputedStyle(headers[0]!).textAlign).toBe("start"); - expect(getComputedStyle(cells[0]!).textAlign).toBe("start"); - expect(getComputedStyle(headers[1]!).textAlign).toBe("left"); - expect(getComputedStyle(cells[1]!).textAlign).toBe("left"); - expect(getComputedStyle(headers[2]!).textAlign).toBe("right"); - expect(getComputedStyle(cells[2]!).textAlign).toBe("right"); - expect(getComputedStyle(headers[3]!).textAlign).toBe("center"); - expect(getComputedStyle(cells[3]!).textAlign).toBe("center"); - }); - - it("hides columns whose visibility flag is false unless alwaysVisible", () => { - const cols: DataTableColumn[] = [ - { ...COLUMNS[0]!, alwaysVisible: true }, - { ...COLUMNS[1]! }, - ]; - render( - r.id} - columnState={{ widths: {}, visibility: { name: false, id: false } }} - />, - ); - expect(screen.getByText("Name")).toBeInTheDocument(); - expect(screen.queryByText("ID")).not.toBeInTheDocument(); - }); - - it("touches no storage of its own", () => { - const setItem = vi.spyOn(Storage.prototype, "setItem"); - const getItem = vi.spyOn(Storage.prototype, "getItem"); - - render( - r.id} - columnState={{ widths: { name: 200 }, visibility: {} }} - onColumnStateChange={() => {}} - />, - ); - - // Where column preferences live is the consumer's decision, and a host - // without `localStorage` has to keep working. - expect(setItem).not.toHaveBeenCalled(); - expect(getItem).not.toHaveBeenCalled(); - setItem.mockRestore(); - getItem.mockRestore(); - }); - - it("does not report the state it was handed back to the caller", () => { - const onColumnStateChange = vi.fn(); - const columnState = { widths: { name: 250 }, visibility: {} }; - - const { rerender } = render( - r.id} - columnState={columnState} - onColumnStateChange={onColumnStateChange} - />, - ); - rerender( - r.id} - columnState={columnState} - onColumnStateChange={onColumnStateChange} - />, - ); - - // A caller that persists on change and feeds the result back must not - // find itself in a loop. - expect(onColumnStateChange).not.toHaveBeenCalled(); - }); - - // ---- Sorting: aria-sort ------------------------------------------------- - - it("renders aria-sort='none' on sortable columns when no sort is active", () => { - const { container } = render( - r.id} />, - ); - const headers = container.querySelectorAll("th"); - // Name column is sortable and inactive - const nameHeader = Array.from(headers).find((h) => h.textContent?.includes("Name")); - expect(nameHeader).toBeDefined(); - expect(nameHeader?.getAttribute("aria-sort")).toBe("none"); - }); - - it("renders aria-sort='ascending' on the active ascending column", () => { - const { container } = render( - r.id} />, - ); - // Click Name header to sort ascending - fireEvent.click(screen.getByText("Name")); - const headers = container.querySelectorAll("th"); - const nameHeader = Array.from(headers).find((h) => h.textContent?.includes("Name")); - expect(nameHeader?.getAttribute("aria-sort")).toBe("ascending"); - }); - - it("renders aria-sort='descending' after clicking active column twice", () => { - const { container } = render( - r.id} />, - ); - const nameHeader = () => - Array.from(container.querySelectorAll("th")).find((h) => - h.textContent?.includes("Name"), - )!; - - fireEvent.click(nameHeader()); // asc - fireEvent.click(nameHeader()); // desc - expect(nameHeader().getAttribute("aria-sort")).toBe("descending"); - }); - - it("clears aria-sort after clicking active column a third time", () => { - const { container } = render( - r.id} />, - ); - const nameHeader = () => - Array.from(container.querySelectorAll("th")).find((h) => - h.textContent?.includes("Name"), - )!; - - fireEvent.click(nameHeader()); // asc - fireEvent.click(nameHeader()); // desc - fireEvent.click(nameHeader()); // clear - expect(nameHeader().getAttribute("aria-sort")).toBe("none"); - }); - - it("does not set aria-sort on non-sortable columns", () => { - const { container } = render( - r.id} />, - ); - const headers = container.querySelectorAll("th"); - const idHeader = Array.from(headers).find((h) => h.textContent?.includes("ID")); - expect(idHeader?.hasAttribute("aria-sort")).toBe(false); - }); - - // ---- Sorting: ascending / descending row order ------------------------- - - it("sorts rows ascending by string accessor when header is clicked", () => { - render( - r.id} />, - ); - fireEvent.click(screen.getByText("Name")); - const cells = screen.getAllByRole("cell"); - const names = cells - .map((c) => c.textContent) - .filter((t) => ["Alpha", "Bravo", "Charlie"].includes(t ?? "")); - expect(names).toEqual(["Alpha", "Bravo", "Charlie"]); - }); - - it("sorts rows descending by string accessor on second click", () => { - render( - r.id} />, - ); - fireEvent.click(screen.getByText("Name")); // asc - fireEvent.click(screen.getByText("Name")); // desc - const cells = screen.getAllByRole("cell"); - const names = cells - .map((c) => c.textContent) - .filter((t) => ["Alpha", "Bravo", "Charlie"].includes(t ?? "")); - expect(names).toEqual(["Charlie", "Bravo", "Alpha"]); - }); - - it("sorts rows ascending by numeric accessor", () => { - render( - r.id} />, - ); - fireEvent.click(screen.getByText("Score")); - expect(screen.getByTestId("score-b")).toBeInTheDocument(); // score 1 - const scoreIds = ["score-b", "score-c", "score-a"]; // 1, 2, 3 - const cells = scoreIds.map((id) => screen.getByTestId(id)); - expect(cells[0]?.textContent).toBe("1"); - expect(cells[1]?.textContent).toBe("2"); - expect(cells[2]?.textContent).toBe("3"); - }); - - it("sorts rows descending by numeric accessor on second click", () => { - render( - r.id} />, - ); - fireEvent.click(screen.getByText("Score")); // asc - fireEvent.click(screen.getByText("Score")); // desc - const scoreIds = ["score-a", "score-c", "score-b"]; // 3, 2, 1 - const cells = scoreIds.map((id) => screen.getByTestId(id)); - expect(cells[0]?.textContent).toBe("3"); - expect(cells[1]?.textContent).toBe("2"); - expect(cells[2]?.textContent).toBe("1"); - }); - - // ---- Sorting: stable sort --------------------------------------------- - - it("preserves original order for rows with equal sort values (stable)", () => { - const tiedRows: Row[] = [ - { id: "x", name: "Same", score: 10 }, - { id: "y", name: "Same", score: 10 }, - { id: "z", name: "Same", score: 10 }, - ]; - // Use a column definition that renders the id in a way we can query. - const stableCols: DataTableColumn[] = [ - { - id: "name", - header: "Name", - sortable: true, - sortValueAccessor: (r) => r.name, - render: (r) => {r.name}, - }, - { - id: "id", - header: "ID", - render: (r) => {r.id}, - }, - ]; - render( r.id} />); - fireEvent.click(screen.getByText("Name")); // ascending - // All three ids should still appear in original order x, y, z - const idCells = [ - screen.getByTestId("row-id-x"), - screen.getByTestId("row-id-y"), - screen.getByTestId("row-id-z"), - ]; - // Verify they are all present (stable sort didn't lose any rows) - expect(idCells[0]?.textContent).toBe("x"); - expect(idCells[1]?.textContent).toBe("y"); - expect(idCells[2]?.textContent).toBe("z"); - // Verify the DOM order matches original insertion order (stable) - const allRows = screen - .getAllByRole("row") - .filter((r) => r.querySelector("[data-testid^='row-id-']") !== null); - expect( - allRows.map((r) => r.querySelector("[data-testid^='row-id-']")?.textContent), - ).toEqual(["x", "y", "z"]); - }); - - // ---- Sorting: keyboard activation -------------------------------------- - - it("activates sort on Enter key press on a sortable header", () => { - const { container } = render( - r.id} />, - ); - const nameHeader = Array.from(container.querySelectorAll("th")).find((h) => - h.textContent?.includes("Name"), - )!; - fireEvent.keyDown(nameHeader, { key: "Enter" }); - expect(nameHeader.getAttribute("aria-sort")).toBe("ascending"); - }); - - it("activates sort on Space key press on a sortable header", () => { - const { container } = render( - r.id} />, - ); - const nameHeader = Array.from(container.querySelectorAll("th")).find((h) => - h.textContent?.includes("Name"), - )!; - fireEvent.keyDown(nameHeader, { key: " " }); - expect(nameHeader.getAttribute("aria-sort")).toBe("ascending"); - }); - - it("does not activate sort on arbitrary key press", () => { - const { container } = render( - r.id} />, - ); - const nameHeader = Array.from(container.querySelectorAll("th")).find((h) => - h.textContent?.includes("Name"), - )!; - fireEvent.keyDown(nameHeader, { key: "Tab" }); - expect(nameHeader.getAttribute("aria-sort")).toBe("none"); - }); - - // ---- Sorting: sortComparator ------------------------------------------- - - it("uses sortComparator when provided, overriding sortValueAccessor", () => { - const comparatorSpy = vi.fn((a: Row, b: Row, dir: SortDirection) => - dir === "asc" ? a.score - b.score : b.score - a.score, - ); - const cols: DataTableColumn[] = [ - { - id: "score", - header: "Score", - sortable: true, - sortValueAccessor: (_r) => 0, // Would produce no useful ordering - sortComparator: comparatorSpy, - render: (r) => {r.score}, - }, - ]; - render( r.id} />); - fireEvent.click(screen.getByText("Score")); - expect(comparatorSpy).toHaveBeenCalled(); - // Comparator should produce ascending score order: 1, 2, 3 - expect(screen.getByTestId("score-b").textContent).toBe("1"); // b.score=1 first - }); - - // ---- Sorting: does not mutate original rows ---------------------------- - - it("does not mutate the original rows prop when sorting", () => { - const originalRows = [...ROWS]; - render( - r.id} />, - ); - fireEvent.click(screen.getByText("Name")); - // ROWS array should be unchanged in identity and order - expect(ROWS).toEqual(originalRows); - }); - - // ---- Sorting: table without sortable columns -------------------------- - - it("renders correctly when no columns are sortable", () => { - render( r.id} />); - // No sort icons should be present - const { container } = render( - r.id} />, - ); - expect(container.querySelectorAll(".data-table__sort-icon").length).toBe(0); - // All rows should still be rendered - expect(screen.getAllByText("Alpha").length).toBeGreaterThan(0); - }); - - // ---- Sorting: controlled mode ----------------------------------------- - - it("calls onSortChange when a sortable header is clicked (controlled)", () => { - const onSortChange = vi.fn(); - render( - r.id} - sortColumnId={undefined} - sortDirection={undefined} - onSortChange={onSortChange} - />, - ); - fireEvent.click(screen.getByText("Name")); - expect(onSortChange).toHaveBeenCalledWith("name", "asc"); - }); - - it("renders rows according to controlled sort props", () => { - render( - r.id} - sortColumnId="name" - sortDirection="desc" - onSortChange={vi.fn()} - />, - ); - const cells = screen.getAllByRole("cell"); - const names = cells - .map((c) => c.textContent) - .filter((t) => ["Alpha", "Bravo", "Charlie"].includes(t ?? "")); - expect(names).toEqual(["Charlie", "Bravo", "Alpha"]); - }); - - it("renders aria-sort='descending' from controlled props", () => { - const { container } = render( - r.id} - sortColumnId="name" - sortDirection="desc" - onSortChange={vi.fn()} - />, - ); - const nameHeader = Array.from(container.querySelectorAll("th")).find((h) => - h.textContent?.includes("Name"), - ); - expect(nameHeader?.getAttribute("aria-sort")).toBe("descending"); - }); - - // ---- Sorting: resizing compatibility ---------------------------------- - - it("keeps resize pointer, click, and keyboard events isolated from sorting", () => { - const { container } = render( - r.id} />, - ); - const nameHeader = Array.from(container.querySelectorAll("th")).find((h) => - h.textContent?.includes("Name"), - )!; - const resizeHandle = nameHeader.querySelector( - ".data-table__resize-handle", - ) as HTMLElement; - - fireEvent.click(resizeHandle); - fireEvent.keyDown(resizeHandle, { key: "ArrowRight" }); - fireEvent.pointerDown(resizeHandle, { - pointerId: 1, - clientX: 200, - isPrimary: true, - }); - fireEvent.pointerUp(resizeHandle, { pointerId: 1, clientX: 200 }); - - expect(nameHeader.getAttribute("aria-sort")).toBe("none"); - }); - - it("starts pointer resizing from the rendered header width", () => { - const onColumnStateChange = vi.fn(); - const { container } = render( - row.id} - onColumnStateChange={onColumnStateChange} - />, - ); - const nameHeader = container.querySelector("th") as HTMLElement; - const resizeHandle = nameHeader.querySelector( - ".data-table__resize-handle", - ) as HTMLElement; - Object.defineProperty(nameHeader, "getBoundingClientRect", { - configurable: true, - value: () => ({ width: 240 }), - }); - - fireEvent.pointerDown(resizeHandle, { - pointerId: 4, - clientX: 200, - isPrimary: true, - }); - fireEvent.pointerMove(resizeHandle, { pointerId: 4, clientX: 210 }); - fireEvent.pointerUp(resizeHandle, { pointerId: 4, clientX: 210 }); - - expect(nameHeader).toHaveStyle({ width: "250px", minWidth: "100px" }); - expect(onColumnStateChange).toHaveBeenLastCalledWith({ - widths: { name: 250 }, - visibility: {}, - }); - }); - - it("tracks fluid header widths for separator values and responsive updates", () => { - vi.stubGlobal("ResizeObserver", MockResizeObserver); - const fluidColumns: DataTableColumn[] = [ - { id: "name", header: "Name", minWidth: 100, render: (row) => row.name }, - ]; - const { container } = render( - row.id} />, - ); - const header = container.querySelector("th") as HTMLElement; - let measuredWidth = 240; - Object.defineProperty(header, "getBoundingClientRect", { - configurable: true, - value: () => ({ width: measuredWidth }), - }); - const resizeHandle = screen.getByRole("separator", { - name: "Resize name column", - }); - - act(() => MockResizeObserver.instances[0]?.trigger(header, measuredWidth)); - expect(resizeHandle).toHaveAttribute("aria-valuenow", "240"); - expect(resizeHandle).toHaveAttribute("aria-valuetext", "240 pixels"); - - measuredWidth = 320; - act(() => MockResizeObserver.instances[0]?.trigger(header, measuredWidth)); - expect(resizeHandle).toHaveAttribute("aria-valuenow", "320"); - }); - - it("exposes a labelled vertical separator and updates its value from the keyboard", () => { - const onColumnStateChange = vi.fn(); - render( - row.id} - onColumnStateChange={onColumnStateChange} - />, - ); - const resizeHandle = screen.getByRole("separator", { - name: "Resize name column", - }); - - expect(resizeHandle).toHaveAttribute("aria-orientation", "vertical"); - expect(resizeHandle).toHaveAttribute("aria-valuemin", "100"); - expect(resizeHandle).toHaveAttribute("aria-valuemax", "1200"); - expect(resizeHandle).toHaveAttribute("aria-valuenow", "200"); - expect(resizeHandle).toHaveAttribute("aria-valuetext", "200 pixels"); - - fireEvent.keyDown(resizeHandle, { key: "ArrowRight" }); - expect(resizeHandle).toHaveAttribute("aria-valuenow", "216"); - fireEvent.keyDown(resizeHandle, { key: "ArrowLeft" }); - fireEvent.keyDown(resizeHandle, { key: "Home" }); - - expect(resizeHandle).toHaveAttribute("aria-valuenow", "100"); - expect(onColumnStateChange).toHaveBeenLastCalledWith({ - widths: { name: 100 }, - visibility: {}, - }); - }); - - it("clamps pointer and keyboard resizing to the declared maximum", () => { - const onColumnStateChange = vi.fn(); - const boundedColumns: DataTableColumn[] = [ - { ...COLUMNS[0]!, maxWidth: 220 }, - COLUMNS[1]!, - ]; - const { container } = render( - row.id} - onColumnStateChange={onColumnStateChange} - />, - ); - const header = container.querySelector("th") as HTMLElement; - const resizeHandle = screen.getByRole("separator", { - name: "Resize name column", - }); - - fireEvent.pointerDown(resizeHandle, { - pointerId: 5, - clientX: 100, - isPrimary: true, - }); - fireEvent.pointerMove(resizeHandle, { pointerId: 5, clientX: 1000 }); - fireEvent.pointerUp(resizeHandle, { pointerId: 5, clientX: 1000 }); - fireEvent.keyDown(resizeHandle, { key: "ArrowRight" }); - - expect(resizeHandle).toHaveAttribute("aria-valuemin", "100"); - expect(resizeHandle).toHaveAttribute("aria-valuenow", "220"); - expect(resizeHandle).toHaveAttribute("aria-valuemax", "220"); - expect(header).toHaveStyle({ width: "220px", maxWidth: "220px" }); - expect(onColumnStateChange).toHaveBeenLastCalledWith({ - widths: { name: 220 }, - visibility: {}, - }); - }); - - it("uses a consumer-provided resize handle label", () => { - render( - row.id} - resizeHandleLabel={(column) => `Adjust ${column.id}`} - />, - ); - expect(screen.getByRole("separator", { name: "Adjust name" })).toBeInTheDocument(); - }); - - it("uses a consumer-provided resize value formatter", () => { - render( - row.id} - resizeValueText={(width) => `${width} 픽셀`} - />, - ); - expect( - screen.getByRole("separator", { name: "Resize name column" }), - ).toHaveAttribute("aria-valuetext", "200 픽셀"); - }); - - it("does not resize a column below its explicit minimum with pointer input", () => { - const onColumnStateChange = vi.fn(); - const { container } = render( - row.id} - onColumnStateChange={onColumnStateChange} - />, - ); - const nameHeader = container.querySelector("th") as HTMLElement; - const resizeHandle = nameHeader.querySelector( - ".data-table__resize-handle", - ) as HTMLElement; - - fireEvent.pointerDown(resizeHandle, { - pointerId: 3, - clientX: 200, - isPrimary: true, - }); - fireEvent.pointerMove(resizeHandle, { pointerId: 3, clientX: 0 }); - fireEvent.pointerUp(resizeHandle, { pointerId: 3, clientX: 0 }); - - expect(nameHeader).toHaveStyle({ width: "100px", minWidth: "100px" }); - expect(resizeHandle).toHaveAttribute("aria-valuemin", "100"); - expect(resizeHandle).toHaveAttribute("aria-valuenow", "100"); - expect(resizeHandle).toHaveAttribute("aria-valuemax", "1200"); - expect(onColumnStateChange).toHaveBeenLastCalledWith({ - widths: { name: 100 }, - visibility: {}, - }); - }); - - it("captures the active pointer and clears state before a reentrant cancellation release", () => { - const { container } = render( - row.id} />, - ); - const resizeHandle = container.querySelector( - ".data-table__resize-handle", - ) as HTMLDivElement; - const setPointerCapture = vi.fn(); - const releasePointerCapture = vi.fn(() => { - fireEvent.lostPointerCapture(resizeHandle, { pointerId: 8 }); - }); - Object.assign(resizeHandle, { - setPointerCapture, - hasPointerCapture: vi.fn(() => true), - releasePointerCapture, - }); - - fireEvent.pointerDown(resizeHandle, { - pointerId: 8, - clientX: 200, - isPrimary: true, - }); - fireEvent.pointerCancel(resizeHandle, { pointerId: 8, clientX: 200 }); - - expect(setPointerCapture).toHaveBeenCalledWith(8); - expect(releasePointerCapture).toHaveBeenCalledTimes(1); - expect(releasePointerCapture).toHaveBeenCalledWith(8); - }); - - it("stops resizing after lost pointer capture and releases a capture on unmount", () => { - const onColumnStateChange = vi.fn(); - const { container, unmount } = render( - row.id} - onColumnStateChange={onColumnStateChange} - />, - ); - const resizeHandle = container.querySelector( - ".data-table__resize-handle", - ) as HTMLDivElement; - const releasePointerCapture = vi.fn(); - Object.assign(resizeHandle, { - setPointerCapture: vi.fn(), - hasPointerCapture: vi.fn(() => true), - releasePointerCapture, - }); - - fireEvent.pointerDown(resizeHandle, { - pointerId: 9, - clientX: 200, - isPrimary: true, - }); - fireEvent.lostPointerCapture(resizeHandle, { pointerId: 9 }); - fireEvent.pointerMove(resizeHandle, { pointerId: 9, clientX: 250 }); - expect(onColumnStateChange).not.toHaveBeenCalled(); - - fireEvent.pointerDown(resizeHandle, { - pointerId: 10, - clientX: 200, - isPrimary: true, - }); - unmount(); - expect(releasePointerCapture).toHaveBeenCalledWith(10); - }); - - it("ignores a second or non-primary pointer while a drag is active", () => { - const onColumnStateChange = vi.fn(); - const { container } = render( - row.id} - onColumnStateChange={onColumnStateChange} - />, - ); - const resizeHandle = container.querySelector( - ".data-table__resize-handle", - ) as HTMLDivElement; - const setPointerCapture = vi.fn(); - Object.assign(resizeHandle, { setPointerCapture }); - - fireEvent.pointerDown(resizeHandle, { - pointerId: 11, - clientX: 200, - isPrimary: true, - }); - fireEvent.pointerDown(resizeHandle, { - pointerId: 12, - clientX: 200, - pointerType: "touch", - isPrimary: false, - }); - fireEvent.pointerMove(resizeHandle, { pointerId: 12, clientX: 260 }); - fireEvent.pointerMove(resizeHandle, { pointerId: 11, clientX: 220 }); - - expect(setPointerCapture).toHaveBeenCalledTimes(1); - expect(onColumnStateChange).toHaveBeenLastCalledWith({ - widths: { name: 220 }, - visibility: {}, - }); - }); - - it("rejects right-button input and ignores stale lost capture after a new drag", () => { - const onColumnStateChange = vi.fn(); - const { container } = render( - row.id} - onColumnStateChange={onColumnStateChange} - />, - ); - const resizeHandle = container.querySelector( - ".data-table__resize-handle", - ) as HTMLDivElement; - const setPointerCapture = vi.fn(); - Object.assign(resizeHandle, { setPointerCapture }); - - fireEvent.pointerDown(resizeHandle, { - pointerId: 13, - clientX: 200, - button: 2, - isPrimary: true, - }); - expect(setPointerCapture).not.toHaveBeenCalled(); - - fireEvent.pointerDown(resizeHandle, { - pointerId: 14, - clientX: 200, - isPrimary: true, - }); - fireEvent.lostPointerCapture(resizeHandle, { pointerId: 14 }); - fireEvent.pointerDown(resizeHandle, { - pointerId: 15, - clientX: 200, - isPrimary: true, - }); - fireEvent.lostPointerCapture(resizeHandle, { pointerId: 14 }); - fireEvent.pointerMove(resizeHandle, { pointerId: 15, clientX: 230 }); - - expect(setPointerCapture).toHaveBeenCalledTimes(2); - expect(onColumnStateChange).toHaveBeenLastCalledWith({ - widths: { name: 230 }, - visibility: {}, - }); - }); -}); - -describe("isRowClickable and rowClassName", () => { - const rows = [ - { id: "a", name: "Alpha" }, - { id: "b", name: "Beta" }, - ]; - const columns = [ - { id: "name", header: "Name", render: (r: (typeof rows)[number]) => r.name }, - ]; - - it("gives an excluded row no affordance, no tab stop, and no role", () => { - const onRowClick = vi.fn(); - const { container } = render( - r.id} - onRowClick={onRowClick} - isRowClickable={(r) => r.id === "a"} - />, - ); - const [first, second] = [ - ...container.querySelectorAll(".data-table__row:not(.data-table__row--head)"), - ]; - - expect(first).toHaveClass("data-table__row--clickable"); - expect(first).toHaveAttribute("tabindex", "0"); - expect(first).toHaveAttribute("role", "button"); - - expect(second).not.toHaveClass("data-table__row--clickable"); - expect(second).not.toHaveAttribute("tabindex"); - expect(second).not.toHaveAttribute("role"); - }); - - it("does not invoke onRowClick for an excluded row", () => { - const onRowClick = vi.fn(); - const { container } = render( - r.id} - onRowClick={onRowClick} - isRowClickable={(r) => r.id === "a"} - />, - ); - const [first, second] = [ - ...container.querySelectorAll(".data-table__row:not(.data-table__row--head)"), - ]; - fireEvent.click(second as Element); - expect(onRowClick).not.toHaveBeenCalled(); - fireEvent.click(first as Element); - expect(onRowClick).toHaveBeenCalledTimes(1); - }); - - it("treats every row as clickable when the predicate is absent", () => { - const { container } = render( - r.id} - onRowClick={vi.fn()} - />, - ); - const body = container.querySelectorAll( - ".data-table__row:not(.data-table__row--head)", - ); - expect( - [...body].every((r) => r.classList.contains("data-table__row--clickable")), - ).toBe(true); - }); - - it("adds a per-row class, and adds nothing for a falsy return", () => { - const { container } = render( - r.id} - rowClassName={(r) => (r.id === "a" ? "row--flagged" : undefined)} - />, - ); - const [first, second] = [ - ...container.querySelectorAll(".data-table__row:not(.data-table__row--head)"), - ]; - expect(first).toHaveClass("row--flagged"); - expect(second?.className).toBe("data-table__row"); - }); -}); - -describe("secondary actions inside a clickable row", () => { - const rows = [{ id: "a", name: "Alpha" }]; - const base = [ - { id: "name", header: "Name", render: (r: (typeof rows)[number]) => r.name }, - ]; - - function renderWithAction(control: React.ReactNode) { - const onRowClick = vi.fn(); - const view = render( - control }]} - getRowKey={(r) => r.id} - onRowClick={onRowClick} - />, - ); - return { ...view, onRowClick }; - } - - it("does not fire onRowClick when a nested button is clicked", () => { - const onEdit = vi.fn(); - const { screen: _s, onRowClick } = { - ...renderWithAction( - , - ), - screen, - }; - fireEvent.click(screen.getByRole("button", { name: "Edit" })); - expect(onEdit).toHaveBeenCalledTimes(1); - expect(onRowClick).not.toHaveBeenCalled(); - }); - - it("does not fire onRowClick from a nested link or input", () => { - const { onRowClick } = renderWithAction( - <> - Open - - , - ); - fireEvent.click(screen.getByRole("link", { name: "Open" })); - fireEvent.click(screen.getByLabelText("rename")); - expect(onRowClick).not.toHaveBeenCalled(); - }); - - it("still fires onRowClick from the row's own cells", () => { - const { onRowClick } = renderWithAction(); - fireEvent.click(screen.getByText("Alpha")); - expect(onRowClick).toHaveBeenCalledTimes(1); - }); - - it("does not fire onRowClick on Enter inside a nested control", () => { - const { onRowClick } = renderWithAction(); - fireEvent.keyDown(screen.getByRole("button", { name: "Edit" }), { - key: "Enter", - }); - expect(onRowClick).not.toHaveBeenCalled(); - }); -}); diff --git a/src/components/DataTable/DataTable.tsx b/src/components/DataTable/DataTable.tsx deleted file mode 100644 index b6aabe0..0000000 --- a/src/components/DataTable/DataTable.tsx +++ /dev/null @@ -1,941 +0,0 @@ -/** - * DataTable Component (epic #2730 / issue #2738 · sort: epic #2859 / issue #2861) - * - * Generic, type-safe table primitive modeled after the Backend.AI WebUI - * `BAITable`. Designed so future menus (Models, Sessions, Data, ...) - * can share one tabular surface instead of inventing a new one each - * time. - * - * Features (V1 — additional capabilities planned for follow-up issues): - * - Type-safe column definitions with custom cell renderers - * - Optional column-visibility persistence via `localStorage` when - * `persistKey` is supplied - * - Optional resizable columns (drag the right edge of a header cell) - * with widths persisted alongside visibility - * - Empty-state slot rendered when `rows` is empty - * - Loading-state slot rendered when `loading` is `true` - * - Stable `getRowKey` accessor so rows with the same identifier remain - * referentially identical across event-driven updates - * - * Sorting (V2 — epic #2859 / issue #2861): - * - Optional per-column sortable flag with sort value accessor or - * custom comparator - * - Optional defaultSortDirection per column - * - Controlled (`sortColumnId` + `sortDirection` + `onSortChange`) and - * uncontrolled (internal state) modes - * - Accessible `aria-sort` attributes on header cells - * - Keyboard activation (Enter / Space) for sortable headers - * - Stable sort — equal-valued rows retain their original order - * - Sorting never mutates the original `rows` prop - * - * The component is intentionally domain-agnostic — it MUST NOT carry - * Session, Model, or Agent-specific knowledge. The first consumer is - * `SessionListTable` (epic #2730 / #2738) but the API is generic. - */ - -import { - memo, - useCallback, - useEffect, - useLayoutEffect, - useMemo, - useRef, - useState, - type KeyboardEvent as ReactKeyboardEvent, - type PointerEvent as ReactPointerEvent, - type ReactNode, -} from "react"; -import "./DataTable.css"; - -// ============================================================================ -// Public types -// ============================================================================ - -/** Direction a column is currently sorted. */ -export type SortDirection = "asc" | "desc"; - -export interface DataTableColumn { - /** Unique identifier for the column. Used for visibility + width persistence. */ - id: string; - /** Header label. */ - header: ReactNode; - /** - * Cell renderer. Receives the row and returns a React node. Pure - * functions are encouraged so memoization stays effective. - */ - render: (row: T, index: number) => ReactNode; - /** Optional minimum column width in pixels. */ - minWidth?: number; - /** - * Optional maximum column width in pixels. Values above this limit are - * clamped for pointer and keyboard resizing. Resizable columns default to - * 1200 pixels so every separator exposes a finite ARIA maximum. A - * `noResize` column keeps its existing unbounded width unless it explicitly - * supplies this property. - */ - maxWidth?: number; - /** Optional initial width in pixels (only honored on the first paint). */ - initialWidth?: number; - /** When `true`, the column cannot be resized. Defaults to `false`. */ - noResize?: boolean; - /** When `true`, the column cannot be hidden by the user. Defaults to `false`. */ - alwaysVisible?: boolean; - /** Extra `class` for the column's `` and `` cells. */ - className?: string; - /** - * When provided, the value is exposed via the `align` style on the - * column's cells. Useful for right-aligned numeric columns. - */ - align?: "left" | "right" | "center"; - - // ---- Sorting API (added epic #2859 / issue #2861) ------------------------- - - /** - * When `true`, the column header renders a sort affordance and clicking - * (or pressing Enter / Space) cycles through ascending → descending → - * unsorted states. Defaults to `false`. - */ - sortable?: boolean; - /** - * Extracts a primitive comparable value from a row for sorting purposes. - * Used when the default ascending/descending behavior is sufficient. - * Mutually exclusive with `sortComparator` — if both are provided, - * `sortComparator` takes precedence. - * - * @example - * ```tsx - * sortValueAccessor: (row) => row.name.toLowerCase() - * ``` - */ - sortValueAccessor?: (row: T) => string | number | boolean | null | undefined; - /** - * Custom comparator used for sorting. Receives two rows and the active - * sort direction. Return a negative number if `a` should come first, a - * positive number if `b` should come first, or `0` if they are equal. - * The table applies a stable fallback on `0` to preserve original order. - * - * @example - * ```tsx - * sortComparator: (a, b, dir) => - * dir === "asc" - * ? a.createdAt - b.createdAt - * : b.createdAt - a.createdAt - * ``` - */ - sortComparator?: (a: T, b: T, direction: SortDirection) => number; - /** - * The sort direction applied when this column is first activated. - * Defaults to `"asc"`. - */ - defaultSortDirection?: SortDirection; -} - -export interface DataTablePersistedState { - /** column id → user-overridden width in pixels. */ - widths: Record; - /** column id → visibility flag. Missing keys count as visible. */ - visibility: Record; -} - -/** - * The controls a row can contain that are actions in their own right. A click - * or an Enter on one of these is that control's business, not the row's. - */ -const INTERACTIVE_DESCENDANT_SELECTOR = [ - "a", - "button", - "input", - "select", - "textarea", - "summary", - "label", - "[contenteditable='true']", - "[role='button']", - "[role='link']", - "[tabindex]:not([tabindex='-1'])", -].join(","); - -/** - * Whether an event inside a clickable row came from a control nested in it. - * - * A row that is itself clickable and contains an Edit button has two actions - * competing for one click. Without this, both fire: the button edits and the - * row also navigates away from the thing just edited. The row is the fallback, - * so anything more specific wins. - */ -function fromInteractiveDescendant( - target: EventTarget | null, - currentTarget: EventTarget, -): boolean { - if (!(target instanceof Element) || !(currentTarget instanceof Element)) { - return false; - } - const control = target.closest(INTERACTIVE_DESCENDANT_SELECTOR); - return ( - control !== null && control !== currentTarget && currentTarget.contains(control) - ); -} - -export interface DataTableProps { - /** Column definitions, in display order. */ - columns: DataTableColumn[]; - /** Row data. */ - rows: T[]; - /** - * Stable identifier accessor — used as the React key for each row. - * Must be deterministic for the lifetime of the row. - */ - getRowKey: (row: T, index: number) => string; - /** Render this node when `rows` is empty. */ - emptyState?: ReactNode; - /** Render this node when `loading` is `true`. */ - loadingState?: ReactNode; - /** When `true`, the loading slot replaces the table body. */ - loading?: boolean; - /** - * Column widths and visibility, controlled by the caller. - * - * This component used to take a `persistKey` and write to `localStorage` - * itself. Storage is a policy decision that belongs to the consumer: where - * it goes, under which key, whether it is per user or per workspace, and - * whether it exists at all in a host that has no `localStorage`. A - * rendering component that answers those questions on its own cannot be - * reused by a consumer that answers them differently. - */ - columnState?: DataTablePersistedState; - /** - * Called whenever the user resizes a column or toggles its visibility. - * Pair it with `columnState` to persist wherever the consumer keeps - * preferences; omit both to get a table that forgets on unmount. - */ - onColumnStateChange?: (state: DataTablePersistedState) => void; - /** Extra class for the table's wrapping element. */ - className?: string; - /** ARIA label for the table. Defaults to "Data table". */ - ariaLabel?: string; - /** - * Produces the accessible name for a resizable column's separator. The - * default is `Resize column`; pass a product's translated label - * when column ids are not suitable for people to hear. - */ - resizeHandleLabel?: (column: DataTableColumn) => string; - /** - * Produces the human-readable current width announced by a resize - * separator. The default is ` pixels`; pass a translated formatter - * when the host localizes measurements. - */ - resizeValueText?: (width: number, column: DataTableColumn) => string; - /** Test ID for testing harnesses. */ - testId?: string; - /** - * Callback invoked when a row is clicked. When provided, rows render - * with a clickable affordance (cursor: pointer + keyboard binding). - */ - onRowClick?: (row: T) => void; - /** - * Narrows `onRowClick` to the rows that can actually act on it, for a table - * that mixes actionable and informational rows. A row this returns `false` - * for gets no pointer affordance, no tab stop, and no keyboard binding, so it - * is not announced as a button it cannot be. - */ - isRowClickable?: (row: T, index: number) => boolean; - /** - * Extra class for one row, for flagging a specific row without forking the - * table: a transient deep-link highlight, a stale entry, a row being removed. - * A falsy return adds nothing. - */ - rowClassName?: (row: T, index: number) => string | undefined | false | null; - - // ---- Sorting props (added epic #2859 / issue #2861) ----------------------- - - /** - * Controlled — the column currently being sorted. Pass `undefined` to - * indicate no active sort. When this prop is present, the component - * operates in controlled mode: the caller is responsible for updating - * the sort state via `onSortChange`. - */ - sortColumnId?: string; - /** - * Controlled — the direction of the active sort. Ignored when - * `sortColumnId` is `undefined`. - */ - sortDirection?: SortDirection; - /** - * Called when the user activates a sortable column header. In - * controlled mode the caller must propagate this back as - * `sortColumnId` / `sortDirection`. In uncontrolled mode this prop is - * purely observational. - * - * @param columnId The column being sorted, or `undefined` when the - * sort is cleared. - * @param direction The new direction. - */ - onSortChange?: ( - columnId: string | undefined, - direction: SortDirection | undefined, - ) => void; -} - -const EMPTY_COLUMN_STATE: DataTablePersistedState = { widths: {}, visibility: {} }; - -/** Keyboard increment, in CSS pixels, for a column resize separator. */ -const KEYBOARD_RESIZE_STEP = 16; - -/** Product-neutral finite ceiling for a column without an explicit maximum. */ -const DEFAULT_MAX_COLUMN_WIDTH = 1200; - -interface ColumnWidthBounds { - min: number; - max: number; -} - -function columnWidthBounds(column: DataTableColumn): ColumnWidthBounds { - const min = Math.max(0, column.minWidth ?? 80); - const requestedMax = column.maxWidth ?? DEFAULT_MAX_COLUMN_WIDTH; - const max = Number.isFinite(requestedMax) - ? Math.max(min, requestedMax) - : DEFAULT_MAX_COLUMN_WIDTH; - return { min, max: Math.max(min, max) }; -} - -function clampColumnWidth(column: DataTableColumn, width: number): number { - const { min, max } = columnWidthBounds(column); - return Math.min(max, Math.max(min, Math.round(width))); -} - -// ============================================================================ -// Sorting helpers -// ============================================================================ - -/** - * Stable sort — rows with equal comparison result keep their original - * relative order. We achieve stability by augmenting each row with its - * original index and using that as a tie-breaker. - * - * The input array is never mutated. - */ -function stableSort(rows: T[], comparator: (a: T, b: T) => number): T[] { - const indexed = rows.map((row, i) => ({ row, i })); - indexed.sort((a, b) => { - const result = comparator(a.row, b.row); - return result !== 0 ? result : a.i - b.i; - }); - return indexed.map(({ row }) => row); -} - -/** - * Builds a comparator from a `DataTableColumn` + active sort direction. - * Falls back to string comparison when neither `sortComparator` nor - * `sortValueAccessor` is provided on the column. - */ -function buildComparator( - col: DataTableColumn, - direction: SortDirection, -): (a: T, b: T) => number { - const { sortComparator } = col; - if (sortComparator) { - return (a, b) => sortComparator(a, b, direction); - } - const accessor = col.sortValueAccessor; - if (accessor) { - return (a, b) => { - const av = accessor(a); - const bv = accessor(b); - // Nullish values sort last regardless of direction. - if (av == null && bv == null) return 0; - if (av == null) return 1; - if (bv == null) return -1; - let cmp: number; - if (typeof av === "number" && typeof bv === "number") { - cmp = av - bv; - } else { - cmp = String(av).localeCompare(String(bv)); - } - return direction === "asc" ? cmp : -cmp; - }; - } - // No accessor — unsortable in practice (column should not be sortable). - return () => 0; -} - -/** - * Returns the `aria-sort` attribute value for a column header cell. - * - * @see https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-sort - */ -function ariaSortValue( - col: DataTableColumn, - activeSortColumnId: string | undefined, - activeSortDirection: SortDirection | undefined, -): "ascending" | "descending" | "none" | undefined { - if (!col.sortable) return undefined; - if (col.id === activeSortColumnId) { - return activeSortDirection === "asc" ? "ascending" : "descending"; - } - return "none"; -} - -// Sort indicator icons rendered as inline SVG so there is no external -// asset dependency. They intentionally reuse design-token colours so they -// automatically adapt to light / dark themes. - -function SortIcon({ direction }: { direction: SortDirection | "none" }) { - if (direction === "none") { - // Neutral chevron stack — both arrows in muted colour - return ( - - ); - } - if (direction === "asc") { - return ( - - ); - } - return ( - - ); -} - -// ============================================================================ -// Component -// ============================================================================ - -function DataTableInner({ - columns, - rows, - getRowKey, - emptyState, - loadingState, - loading = false, - columnState, - onColumnStateChange, - className = "", - ariaLabel = "Data table", - resizeHandleLabel = (column) => `Resize ${column.id} column`, - resizeValueText = (width) => `${String(width)} pixels`, - testId, - onRowClick, - isRowClickable, - rowClassName, - // Sorting (controlled) - sortColumnId: controlledSortColumnId, - sortDirection: controlledSortDirection, - onSortChange, -}: DataTableProps) { - // ---- Column state (widths + visibility) ---------------------------------- - // - // Held internally so a caller that does not care about persistence gets a - // working table, and re-seeded whenever the caller supplies a new one. - const [persisted, setPersisted] = useState( - () => columnState ?? EMPTY_COLUMN_STATE, - ); - - useEffect(() => { - if (columnState) setPersisted(columnState); - }, [columnState]); - - // ---- Sorting state (uncontrolled fallback) ------------------------------- - // - // Controlled mode is detected by the presence of `sortColumnId` as a prop - // (the caller explicitly manages the sort column). Using only this prop as - // the signal (rather than OR-ing with `sortDirection`) avoids a - // pathological state where `sortDirection` is set but `sortColumnId` is - // not, which would enter controlled mode with no sort effect. - // - // Note: TypeScript cannot distinguish "prop not passed" from "prop passed - // as undefined", so we rely on the convention that controlled callers - // always pass both `sortColumnId` and `sortDirection` together. - const isControlled = - controlledSortColumnId !== undefined || controlledSortDirection !== undefined; - - const [internalSortColumnId, setInternalSortColumnId] = useState( - undefined, - ); - const [internalSortDirection, setInternalSortDirection] = useState< - SortDirection | undefined - >(undefined); - - const activeSortColumnId = isControlled - ? controlledSortColumnId - : internalSortColumnId; - const activeSortDirection = isControlled - ? controlledSortDirection - : internalSortDirection; - - /** Handle a click / keypress on a sortable column header. */ - const handleSortToggle = useCallback( - (col: DataTableColumn) => { - if (!col.sortable) return; - - let nextColumnId: string | undefined; - let nextDirection: SortDirection | undefined; - - if (activeSortColumnId !== col.id) { - // Activate this column in its default direction. - nextColumnId = col.id; - nextDirection = col.defaultSortDirection ?? "asc"; - } else if (activeSortDirection === (col.defaultSortDirection ?? "asc")) { - // Flip to the opposite direction. - nextColumnId = col.id; - nextDirection = activeSortDirection === "asc" ? "desc" : "asc"; - } else { - // Already in the non-default direction — clear the sort. - nextColumnId = undefined; - nextDirection = undefined; - } - - if (!isControlled) { - setInternalSortColumnId(nextColumnId); - setInternalSortDirection(nextDirection); - } - onSortChange?.(nextColumnId, nextDirection); - }, - [activeSortColumnId, activeSortDirection, isControlled, onSortChange], - ); - - // ---- Visibility computation (column always-visible wins) ----------------- - const visibleColumns = useMemo( - () => - columns.filter((col) => { - if (col.alwaysVisible) return true; - const flag = persisted.visibility[col.id]; - return flag !== false; - }), - [columns, persisted.visibility], - ); - - // ---- Width resolution ---------------------------------------------------- - const resolveWidth = useCallback( - (col: DataTableColumn): number | undefined => { - const persistedWidth = persisted.widths[col.id]; - const width = - typeof persistedWidth === "number" && persistedWidth > 0 - ? persistedWidth - : col.initialWidth; - if (width === undefined) return undefined; - if (col.noResize && col.maxWidth === undefined) { - return Math.max(width, col.minWidth ?? 0); - } - return clampColumnWidth(col, width); - }, - [persisted.widths], - ); - - // ---- Resize handling ----------------------------------------------------- - const headerRefs = useRef(new Map()); - const [renderedWidths, setRenderedWidths] = useState>({}); - - const updateRenderedWidth = useCallback( - (column: DataTableColumn, width: number) => { - const nextWidth = clampColumnWidth(column, width); - setRenderedWidths((previous) => - previous[column.id] === nextWidth - ? previous - : { ...previous, [column.id]: nextWidth }, - ); - }, - [], - ); - - const setHeaderRef = useCallback( - (columnId: string, element: HTMLTableCellElement | null) => { - if (element) headerRefs.current.set(columnId, element); - else headerRefs.current.delete(columnId); - }, - [], - ); - - useLayoutEffect(() => { - const updateFromElement = (column: DataTableColumn, element: Element) => { - const nextWidth = element.getBoundingClientRect().width; - if (nextWidth > 0) updateRenderedWidth(column, nextWidth); - }; - - for (const column of visibleColumns) { - const header = headerRefs.current.get(column.id); - if (header) updateFromElement(column, header); - } - - if (typeof ResizeObserver === "undefined") return; - const observer = new ResizeObserver((entries) => { - for (const entry of entries) { - const columnId = (entry.target as HTMLElement).dataset.columnId; - const column = visibleColumns.find((candidate) => candidate.id === columnId); - if (column) updateFromElement(column, entry.target); - } - }); - for (const column of visibleColumns) { - const header = headerRefs.current.get(column.id); - if (header) observer.observe(header); - } - return () => observer.disconnect(); - }, [updateRenderedWidth, visibleColumns]); - - const resizeRef = useRef<{ - columnId: string; - startX: number; - startWidth: number; - minWidth: number; - maxWidth: number; - pointerId: number; - handle: HTMLDivElement; - } | null>(null); - - const updateColumnWidth = useCallback( - (column: DataTableColumn, width: number) => { - const nextWidth = clampColumnWidth(column, width); - setPersisted((previous) => ({ - ...previous, - widths: { ...previous.widths, [column.id]: nextWidth }, - })); - updateRenderedWidth(column, nextWidth); - }, - [updateRenderedWidth], - ); - - const clearResize = useCallback((pointerId?: number) => { - const resize = resizeRef.current; - if (!resize || (pointerId !== undefined && resize.pointerId !== pointerId)) return; - // Clear the state before releasing capture: releasePointerCapture can - // synchronously dispatch lostpointercapture in some browsers. - resizeRef.current = null; - if (resize.handle.hasPointerCapture?.(resize.pointerId)) { - resize.handle.releasePointerCapture?.(resize.pointerId); - } - }, []); - - useEffect(() => clearResize, [clearResize]); - - const measuredWidth = useCallback( - (handle: HTMLDivElement, col: DataTableColumn): number => { - const renderedWidth = handle.parentElement?.getBoundingClientRect().width ?? 0; - const fallbackWidth = - renderedWidths[col.id] ?? resolveWidth(col) ?? columnWidthBounds(col).min; - return clampColumnWidth(col, Math.round(renderedWidth) || fallbackWidth); - }, - [renderedWidths, resolveWidth], - ); - - const beginResize = useCallback( - (ev: ReactPointerEvent, col: DataTableColumn) => { - if (col.noResize || !ev.isPrimary || ev.button !== 0 || resizeRef.current) { - return; - } - ev.preventDefault(); - ev.stopPropagation(); - const handle = ev.currentTarget; - resizeRef.current = { - columnId: col.id, - startX: ev.clientX, - startWidth: measuredWidth(handle, col), - minWidth: columnWidthBounds(col).min, - maxWidth: columnWidthBounds(col).max, - pointerId: ev.pointerId, - handle, - }; - handle.setPointerCapture?.(ev.pointerId); - }, - [measuredWidth], - ); - - const moveResize = useCallback( - (ev: ReactPointerEvent) => { - const resize = resizeRef.current; - if (!resize || resize.pointerId !== ev.pointerId) return; - ev.preventDefault(); - ev.stopPropagation(); - const nextWidth = Math.max( - resize.minWidth, - Math.min(resize.maxWidth, resize.startWidth + ev.clientX - resize.startX), - ); - const column = columns.find((candidate) => candidate.id === resize.columnId); - if (column) updateColumnWidth(column, nextWidth); - }, - [columns, updateColumnWidth], - ); - - const endResize = useCallback( - (ev: ReactPointerEvent) => { - if (resizeRef.current?.pointerId !== ev.pointerId) return; - ev.preventDefault(); - ev.stopPropagation(); - clearResize(ev.pointerId); - }, - [clearResize], - ); - - const handleResizeKeyDown = useCallback( - (ev: ReactKeyboardEvent, col: DataTableColumn) => { - if (ev.key !== "ArrowLeft" && ev.key !== "ArrowRight" && ev.key !== "Home") { - return; - } - ev.preventDefault(); - ev.stopPropagation(); - const { min: minimum, max: maximum } = columnWidthBounds(col); - const current = measuredWidth(ev.currentTarget, col); - const nextWidth = - ev.key === "Home" - ? minimum - : Math.min( - maximum, - Math.max( - minimum, - current + - (ev.key === "ArrowRight" - ? KEYBOARD_RESIZE_STEP - : -KEYBOARD_RESIZE_STEP), - ), - ); - updateColumnWidth(col, nextWidth); - }, - [measuredWidth, updateColumnWidth], - ); - - // Report changes so the caller can persist them. Skipped while `persisted` - // still holds what the caller last handed in, so echoing the callback back - // through `columnState` does not loop. - const reported = useRef(persisted); - useEffect(() => { - if (reported.current === persisted) return; - reported.current = persisted; - onColumnStateChange?.(persisted); - }, [persisted, onColumnStateChange]); - - // ---- Sorted rows -------------------------------------------------------- - const sortedRows = useMemo(() => { - if (!activeSortColumnId || !activeSortDirection) return rows; - const col = columns.find((c) => c.id === activeSortColumnId); - if (!col?.sortable) return rows; - return stableSort(rows, buildComparator(col, activeSortDirection)); - }, [rows, columns, activeSortColumnId, activeSortDirection]); - - // ---- Body rendering ------------------------------------------------------ - const composedClassName = ["data-table", className].filter(Boolean).join(" "); - - const renderBody = () => { - if (loading) { - return ( - - - {loadingState ??
} - - - ); - } - if (sortedRows.length === 0) { - return ( - - - {emptyState} - - - ); - } - return sortedRows.map((row, index) => { - const key = getRowKey(row, index); - // A row is interactive only if the table has a handler AND this row is - // not excluded, so the affordance, the tab stop and the role move - // together. Splitting them would announce a button that does nothing. - const clickable = Boolean(onRowClick && (isRowClickable?.(row, index) ?? true)); - const extraClass = rowClassName?.(row, index); - return ( - { - if (fromInteractiveDescendant(e.target, e.currentTarget)) return; - onRowClick?.(row); - } - : undefined - } - onKeyDown={ - clickable - ? (e) => { - if (e.key !== "Enter" && e.key !== " ") return; - if (fromInteractiveDescendant(e.target, e.currentTarget)) return; - e.preventDefault(); - onRowClick?.(row); - } - : undefined - } - tabIndex={clickable ? 0 : undefined} - role={clickable ? "button" : undefined} - > - {visibleColumns.map((col) => ( - - {col.render(row, index)} - - ))} - - ); - }); - }; - - return ( -
- - - - {visibleColumns.map((col) => { - const width = resolveWidth(col); - const currentWidth = clampColumnWidth( - col, - renderedWidths[col.id] ?? width ?? columnWidthBounds(col).min, - ); - const bounds = columnWidthBounds(col); - const isSortActive = col.sortable && col.id === activeSortColumnId; - - return ( - - ); - })} - - - {renderBody()} -
setHeaderRef(col.id, element)} - data-column-id={col.id} - scope="col" - className={[ - "data-table__cell", - "data-table__cell--head", - !col.noResize && "data-table__cell--resizable", - col.sortable && "data-table__cell--sortable", - isSortActive && "data-table__cell--sort-active", - col.className, - col.align && `data-table__cell--align-${col.align}`, - ] - .filter(Boolean) - .join(" ")} - style={{ - width: width === undefined ? undefined : `${String(width)}px`, - minWidth: col.minWidth, - maxWidth: col.noResize ? col.maxWidth : bounds.max, - }} - aria-sort={ariaSortValue( - col as DataTableColumn, - activeSortColumnId, - activeSortDirection, - )} - onClick={ - col.sortable - ? () => { - handleSortToggle(col); - } - : undefined - } - onKeyDown={ - col.sortable - ? (e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - handleSortToggle(col); - } - } - : undefined - } - tabIndex={col.sortable ? 0 : undefined} - > - {col.header} - {col.sortable && ( - - )} - {!col.noResize && ( -
event.stopPropagation()} - onPointerDown={(event) => beginResize(event, col)} - onPointerMove={moveResize} - onPointerUp={endResize} - onPointerCancel={endResize} - onLostPointerCapture={(event) => clearResize(event.pointerId)} - onKeyDown={(event) => handleResizeKeyDown(event, col)} - /> - )} -
-
- ); -} - -// Memoize while preserving the generic signature. -export const DataTable = memo(DataTableInner) as typeof DataTableInner; - -export default DataTable; diff --git a/src/components/DataTable/index.ts b/src/components/DataTable/index.ts deleted file mode 100644 index 73834a4..0000000 --- a/src/components/DataTable/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -export { DataTable, default } from "./DataTable"; -export type { - DataTableColumn, - DataTableProps, - DataTablePersistedState, - SortDirection, -} from "./DataTable"; diff --git a/src/components/Drawer/Drawer.css b/src/components/Drawer/Drawer.css deleted file mode 100644 index 1f7981a..0000000 --- a/src/components/Drawer/Drawer.css +++ /dev/null @@ -1,266 +0,0 @@ -/** - * Drawer Component Styles - * - * Shared styles for drawer component using Backend.AI design system tokens. - * Provides backdrop animation, slide-in transitions, and responsive behavior. - * - * IMPORTANT: Selectors that declare `transition` use doubled-class specificity - * (e.g., `.drawer.drawer`) to ensure they are NOT overridden by the - * `[class*="drawer"]` attribute selector in transitions.css. Both selectors - * would normally have specificity (0,1,0), but since transitions.css appears - * later in the CSS bundle, it would win the cascade and strip transform/opacity - * transitions. Doubling the class selector raises specificity to (0,2,0). - */ - -/* ============================================================================ - * Backdrop - * ============================================================================ */ - -.drawer__backdrop.drawer__backdrop { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background-color: var(--token-colorBgMask, rgba(0, 0, 0, 0.45)); - -webkit-backdrop-filter: blur(2px); - backdrop-filter: blur(2px); - z-index: var(--token-zIndexDrawer, 1100); - pointer-events: none; - visibility: hidden; - opacity: 0; - /* Merge animation transitions (opacity, visibility) with theme color - * transitions (background-color) so both work correctly. */ - transition: - opacity var(--token-motionDurationMid, 0.3s) ease, - visibility 0s linear var(--token-motionDurationMid, 0.3s), - background-color var(--token-themeTransitionDuration, 0.25s) - var(--token-themeTransitionTiming, ease-in-out), - backdrop-filter var(--token-themeTransitionDuration, 0.25s) - var(--token-themeTransitionTiming, ease-in-out); -} - -.drawer__backdrop--open.drawer__backdrop--open { - pointer-events: auto; - visibility: visible; - opacity: 1; - /* When open, visibility transition has no delay so backdrop appears instantly. */ - transition: - opacity var(--token-motionDurationMid, 0.3s) ease, - visibility 0s linear 0s, - background-color var(--token-themeTransitionDuration, 0.25s) - var(--token-themeTransitionTiming, ease-in-out), - backdrop-filter var(--token-themeTransitionDuration, 0.25s) - var(--token-themeTransitionTiming, ease-in-out); -} - -/* ============================================================================ - * Drawer Panel - * ============================================================================ */ - -.drawer.drawer { - position: fixed; - top: 0; - right: 0; - bottom: 0; - background-color: var(--token-colorBgContainer, #ffffff); - box-shadow: var(--token-boxShadowDrawerRight, -4px 0 20px rgba(0, 0, 0, 0.15)); - display: flex; - flex-direction: column; - transform: translateX(100%); - opacity: 0; - /* Merge animation transitions (transform, opacity) with theme color - * transitions (background-color, color, border-color, box-shadow) - * so both slide animation and theme switching work correctly. */ - transition: - transform var(--token-motionDurationMid, 0.3s) cubic-bezier(0.4, 0, 0.2, 1), - opacity var(--token-motionDurationMid, 0.3s) ease, - background-color var(--token-themeTransitionDuration, 0.25s) - var(--token-themeTransitionTiming, ease-in-out), - color var(--token-themeTransitionDuration, 0.25s) - var(--token-themeTransitionTiming, ease-in-out), - border-color var(--token-themeTransitionDuration, 0.25s) - var(--token-themeTransitionTiming, ease-in-out), - box-shadow var(--token-themeTransitionDuration, 0.25s) - var(--token-themeTransitionTiming, ease-in-out); - z-index: var(--token-zIndexDrawerContent, 1101); -} - -.drawer.drawer--open { - transform: translateX(0); - opacity: 1; -} - -/* ============================================================================ - * Header - * ============================================================================ */ - -.drawer__header { - padding: var(--token-paddingLG, 1.5rem); - border-bottom: 1px solid var(--token-colorBorder, #d9d9d9); - flex-shrink: 0; -} - -.drawer__title-row { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--token-paddingMD, 1rem); -} - -.drawer__title { - font-size: var(--token-fontSizeHeading4, 1.25rem); - font-weight: 600; - color: var(--token-colorText, #141414); - margin: 0; -} - -.drawer__subtitle { - font-size: var(--token-fontSizeSM, 0.875rem); - color: var(--token-colorTextSecondary, #6b7280); - margin: var(--token-paddingSM, 0.5rem) 0 0 0; - overflow: hidden; - text-overflow: ellipsis; -} - -.drawer__close-btn.drawer__close-btn { - display: flex; - align-items: center; - justify-content: center; - width: 2rem; - height: 2rem; - border: none; - background: transparent; - border-radius: var(--token-borderRadius, 6px); - color: var(--token-colorTextSecondary, #6b7280); - cursor: pointer; - flex-shrink: 0; - /* Merge interaction transitions (background-color, color) with theme - * color transition (border-color) so both hover effects and theme - * switching work correctly. */ - transition: - background-color var(--token-motionDurationFast, 0.15s) ease, - color var(--token-motionDurationFast, 0.15s) ease, - border-color var(--token-themeTransitionDuration, 0.25s) - var(--token-themeTransitionTiming, ease-in-out); -} - -.drawer__close-btn:hover { - background-color: var(--token-colorFillSecondary, #f3f4f6); - color: var(--token-colorText, #141414); -} - -.drawer__close-btn:focus-visible { - outline: var(--token-focusRingWidth, 2px) var(--token-focusRingStyle, solid) - var(--token-focusRingColor, var(--token-colorPrimary)); - outline-offset: var(--token-focusRingOffset, 2px); -} - -/* ============================================================================ - * Content - * ============================================================================ */ - -.drawer__content { - flex: 1; - overflow-y: auto; - overflow-x: hidden; -} - -/* ============================================================================ - * Footer - * ============================================================================ */ - -.drawer__footer { - padding: var(--token-paddingMD, 1rem) var(--token-paddingLG, 1.5rem); - border-top: 1px solid var(--token-colorBorder, #d9d9d9); - background-color: var(--token-colorFillSecondary, #f3f4f6); - flex-shrink: 0; -} - -/* ============================================================================ - * Accessibility - Reduced Motion - * - * Uses doubled-class specificity to match the main rules above, ensuring - * transition: none takes effect even if transitions.css appears later. - * ============================================================================ */ - -@media (prefers-reduced-motion: reduce) { - .drawer__backdrop.drawer__backdrop, - .drawer.drawer, - .drawer__close-btn.drawer__close-btn { - transition: none; - } - - .drawer__backdrop--open.drawer__backdrop--open { - transition: none; - } -} - -/* ============================================================================ - * Accessibility - Reduced Transparency - * ============================================================================ */ - -@media (prefers-reduced-transparency: reduce) { - .drawer__backdrop.drawer__backdrop { - backdrop-filter: none; - -webkit-backdrop-filter: none; - background-color: rgba(0, 0, 0, 0.65); - } -} - -/* ============================================================================ - * Responsive - Mobile - * ============================================================================ */ - -@media (max-width: 767px) { - .drawer { - width: 100vw !important; - max-width: 100vw !important; - } - - .drawer__header { - padding: var(--token-paddingMD, 1rem); - } - - .drawer__footer { - padding: var(--token-paddingMD, 1rem); - } -} - -/* ============================================================================ - * Shake for a prevented dismissal - * - * Only runs while the drawer is open (transform: translateX(0)), so the - * keyframes start and end at translateX(0) and revert cleanly to the open - * transform. - * ============================================================================ */ - -@keyframes drawer-shake { - 0%, - 100% { - transform: translateX(0); - } - 10%, - 30%, - 50%, - 70%, - 90% { - transform: translateX(-4px); - } - 20%, - 40%, - 60%, - 80% { - transform: translateX(4px); - } -} - -.drawer--shaking { - animation: drawer-shake 0.4s ease-in-out; -} - -@media (prefers-reduced-motion: reduce) { - .drawer--shaking { - animation: none; - } -} diff --git a/src/components/Drawer/Drawer.test.tsx b/src/components/Drawer/Drawer.test.tsx deleted file mode 100644 index 7aa5ca4..0000000 --- a/src/components/Drawer/Drawer.test.tsx +++ /dev/null @@ -1,558 +0,0 @@ -/** - * Drawer Component Tests - * - * Verifies rendering, open/closed state CSS classes, accessibility attributes, - * and interaction behavior of the shared Drawer component. - */ - -import { useState } from "react"; -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen, fireEvent, act } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { Drawer } from "./Drawer"; -import type { DrawerProps } from "./Drawer"; - -const defaultProps = { - isOpen: false, - onClose: vi.fn(), - title: "Test Drawer", - children:
Drawer Content
, -}; - -async function flushDrawerOpenAnimation(): Promise { - await act(async () => { - await new Promise((resolve) => { - requestAnimationFrame(() => { - requestAnimationFrame(() => resolve()); - }); - }); - }); -} - -describe("Drawer", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - describe("Rendering", () => { - it("renders drawer with title and content", () => { - render(); - - expect(screen.getByText("Test Drawer")).toBeInTheDocument(); - expect(screen.getByText("Drawer Content")).toBeInTheDocument(); - }); - - it("renders subtitle when provided", () => { - render(); - - expect(screen.getByText("Test Subtitle")).toBeInTheDocument(); - }); - - it("does not render subtitle when not provided", () => { - const { container } = render(); - - expect(container.querySelector(".drawer__subtitle")).not.toBeInTheDocument(); - }); - - it("renders footer when provided", () => { - render( - Footer Content
} />, - ); - - expect(screen.getByText("Footer Content")).toBeInTheDocument(); - }); - - it("does not render footer when not provided", () => { - const { container } = render(); - - expect(container.querySelector(".drawer__footer")).not.toBeInTheDocument(); - }); - - it("renders close button", () => { - const { container } = render(); - - const closeButton = container.querySelector(".drawer__close-btn"); - expect(closeButton).toBeInTheDocument(); - expect(closeButton).toHaveAttribute("aria-label", "Close"); - }); - - it("renders custom closeLabel when provided", () => { - const { container } = render( - , - ); - - const closeButton = container.querySelector(".drawer__close-btn"); - expect(closeButton).toHaveAttribute("aria-label", "Dismiss panel"); - }); - }); - - describe("CSS class structure - specificity fix", () => { - it("always applies the base 'drawer' class to the aside element", () => { - const { container } = render(); - - const aside = container.querySelector("aside"); - expect(aside).toBeInTheDocument(); - expect(aside).toHaveClass("drawer"); - }); - - it("applies 'drawer--open' class alongside 'drawer' class when open", async () => { - const { container } = render(); - - // Wait for animation frame to fire (double rAF in component) - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 50)); - }); - - const aside = container.querySelector("aside"); - expect(aside).toHaveClass("drawer"); - expect(aside).toHaveClass("drawer--open"); - }); - - it("does not apply 'drawer--open' class when closed", () => { - const { container } = render(); - - const aside = container.querySelector("aside"); - expect(aside).toHaveClass("drawer"); - expect(aside).not.toHaveClass("drawer--open"); - }); - - it("removes 'drawer--open' class when toggled from open to closed", async () => { - const { container, rerender } = render( - , - ); - - // Wait for the open animation to trigger - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 50)); - }); - - const aside = container.querySelector("aside"); - expect(aside).toHaveClass("drawer--open"); - - rerender(); - - expect(aside).not.toHaveClass("drawer--open"); - }); - - it("applies both 'drawer' and 'drawer--open' classes simultaneously when open (required for .drawer.drawer--open selector)", async () => { - const { container } = render(); - - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 50)); - }); - - const aside = container.querySelector("aside.drawer.drawer--open"); - expect(aside).toBeInTheDocument(); - }); - }); - - describe("Backdrop CSS classes", () => { - it("applies base 'drawer__backdrop' class at all times", () => { - const { container } = render(); - - expect(container.querySelector(".drawer__backdrop")).toBeInTheDocument(); - }); - - it("applies 'drawer__backdrop--open' class when drawer is open", async () => { - const { container } = render(); - - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 50)); - }); - - expect( - container.querySelector(".drawer__backdrop.drawer__backdrop--open"), - ).toBeInTheDocument(); - }); - - it("does not apply 'drawer__backdrop--open' class when drawer is closed", () => { - const { container } = render(); - - expect( - container.querySelector(".drawer__backdrop--open"), - ).not.toBeInTheDocument(); - }); - }); - - describe("Custom className", () => { - it("applies additional className to the drawer panel", async () => { - const { container } = render( - , - ); - - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 50)); - }); - - const aside = container.querySelector("aside"); - expect(aside).toHaveClass("drawer"); - expect(aside).toHaveClass("custom-drawer"); - }); - }); - - describe("Width presets", () => { - it("applies narrow width preset (400px)", () => { - const { container } = render( - , - ); - - const aside = container.querySelector("aside"); - expect(aside).toHaveStyle({ width: "400px" }); - }); - - it("applies medium width preset (520px) by default", () => { - const { container } = render(); - - const aside = container.querySelector("aside"); - expect(aside).toHaveStyle({ width: "520px" }); - }); - - it("applies wide width preset (900px)", () => { - const { container } = render( - , - ); - - const aside = container.querySelector("aside"); - expect(aside).toHaveStyle({ width: "900px" }); - }); - - it("applies custom width value", () => { - const { container } = render( - , - ); - - const aside = container.querySelector("aside"); - expect(aside).toHaveStyle({ width: "640px" }); - }); - }); - - describe("Accessibility", () => { - it("applies role='dialog' and aria-modal to the aside element", () => { - const { container } = render(); - - const dialog = container.querySelector("[role='dialog']"); - expect(dialog).toBeInTheDocument(); - expect(dialog).toHaveAttribute("aria-modal", "true"); - }); - - it("sets aria-hidden on backdrop when closed", () => { - const { container } = render(); - - const backdrop = container.querySelector("[role='presentation']"); - expect(backdrop).toHaveAttribute("aria-hidden", "true"); - }); - - it("sets aria-hidden to false on backdrop when open", () => { - const { container } = render(); - - const backdrop = container.querySelector("[role='presentation']"); - expect(backdrop).toHaveAttribute("aria-hidden", "false"); - }); - - it("connects title via aria-labelledby", () => { - const { container } = render(); - - const dialog = container.querySelector("[role='dialog']"); - expect(dialog).toBeTruthy(); - - const labelId = dialog!.getAttribute("aria-labelledby") ?? ""; - expect(labelId).toBeTruthy(); - - const titleEl = container.querySelector(`#${labelId}`); - expect(titleEl).toHaveTextContent("Test Drawer"); - }); - - it("gives mounted drawers distinct accessible names and descriptions", async () => { - render( - <> - - - , - ); - await flushDrawerOpenAnimation(); - - const dialogs = document.querySelectorAll("[role='dialog']"); - expect(dialogs[0]?.getAttribute("aria-labelledby")).not.toBe( - dialogs[1]?.getAttribute("aria-labelledby"), - ); - expect(dialogs[0]?.getAttribute("aria-describedby")).not.toBe( - dialogs[1]?.getAttribute("aria-describedby"), - ); - const openDialog = screen.getByRole("dialog", { name: "Second drawer" }); - expect(openDialog).toHaveAccessibleDescription("Second description"); - }); - - it("preserves supplied IDs and omits a default description without a subtitle", async () => { - const { rerender } = render( - , - ); - await flushDrawerOpenAnimation(); - const dialog = screen.getByRole("dialog"); - expect(dialog).toHaveAttribute("aria-labelledby", "custom-title"); - expect(dialog).toHaveAttribute("aria-describedby", "custom-description"); - - rerender(); - expect(screen.getByRole("dialog")).not.toHaveAttribute("aria-describedby"); - }); - }); - - describe("Close behavior", () => { - it("calls onClose when close button is clicked", () => { - const onClose = vi.fn(); - const { container } = render( - , - ); - - const closeButton = container.querySelector( - ".drawer__close-btn", - ) as HTMLButtonElement; - fireEvent.click(closeButton); - - expect(onClose).toHaveBeenCalledTimes(1); - }); - - it("calls onClose when backdrop is clicked", () => { - const onClose = vi.fn(); - const { container } = render( - , - ); - - const backdrop = container.querySelector(".drawer__backdrop")!; - fireEvent.click(backdrop); - - expect(onClose).toHaveBeenCalledTimes(1); - }); - - it("does not call onClose when clicking inside the drawer panel", () => { - const onClose = vi.fn(); - const { container } = render( - - - , - ); - - const innerButton = container.querySelector(".inner-button") as HTMLButtonElement; - fireEvent.click(innerButton); - - expect(onClose).not.toHaveBeenCalled(); - }); - - it("calls onClose when Escape key is pressed", async () => { - const onClose = vi.fn(); - const { container } = render( - , - ); - - await flushDrawerOpenAnimation(); - - const aside = container.querySelector("aside")!; - fireEvent.keyDown(aside, { key: "Escape" }); - - expect(onClose).toHaveBeenCalledTimes(1); - }); - }); -}); - -describe("preventDismiss", () => { - function open(props: Partial = {}) { - const onClose = vi.fn(); - const onDismissAttempt = vi.fn(); - const view = render( - -

body

-
, - ); - return { ...view, onClose, onDismissAttempt }; - } - - it("refuses Escape and reports the attempt instead", async () => { - const { container, onClose, onDismissAttempt } = open(); - await flushDrawerOpenAnimation(); - fireEvent.keyDown(container.querySelector(".drawer") as Element, { - key: "Escape", - }); - expect(onClose).not.toHaveBeenCalled(); - expect(onDismissAttempt).toHaveBeenCalledTimes(1); - }); - - it("refuses a backdrop click and reports the attempt instead", () => { - const { container, onClose, onDismissAttempt } = open(); - const backdrop = container.querySelector(".drawer__backdrop"); - fireEvent.click(backdrop as Element); - expect(onClose).not.toHaveBeenCalled(); - expect(onDismissAttempt).toHaveBeenCalledTimes(1); - }); - - it("shakes the panel", async () => { - const { container } = open(); - await flushDrawerOpenAnimation(); - const panel = container.querySelector(".drawer") as Element; - fireEvent.keyDown(panel, { key: "Escape" }); - expect(panel).toHaveClass("drawer--shaking"); - }); - - // The class is cleared by `onAnimationEnd`, which jsdom cannot drive: it has - // no `AnimationEvent` constructor, and a synthesised `animationend` never - // reaches React's handler. Asserting it here would mean asserting a mock, so - // the reset is left to a real browser. - - // The close button is documented as bypassing the guard, so a consumer can - // route it through its own confirmation rather than being unable to close. - it("leaves the close button calling onClose", () => { - const { onClose } = open(); - fireEvent.click(screen.getByLabelText("Close")); - expect(onClose).toHaveBeenCalledTimes(1); - }); - - it("closes normally when the guard is off", async () => { - const { container, onClose } = open({ preventDismiss: false }); - await flushDrawerOpenAnimation(); - fireEvent.keyDown(container.querySelector(".drawer") as Element, { - key: "Escape", - }); - expect(onClose).toHaveBeenCalledTimes(1); - }); -}); - -describe("focus management", () => { - it("focuses after the drawer becomes visibly open and restores the trigger", async () => { - const visibleWhenFocused: boolean[] = []; - const originalFocus = HTMLElement.prototype.focus; - const focusSpy = vi - .spyOn(HTMLElement.prototype, "focus") - .mockImplementation(function (this: HTMLElement, options?: FocusOptions) { - if (this.classList.contains("drawer__close-btn")) { - visibleWhenFocused.push(Boolean(this.closest(".drawer__backdrop--open"))); - } - originalFocus.call(this, options); - }); - - function Harness() { - const [open, setOpen] = useState(false); - return ( - <> - - setOpen(false)} title="Detail"> - - - - ); - } - - try { - render(); - const trigger = screen.getByRole("button", { name: "Open drawer" }); - trigger.focus(); - fireEvent.click(trigger); - await flushDrawerOpenAnimation(); - - expect(visibleWhenFocused).toEqual([true]); - expect(screen.getByRole("button", { name: "Close" })).toHaveFocus(); - fireEvent.keyDown(screen.getByRole("dialog"), { key: "Escape" }); - expect(trigger).toHaveFocus(); - } finally { - focusSpy.mockRestore(); - } - }); - - it("keeps focus in place when onClose changes and invokes its latest value", async () => { - const previousClose = vi.fn(); - const latestClose = vi.fn(); - const { rerender } = render( - - - , - ); - await flushDrawerOpenAnimation(); - const field = screen.getByRole("textbox", { name: "Detail field" }); - field.focus(); - - rerender( - - - , - ); - - expect(field).toHaveFocus(); - fireEvent.keyDown(screen.getByRole("dialog"), { key: "Escape" }); - expect(previousClose).not.toHaveBeenCalled(); - expect(latestClose).toHaveBeenCalledOnce(); - }); - - it("wraps Tab and Shift+Tab across the initial focusable controls", async () => { - render( - Save} - > - - , - ); - await flushDrawerOpenAnimation(); - const closeButton = screen.getByRole("button", { name: "Close" }); - const saveButton = screen.getByRole("button", { name: "Save" }); - - expect(closeButton).toHaveFocus(); - fireEvent.keyDown(closeButton, { key: "Tab", shiftKey: true }); - expect(saveButton).toHaveFocus(); - fireEvent.keyDown(saveButton, { key: "Tab" }); - expect(closeButton).toHaveFocus(); - }); -}); - -describe("closed drawer is inert", () => { - it("keeps its subtree out of the tab sequence", async () => { - const user = userEvent.setup(); - render( - <> - - - - - - , - ); - - const backdrop = document.querySelector(".drawer__backdrop"); - expect(backdrop).toHaveAttribute("inert"); - expect(backdrop).toHaveAttribute("aria-hidden", "true"); - - // aria-hidden alone would leave the input reachable here. - screen.getByRole("button", { name: "Before" }).focus(); - await user.tab(); - expect(screen.getByRole("button", { name: "After" })).toHaveFocus(); - }); - - it("drops inert once open", () => { - render( - - - , - ); - const backdrop = document.querySelector(".drawer__backdrop"); - expect(backdrop).not.toHaveAttribute("inert"); - expect(backdrop).toHaveAttribute("aria-hidden", "false"); - }); -}); diff --git a/src/components/Drawer/Drawer.tsx b/src/components/Drawer/Drawer.tsx deleted file mode 100644 index b1dbaeb..0000000 --- a/src/components/Drawer/Drawer.tsx +++ /dev/null @@ -1,276 +0,0 @@ -/** - * Drawer Component - * - * A reusable right-side drawer panel with backdrop, animations, and accessibility features. - * Consolidates common patterns from all drawer implementations. - */ - -import { useEffect, useRef, useCallback, useId, useState } from "react"; -import "./Drawer.css"; - -export interface DrawerProps { - /** Whether the drawer is open */ - isOpen: boolean; - /** Callback when drawer should close */ - onClose: () => void; - /** Drawer title */ - title: React.ReactNode; - /** Optional subtitle */ - subtitle?: React.ReactNode; - /** Drawer width preset or custom CSS width value */ - width?: "narrow" | "medium" | "wide" | (string & Record); - /** Drawer content */ - children: React.ReactNode; - /** Optional footer content */ - footer?: React.ReactNode; - /** Additional CSS class for drawer panel */ - className?: string; - /** ARIA labelledby ID (defaults to internal title ID) */ - ariaLabelledBy?: string; - /** ARIA describedby ID (defaults to internal subtitle ID if subtitle exists) */ - ariaDescribedBy?: string; - /** Accessible label for the close button. Default: "Close" */ - closeLabel?: string; - /** - * Refuse dismissal by Escape or a backdrop click, shaking the panel instead. - * The close button and any footer control still call `onClose` directly, so - * route those through the same guard when they also need confirming. - */ - preventDismiss?: boolean; - /** - * Called when `preventDismiss` blocked a dismissal, so the consumer can offer - * an explicit way out (a discard confirmation, for instance). - */ - onDismissAttempt?: () => void; -} - -const WIDTH_PRESETS = { - narrow: "400px", - medium: "520px", - wide: "900px", -}; - -export function Drawer({ - isOpen, - onClose, - title, - subtitle, - width = "medium", - children, - footer, - className = "", - ariaLabelledBy, - ariaDescribedBy, - closeLabel = "Close", - preventDismiss = false, - onDismissAttempt, -}: DrawerProps) { - const drawerRef = useRef(null); - const generatedId = useId(); - - const [isShaking, setIsShaking] = useState(false); - - // Read the guard through refs so `blockDismiss` keeps a stable identity. The - // focus-management effect depends on it, and if the identity changed the - // moment the consumer marks the drawer dirty, that effect would re-run on the - // first keystroke and steal focus from the field being typed into. - const preventDismissRef = useRef(preventDismiss); - preventDismissRef.current = preventDismiss; - const onDismissAttemptRef = useRef(onDismissAttempt); - onDismissAttemptRef.current = onDismissAttempt; - const onCloseRef = useRef(onClose); - onCloseRef.current = onClose; - - const requestClose = useCallback(() => { - onCloseRef.current(); - }, []); - - /** Blocks a dismissal when the guard is on. Returns whether it blocked. */ - const blockDismiss = useCallback(() => { - if (!preventDismissRef.current) return false; - setIsShaking(true); - onDismissAttemptRef.current?.(); - return true; - }, []); - - // Guarded on the animation name so an unrelated animation on the panel does - // not clear the flag early. - const handleAnimationEnd = useCallback((e: React.AnimationEvent) => { - if (e.animationName === "drawer-shake") setIsShaking(false); - }, []); - const previousActiveElementRef = useRef(null); - const closeButtonRef = useRef(null); - - // Track if this is the first render to enable animation on initial open - // Without this, when the component mounts with isOpen=true, there's no - // transition because CSS transitions only apply to state changes, not initial renders. - const [shouldAnimate, setShouldAnimate] = useState(false); - const hasInitialized = useRef(false); - - useEffect(() => { - if (isOpen && !hasInitialized.current) { - // First time opening - delay adding open class to trigger animation - hasInitialized.current = true; - // Use double rAF to ensure the closed state is painted first - requestAnimationFrame(() => { - requestAnimationFrame(() => { - setShouldAnimate(true); - }); - }); - } else if (isOpen) { - setShouldAnimate(true); - } else { - setShouldAnimate(false); - } - }, [isOpen]); - - // Compute the actual open state for CSS classes - const isVisuallyOpen = isOpen && shouldAnimate; - - // Compute width value - const widthValue = - width in WIDTH_PRESETS ? WIDTH_PRESETS[width as keyof typeof WIDTH_PRESETS] : width; - - // Internal IDs for ARIA - const titleId = ariaLabelledBy ?? `${generatedId}-title`; - const subtitleId = - ariaDescribedBy ?? (subtitle ? `${generatedId}-subtitle` : undefined); - - // Focus management and trap - useEffect(() => { - // Wait until the open classes have committed so focus never enters a - // backdrop that is still hidden and non-interactive. - if (!isVisuallyOpen) return; - - // Store previously focused element - previousActiveElementRef.current = document.activeElement as HTMLElement; - - const drawer = drawerRef.current; - if (!drawer) return; - - // Focus close button when drawer opens - closeButtonRef.current?.focus(); - - // Setup focus trap - const focusableElements = drawer.querySelectorAll( - 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', - ); - - const firstFocusable = focusableElements[0]; - const lastFocusable = focusableElements[focusableElements.length - 1]; - - const handleKeyDown = (e: KeyboardEvent) => { - // Escape to close, unless the guard blocks it - if (e.key === "Escape") { - if (blockDismiss()) return; - requestClose(); - return; - } - - // Tab key focus trap - if (e.key === "Tab") { - if (e.shiftKey) { - // Shift + Tab - if (document.activeElement === firstFocusable) { - e.preventDefault(); - lastFocusable?.focus(); - } - } else { - // Tab - if (document.activeElement === lastFocusable) { - e.preventDefault(); - firstFocusable?.focus(); - } - } - } - }; - - drawer.addEventListener("keydown", handleKeyDown); - - return () => { - drawer.removeEventListener("keydown", handleKeyDown); - // Restore focus when drawer closes - if (previousActiveElementRef.current) { - previousActiveElementRef.current.focus(); - } - }; - }, [isVisuallyOpen, requestClose, blockDismiss]); - - // Handle backdrop click - const handleBackdropClick = useCallback( - (e: React.MouseEvent) => { - if (e.target === e.currentTarget) { - if (blockDismiss()) return; - requestClose(); - } - }, - [requestClose, blockDismiss], - ); - - return ( -
- -
- ); -} diff --git a/src/components/Drawer/index.ts b/src/components/Drawer/index.ts deleted file mode 100644 index 1108ca1..0000000 --- a/src/components/Drawer/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { Drawer } from "./Drawer"; -export type { DrawerProps } from "./Drawer"; diff --git a/src/components/EmptyState/EmptyState.css b/src/components/EmptyState/EmptyState.css deleted file mode 100644 index 6b16773..0000000 --- a/src/components/EmptyState/EmptyState.css +++ /dev/null @@ -1,341 +0,0 @@ -/** - * EmptyState Component Styles - * - * Unified empty state styling following Backend.AI Design System. - * Uses design tokens for consistent theming across light/dark modes. - */ - -/* ============================================ - * Base Container - * ============================================ */ - -.empty-state { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - padding: var(--token-paddingXL, 2rem) var(--token-paddingLG, 1.5rem); - text-align: center; - min-height: 300px; - animation: empty-state-fade-in 0.5s ease-out; -} - -@keyframes empty-state-fade-in { - from { - opacity: 0; - transform: translateY(10px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -/* ============================================ - * Illustration - * ============================================ */ - -.empty-state__illustration { - position: relative; - margin-bottom: var(--token-paddingLG, 1.5rem); -} - -.empty-state__icon { - color: var(--token-colorTextQuaternary, #bfbfbf); - transition: color var(--token-motionDurationMid, 0.2s); -} - -/* Decorative background glow */ -.empty-state__illustration::before { - content: ""; - position: absolute; - inset: -20px; - background: radial-gradient( - circle, - color-mix(in srgb, var(--token-colorPrimary, #8b5cf6) 8%, transparent) 0%, - transparent 70% - ); - border-radius: 50%; - z-index: -1; - opacity: 0; - animation: glow-pulse 3s ease-in-out infinite; - will-change: opacity, transform; -} - -@keyframes glow-pulse { - 0%, - 100% { - opacity: 0; - transform: scale(0.95); - } - 50% { - opacity: 1; - transform: scale(1); - } -} - -/* Illustration animations */ -.illustration-sparkle { - animation: sparkle-twinkle 2s ease-in-out infinite; - transform-origin: center; - will-change: opacity, transform; -} - -@keyframes sparkle-twinkle { - 0%, - 100% { - opacity: 0.4; - transform: scale(0.8); - } - 50% { - opacity: 1; - transform: scale(1.2); - } -} - -.illustration-arrow { - animation: arrow-bounce 2s ease-in-out infinite; - will-change: transform; -} - -@keyframes arrow-bounce { - 0%, - 100% { - transform: translateY(0); - } - 50% { - transform: translateY(5px); - } -} - -.illustration-needle { - animation: needle-swing 3s ease-in-out infinite; - transform-origin: 256px 308px; - will-change: transform; -} - -@keyframes needle-swing { - 0% { - transform: rotate(-12deg); - } - 50% { - transform: rotate(12deg); - } - 100% { - transform: rotate(-12deg); - } -} - -/* ============================================ - * Content - * ============================================ */ - -.empty-state__content { - margin-bottom: var(--token-paddingLG, 1.5rem); - max-width: 480px; -} - -.empty-state__title { - margin: 0 0 var(--token-paddingSM, 0.5rem) 0; - font-size: var(--token-fontSizeXL, 1.25rem); - font-weight: 600; - color: var(--token-colorText, #141414); - line-height: 1.4; -} - -.empty-state__description { - margin: 0; - font-size: var(--token-fontSize, 0.875rem); - color: var(--token-colorTextSecondary, #595959); - line-height: 1.6; -} - -/* ============================================ - * Actions - * ============================================ */ - -.empty-state__actions { - display: flex; - gap: var(--token-paddingMD, 1rem); - align-items: center; - justify-content: center; - flex-wrap: wrap; -} - -.empty-state__action-btn { - min-width: 120px; -} - -.empty-state__action-link { - font-size: var(--token-fontSize, 0.875rem); - color: var(--token-colorLink, #8b5cf6); - text-decoration: none; - padding: var(--token-paddingXS, 0.25rem) var(--token-paddingSM, 0.5rem); - border-radius: var(--token-borderRadiusSM, 0.25rem); - transition: - color var(--token-motionDurationFast, 0.1s), - background-color var(--token-motionDurationFast, 0.1s); -} - -.empty-state__action-link:hover { - color: var(--token-colorLinkHover, #7c3aed); - background-color: var(--token-colorFillTertiary, #f9fafb); -} - -.empty-state__action-link:focus { - outline: var(--token-focusRingWidth, 2px) var(--token-focusRingStyle, solid) - var(--token-focusRingColor, var(--token-colorPrimary)); - outline-offset: var(--token-focusRingOffset, 2px); -} - -/* ============================================ - * Illustration Type Variants - * ============================================ */ - -/* Chat - Warm orange glow */ -.empty-state--chat .empty-state__illustration::before { - background: radial-gradient( - circle, - var(--token-colorPrimaryBg, rgba(139, 92, 246, 0.1)) 0%, - transparent 70% - ); -} - -/* Models - Blue/tech glow */ -.empty-state--models .empty-state__illustration::before { - background: radial-gradient(circle, rgba(24, 144, 255, 0.08) 0%, transparent 70%); -} - -/* Creations - Purple/creative glow */ -.empty-state--creations .empty-state__illustration::before { - background: radial-gradient(circle, rgba(114, 46, 209, 0.08) 0%, transparent 70%); -} - -/* Text - Indigo/document glow */ -.empty-state--text .empty-state__illustration::before { - background: radial-gradient(circle, rgba(82, 99, 209, 0.08) 0%, transparent 70%); -} - -/* Benchmark - Green/performance glow */ -.empty-state--benchmark .empty-state__illustration::before { - background: radial-gradient(circle, rgba(0, 189, 155, 0.08) 0%, transparent 70%); -} - -/* Statistics - Teal/data glow */ -.empty-state--statistics .empty-state__illustration::before { - background: radial-gradient(circle, rgba(19, 194, 194, 0.08) 0%, transparent 70%); -} - -/* Error - Red/warning glow */ -.empty-state--error .empty-state__illustration::before { - background: radial-gradient(circle, rgba(255, 77, 79, 0.08) 0%, transparent 70%); -} - -/* ============================================ - * Dark Theme Adjustments - * ============================================ */ - -@media (prefers-color-scheme: dark) { - .empty-state--chat .empty-state__illustration::before { - background: radial-gradient( - circle, - var(--token-colorPrimaryBg, rgba(139, 92, 246, 0.1)) 0%, - transparent 70% - ); - } - - .empty-state--models .empty-state__illustration::before { - background: radial-gradient(circle, rgba(24, 144, 255, 0.12) 0%, transparent 70%); - } - - .empty-state--creations .empty-state__illustration::before { - background: radial-gradient(circle, rgba(114, 46, 209, 0.12) 0%, transparent 70%); - } - - .empty-state--text .empty-state__illustration::before { - background: radial-gradient(circle, rgba(82, 99, 209, 0.12) 0%, transparent 70%); - } - - .empty-state--benchmark .empty-state__illustration::before { - background: radial-gradient(circle, rgba(0, 189, 155, 0.12) 0%, transparent 70%); - } - - .empty-state--statistics .empty-state__illustration::before { - background: radial-gradient(circle, rgba(19, 194, 194, 0.12) 0%, transparent 70%); - } - - .empty-state--error .empty-state__illustration::before { - background: radial-gradient(circle, rgba(255, 77, 79, 0.12) 0%, transparent 70%); - } -} - -/* ============================================ - * Reduced Motion Support - * ============================================ */ - -@media (prefers-reduced-motion: reduce) { - .empty-state { - animation: none; - } - - .empty-state__illustration::before { - animation: none; - opacity: 0.5; - } - - .illustration-sparkle, - .illustration-arrow, - .illustration-needle { - animation: none; - } -} - -/* ============================================ - * Mobile Responsive - * ============================================ */ - -@media (max-width: 768px) { - .empty-state { - padding: var(--token-paddingLG, 1.5rem) var(--token-paddingMD, 1rem); - min-height: 250px; - } - - .empty-state__illustration { - margin-bottom: var(--token-paddingMD, 1rem); - } - - .empty-state__icon { - width: 80px; - height: 80px; - } - - .empty-state__title { - font-size: var(--token-fontSizeLG, 1rem); - } - - .empty-state__description { - font-size: var(--token-fontSize, 0.875rem); - } - - .empty-state__actions { - flex-direction: column; - width: 100%; - gap: var(--token-paddingSM, 0.5rem); - } - - .empty-state__action-btn { - width: 100%; - min-width: unset; - } -} - -@media (max-width: 480px) { - .empty-state__icon { - width: 64px; - height: 64px; - } - - .empty-state__title { - font-size: var(--token-fontSizeLG, 1rem); - } -} diff --git a/src/components/EmptyState/EmptyState.example.tsx b/src/components/EmptyState/EmptyState.example.tsx deleted file mode 100644 index 7b72235..0000000 --- a/src/components/EmptyState/EmptyState.example.tsx +++ /dev/null @@ -1,193 +0,0 @@ -/** - * EmptyState Component Examples - * - * Demonstrates usage of EmptyState component in different contexts. - * Note: This file is for documentation/demonstration purposes only. - */ - -import { EmptyState } from "./EmptyState"; - -// Example action handlers (no-op for demo purposes) -const handleNewChat = () => { - // Implementation would go here -}; -const handleBrowseModels = () => { - // Implementation would go here -}; -const handleOpenFolder = () => { - // Implementation would go here -}; -const handleCreate = () => { - // Implementation would go here -}; -const handleStartBenchmark = () => { - // Implementation would go here -}; -const handleViewHistory = () => { - // Implementation would go here -}; -const handleRetry = () => { - // Implementation would go here -}; -const handleReportIssue = () => { - // Implementation would go here -}; -const handleAddItem = () => { - // Implementation would go here -}; -const handleTakeAction = () => { - // Implementation would go here -}; - -export function EmptyStateExamples() { - return ( -
- {/* Chat Empty State */} -
-

Chat Empty State

- } - title="No conversations yet" - description="Start a new chat to begin interacting with your AI model" - primaryAction={{ - label: "New Chat", - onClick: handleNewChat, - }} - secondaryAction={{ - label: "Learn more", - href: "#learn-more", - }} - /> -
- - {/* Models Empty State */} -
-

Models Empty State

- } - title="No models installed" - description="Download a model from Hugging Face to get started" - primaryAction={{ - label: "Browse Models", - onClick: handleBrowseModels, - }} - secondaryAction={{ - label: "Open Folder", - onClick: handleOpenFolder, - }} - /> -
- - {/* Creations Empty State */} -
-

Creations Empty State

- } - title="No creations yet" - description="Your generated images and content will appear here" - primaryAction={{ - label: "Create Something", - onClick: handleCreate, - }} - /> -
- - {/* Benchmark Empty State */} -
-

Benchmark Empty State

- } - title="No benchmarks run" - description="Run a benchmark to compare model performance" - primaryAction={{ - label: "Start Benchmark", - onClick: handleStartBenchmark, - }} - secondaryAction={{ - label: "View History", - onClick: handleViewHistory, - }} - /> -
- - {/* Logs Empty State */} -
-

Logs Empty State

- } - title="No logs available" - description="Application logs will appear here when events occur" - /> -
- - {/* Statistics Empty State */} -
-

Statistics Empty State

- } - title="No statistics yet" - description="Usage statistics will be displayed once you start using the application" - /> -
- - {/* Error Empty State */} -
-

Error Empty State

- } - title="Something went wrong" - description="We encountered an error while loading your data" - primaryAction={{ - label: "Retry", - onClick: handleRetry, - }} - secondaryAction={{ - label: "Report Issue", - onClick: handleReportIssue, - }} - /> -
- - {/* Generic Empty State */} -
-

Generic Empty State

- } - title="No items found" - description="There are no items to display at this time" - primaryAction={{ - label: "Add Item", - onClick: handleAddItem, - }} - /> -
- - {/* Without Illustration */} -
-

Without Illustration

- } - title="Simple Empty State" - description="This example doesn't show an illustration" - showIllustration={false} - primaryAction={{ - label: "Take Action", - onClick: handleTakeAction, - }} - /> -
- - {/* Without Actions */} -
-

Without Actions

- } - title="Informational Only" - description="This empty state provides information without any actions" - /> -
-
- ); -} - -export default EmptyStateExamples; diff --git a/src/components/EmptyState/EmptyState.test.tsx b/src/components/EmptyState/EmptyState.test.tsx deleted file mode 100644 index 842b524..0000000 --- a/src/components/EmptyState/EmptyState.test.tsx +++ /dev/null @@ -1,227 +0,0 @@ -/** - * EmptyState Component Tests - */ - -import { describe, it, expect, vi } from "vitest"; -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { EmptyState } from "./EmptyState"; - -describe("EmptyState", () => { - it("renders with required props", () => { - render( - } - title="Test Title" - description="Test Description" - />, - ); - - expect(screen.getByText("Test Title")).toBeInTheDocument(); - expect(screen.getByText("Test Description")).toBeInTheDocument(); - }); - - it("renders illustration by default", () => { - const { container } = render( - } - title="Test" - description="Test" - />, - ); - - const illustration = container.querySelector(".empty-state__illustration"); - expect(illustration).toBeInTheDocument(); - }); - - it("hides illustration when showIllustration is false", () => { - const { container } = render( - } - title="Test" - description="Test" - showIllustration={false} - />, - ); - - const illustration = container.querySelector(".empty-state__illustration"); - expect(illustration).not.toBeInTheDocument(); - }); - - it("renders primary action button", () => { - const handleClick = vi.fn(); - render( - } - title="Test" - description="Test" - primaryAction={{ - label: "Click Me", - onClick: handleClick, - }} - />, - ); - - const button = screen.getByRole("button", { name: "Click Me" }); - expect(button).toBeInTheDocument(); - }); - - it("calls primaryAction onClick when clicked", async () => { - const user = userEvent.setup(); - const handleClick = vi.fn(); - - render( - } - title="Test" - description="Test" - primaryAction={{ - label: "Click Me", - onClick: handleClick, - }} - />, - ); - - const button = screen.getByRole("button", { name: "Click Me" }); - await user.click(button); - - expect(handleClick).toHaveBeenCalledTimes(1); - }); - - it("renders secondary action as link when href is provided", () => { - render( - } - title="Test" - description="Test" - secondaryAction={{ - label: "Learn More", - href: "#learn-more", - }} - />, - ); - - const link = screen.getByRole("link", { name: "Learn More" }); - expect(link).toBeInTheDocument(); - expect(link).toHaveAttribute("href", "#learn-more"); - }); - - it("renders secondary action as button when onClick is provided", () => { - const handleClick = vi.fn(); - render( - } - title="Test" - description="Test" - secondaryAction={{ - label: "Action", - onClick: handleClick, - }} - />, - ); - - const button = screen.getByRole("button", { name: "Action" }); - expect(button).toBeInTheDocument(); - }); - - it("calls secondaryAction onClick when clicked", async () => { - const user = userEvent.setup(); - const handleClick = vi.fn(); - - render( - } - title="Test" - description="Test" - secondaryAction={{ - label: "Action", - onClick: handleClick, - }} - />, - ); - - const button = screen.getByRole("button", { name: "Action" }); - await user.click(button); - - expect(handleClick).toHaveBeenCalledTimes(1); - }); - - it("applies custom className", () => { - const { container } = render( - } - title="Test" - description="Test" - className="custom-class" - />, - ); - - const emptyState = container.querySelector(".empty-state"); - expect(emptyState).toHaveClass("custom-class"); - }); - - it("renders the illustration it is given", () => { - render( - } - title="Test" - description="Test" - />, - ); - - // The ten named types this replaced were one product's screens, and the - // modifier class they produced styled nothing. - expect(screen.getByTestId("illustration")).toBeInTheDocument(); - }); - - it("has proper accessibility attributes", () => { - const { container } = render( - } - title="Test" - description="Test" - />, - ); - - const emptyState = container.querySelector(".empty-state"); - expect(emptyState).toHaveAttribute("role", "status"); - expect(emptyState).toHaveAttribute("aria-live", "polite"); - }); - - it("does not render actions section when no actions provided", () => { - const { container } = render( - } - title="Test" - description="Test" - />, - ); - - const actionsSection = container.querySelector(".empty-state__actions"); - expect(actionsSection).not.toBeInTheDocument(); - }); - - it("renders both primary and secondary actions together", () => { - const primaryClick = vi.fn(); - const secondaryClick = vi.fn(); - - render( - } - title="Test" - description="Test" - primaryAction={{ - label: "Primary", - onClick: primaryClick, - }} - secondaryAction={{ - label: "Secondary", - onClick: secondaryClick, - }} - />, - ); - - expect(screen.getByRole("button", { name: "Primary" })).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Secondary" })).toBeInTheDocument(); - }); -}); diff --git a/src/components/EmptyState/EmptyState.tsx b/src/components/EmptyState/EmptyState.tsx deleted file mode 100644 index 467df79..0000000 --- a/src/components/EmptyState/EmptyState.tsx +++ /dev/null @@ -1,152 +0,0 @@ -/** - * EmptyState Component - * - * Unified empty state component with consistent layout and styling. - * Provides configurable illustration, title, description, and call-to-action buttons. - * - * Features: - * - Multiple illustration types for different contexts - * - Primary and secondary action buttons, plus a `children` slot for an - * onboarding state that needs more than two (issue #3903) - * - Accessible (ARIA attributes, semantic HTML) - * - Dark/light theme support via design tokens - * - Fade-in animation with reduced motion support - * - Responsive design - * - Memoized for optimal performance - */ - -import { memo, useCallback, useMemo, type ReactNode } from "react"; -import { Button } from "../Button"; -import "./EmptyState.css"; - -export interface EmptyStateAction { - label: string; - onClick: () => void; -} - -export interface EmptyStateSecondaryAction { - label: string; - href?: string; - onClick?: () => void; -} - -export interface EmptyStateProps { - /** - * The drawing shown above the text. - * - * This used to be one of ten names: chat, models, creations, benchmark, - * logs, statistics, schedule and so on, each resolving to an SVG shipped - * inside this package. Those names are one product's information - * architecture, and no other consumer has a "creations" screen to draw for. - * A consumer passes its own artwork and keeps its own vocabulary. - */ - illustration?: ReactNode; - /** Main heading */ - title: string; - /** Descriptive text */ - description: string; - /** Primary action button */ - primaryAction?: EmptyStateAction; - /** Secondary action (link or button) */ - secondaryAction?: EmptyStateSecondaryAction; - /** - * Extra content rendered under the description, inside the same text column. - * - * The two action props cover the common one-or-two-button case. An - * onboarding state that has to offer more than that (e.g. the Data Hub's - * three ingest paths, issue #3903) renders them here instead of growing a - * page-local copy of this component. - */ - children?: ReactNode; - /** Additional CSS classes */ - className?: string; - /** Whether to show illustration (default: true) */ - showIllustration?: boolean; -} - -/** - * EmptyState Component - * - * Displays an empty state with illustration, text, and optional actions. - * Memoized to prevent unnecessary re-renders. - */ -function EmptyStateComponent({ - illustration, - title, - description, - primaryAction, - secondaryAction, - children, - className = "", - showIllustration = true, -}: EmptyStateProps) { - // Use stable callback references by extracting onClick functions - const primaryOnClick = primaryAction?.onClick; - const secondaryOnClick = secondaryAction?.onClick; - - const handlePrimaryAction = useCallback(() => { - primaryOnClick?.(); - }, [primaryOnClick]); - - const handleSecondaryAction = useCallback(() => { - secondaryOnClick?.(); - }, [secondaryOnClick]); - - // Memoize class name computation - const containerClass = useMemo( - () => ["empty-state", className].filter(Boolean).join(" "), - [className], - ); - - return ( -
- {showIllustration && illustration && ( -
{illustration}
- )} - -
-

{title}

-

{description}

- {children} -
- - {(primaryAction || secondaryAction) && ( -
- {primaryAction && ( - - )} - - {secondaryAction && ( - <> - {secondaryAction.href ? ( - - {secondaryAction.label} - - ) : ( - - )} - - )} -
- )} -
- ); -} - -export const EmptyState = memo(EmptyStateComponent); diff --git a/src/components/EmptyState/index.ts b/src/components/EmptyState/index.ts deleted file mode 100644 index 822c7da..0000000 --- a/src/components/EmptyState/index.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * EmptyState Component - Barrel Export - * - * The ten illustrations that used to ship here were one product's information - * architecture: chat, models, creations, benchmark, logs, statistics, - * schedule. No other consumer has a "creations" screen to draw for. A consumer - * passes its own artwork through `illustration` and keeps its own vocabulary. - */ - -export { EmptyState } from "./EmptyState"; -export type { - EmptyStateProps, - EmptyStateAction, - EmptyStateSecondaryAction, -} from "./EmptyState"; diff --git a/src/components/ProgressBar/ProgressBar.css b/src/components/ProgressBar/ProgressBar.css deleted file mode 100644 index 77c4e66..0000000 --- a/src/components/ProgressBar/ProgressBar.css +++ /dev/null @@ -1,106 +0,0 @@ -/** - * ProgressBar Component Styles - * Uses design system tokens - */ - -.progress-bar-wrapper { - display: flex; - align-items: center; - gap: 0.5rem; - width: 100%; -} - -.progress-bar { - flex: 1; - background: var(--token-colorFillSecondary, #f3f4f6); - border-radius: 999px; - overflow: hidden; - position: relative; -} - -/* Size variants */ -.progress-bar--sm { - height: 4px; -} - -.progress-bar--md { - height: 6px; -} - -.progress-bar--lg { - height: 8px; -} - -/* Fill bar */ -.progress-bar__fill { - height: 100%; - border-radius: 999px; - transition: none; -} - -.progress-bar--animated .progress-bar__fill { - transition: width 0.3s ease; -} - -/* Color variants */ -.progress-bar--primary .progress-bar__fill { - background: var(--token-colorPrimary, #8b5cf6); -} - -.progress-bar--success .progress-bar__fill { - background: var(--token-colorSuccess, #52c41a); -} - -.progress-bar--error .progress-bar__fill { - background: var(--token-colorError, #ff4d4f); -} - -.progress-bar--warning .progress-bar__fill { - background: var(--token-colorWarning, #faad14); -} - -/* Indeterminate state */ -.progress-bar--indeterminate { - background: linear-gradient( - 90deg, - var(--token-colorFillSecondary, #f3f4f6) 0%, - var(--token-colorPrimaryBg, rgba(24, 144, 255, 0.1)) 50%, - var(--token-colorFillSecondary, #f3f4f6) 100% - ); - background-size: 200% 100%; - animation: progress-bar-shimmer 1.5s ease-in-out infinite; -} - -.progress-bar--indeterminate .progress-bar__fill { - display: none; -} - -@keyframes progress-bar-shimmer { - 0% { - background-position: 200% 0; - } - 100% { - background-position: -200% 0; - } -} - -/* Label */ -.progress-bar__label { - font-size: var(--token-fontSizeSM, 0.75rem); - font-weight: 500; - color: var(--token-colorText, #141414); - white-space: nowrap; - min-width: 3ch; - text-align: right; -} - -/* Respect reduced motion preference for accessibility */ -@media (prefers-reduced-motion: reduce) { - .progress-bar--indeterminate { - animation: none; - } - - .progress-bar--animated .progress-bar__fill { - transition: none; - } -} diff --git a/src/components/ProgressBar/ProgressBar.test.tsx b/src/components/ProgressBar/ProgressBar.test.tsx deleted file mode 100644 index 86dfc20..0000000 --- a/src/components/ProgressBar/ProgressBar.test.tsx +++ /dev/null @@ -1,192 +0,0 @@ -/** - * ProgressBar Component Tests - * - * Tests cover: - * - Determinate rendering (value clamping, fill width, aria-valuenow) - * - Indeterminate rendering (value=null) - * - Variant and size class names - * - Label rendering (showLabel percentage, custom label override) - * - Animated class toggle - * - Accessibility attributes (role, aria-value*, aria-busy, aria-label) - * - Custom className passthrough - */ - -import { describe, it, expect } from "vitest"; -import { render, screen } from "@testing-library/react"; -import { ProgressBar } from "./ProgressBar"; - -describe("ProgressBar", () => { - describe("determinate rendering", () => { - it("renders with role progressbar", () => { - render(); - - expect(screen.getByRole("progressbar")).toBeInTheDocument(); - }); - - it("sets aria-valuenow, aria-valuemin, and aria-valuemax", () => { - render(); - - const bar = screen.getByRole("progressbar"); - expect(bar).toHaveAttribute("aria-valuenow", "50"); - expect(bar).toHaveAttribute("aria-valuemin", "0"); - expect(bar).toHaveAttribute("aria-valuemax", "100"); - }); - - it("sets the fill width from value", () => { - const { container } = render(); - - const fill = container.querySelector(".progress-bar__fill"); - expect(fill).toHaveStyle({ width: "75%" }); - }); - - it("clamps values above 100", () => { - render(); - - expect(screen.getByRole("progressbar")).toHaveAttribute("aria-valuenow", "100"); - }); - - it("clamps values below 0", () => { - render(); - - expect(screen.getByRole("progressbar")).toHaveAttribute("aria-valuenow", "0"); - }); - - it("is not aria-busy when determinate", () => { - render(); - - expect(screen.getByRole("progressbar")).toHaveAttribute("aria-busy", "false"); - }); - }); - - describe("indeterminate rendering", () => { - it("applies the indeterminate modifier class when value is null", () => { - const { container } = render(); - - expect(container.querySelector(".progress-bar")).toHaveClass( - "progress-bar--indeterminate", - ); - }); - - it("omits aria-valuenow when indeterminate", () => { - render(); - - expect(screen.getByRole("progressbar")).not.toHaveAttribute("aria-valuenow"); - }); - - it("sets aria-busy when indeterminate", () => { - render(); - - expect(screen.getByRole("progressbar")).toHaveAttribute("aria-busy", "true"); - }); - - it("does not render a percentage label even with showLabel", () => { - render(); - - expect(screen.queryByText("%", { exact: false })).not.toBeInTheDocument(); - }); - }); - - describe("variant", () => { - (["primary", "success", "error", "warning"] as const).forEach((variant) => { - it(`applies progress-bar--${variant} class`, () => { - const { container } = render(); - - expect(container.querySelector(".progress-bar")).toHaveClass( - `progress-bar--${variant}`, - ); - }); - }); - - it("defaults to the primary variant", () => { - const { container } = render(); - - expect(container.querySelector(".progress-bar")).toHaveClass( - "progress-bar--primary", - ); - }); - }); - - describe("size", () => { - (["sm", "md", "lg"] as const).forEach((size) => { - it(`applies progress-bar--${size} class`, () => { - const { container } = render(); - - expect(container.querySelector(".progress-bar")).toHaveClass( - `progress-bar--${size}`, - ); - }); - }); - - it("defaults to the md size", () => { - const { container } = render(); - - expect(container.querySelector(".progress-bar")).toHaveClass("progress-bar--md"); - }); - }); - - describe("label", () => { - it("does not render a label by default", () => { - const { container } = render(); - - expect(container.querySelector(".progress-bar__label")).not.toBeInTheDocument(); - }); - - it("renders a rounded percentage label when showLabel is true", () => { - render(); - - expect(screen.getByText("43%")).toBeInTheDocument(); - }); - - it("renders a custom label overriding the percentage", () => { - render(); - - expect(screen.getByText("Downloading")).toBeInTheDocument(); - expect(screen.queryByText("50%")).not.toBeInTheDocument(); - }); - - it("renders a custom label even without showLabel", () => { - render(); - - expect(screen.getByText("Custom")).toBeInTheDocument(); - }); - }); - - describe("animated", () => { - it("applies the animated class by default", () => { - const { container } = render(); - - expect(container.querySelector(".progress-bar")).toHaveClass( - "progress-bar--animated", - ); - }); - - it("omits the animated class when animated is false", () => { - const { container } = render(); - - expect(container.querySelector(".progress-bar")).not.toHaveClass( - "progress-bar--animated", - ); - }); - }); - - describe("custom props", () => { - it("appends a custom className", () => { - const { container } = render( - , - ); - - const bar = container.querySelector(".progress-bar"); - expect(bar).toHaveClass("progress-bar"); - expect(bar).toHaveClass("custom-progress"); - }); - - it("applies an ariaLabel", () => { - render(); - - expect(screen.getByRole("progressbar")).toHaveAttribute( - "aria-label", - "Download progress", - ); - }); - }); -}); diff --git a/src/components/ProgressBar/ProgressBar.tsx b/src/components/ProgressBar/ProgressBar.tsx deleted file mode 100644 index 6b5c041..0000000 --- a/src/components/ProgressBar/ProgressBar.tsx +++ /dev/null @@ -1,92 +0,0 @@ -/** - * ProgressBar Component - * - * A reusable progress bar component that supports both determinate and - * indeterminate states, with multiple color variants and sizes. - * - * @example - * ```tsx - * // Determinate progress - * - * - * // Indeterminate (loading state) - * - * - * // With variant and label - * - * ``` - */ - -import "./ProgressBar.css"; - -export type ProgressBarVariant = "primary" | "success" | "error" | "warning"; -export type ProgressBarSize = "sm" | "md" | "lg"; - -export interface ProgressBarProps { - /** Progress value (0-100). Pass null for indeterminate state. */ - value: number | null; - /** Color variant */ - variant?: ProgressBarVariant; - /** Size of the progress bar */ - size?: ProgressBarSize; - /** Whether to show percentage label */ - showLabel?: boolean; - /** Custom label text (overrides percentage) */ - label?: string; - /** Additional CSS class names */ - className?: string; - /** Whether to animate the progress fill transition */ - animated?: boolean; - /** Aria label for accessibility */ - ariaLabel?: string; -} - -export function ProgressBar({ - value, - variant = "primary", - size = "md", - showLabel = false, - label, - className = "", - animated = true, - ariaLabel, -}: ProgressBarProps) { - const isIndeterminate = value === null; - const clampedValue = value === null ? 0 : Math.max(0, Math.min(100, value)); - - const containerClasses = [ - "progress-bar", - `progress-bar--${variant}`, - `progress-bar--${size}`, - isIndeterminate ? "progress-bar--indeterminate" : "", - animated ? "progress-bar--animated" : "", - className, - ] - .filter(Boolean) - .join(" "); - - const displayLabel = - label ?? (showLabel && !isIndeterminate ? `${clampedValue.toFixed(0)}%` : null); - - return ( -
-
-
-
- {displayLabel && {displayLabel}} -
- ); -} - -export default ProgressBar; diff --git a/src/components/ProgressBar/index.ts b/src/components/ProgressBar/index.ts deleted file mode 100644 index 5a2cff4..0000000 --- a/src/components/ProgressBar/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export { ProgressBar, default } from "./ProgressBar"; -export type { - ProgressBarProps, - ProgressBarVariant, - ProgressBarSize, -} from "./ProgressBar"; diff --git a/src/components/Select/Select.css b/src/components/Select/Select.css deleted file mode 100644 index e7772fe..0000000 --- a/src/components/Select/Select.css +++ /dev/null @@ -1,356 +0,0 @@ -/** - * Select Component Styles - * - * Elegant dropdown selector with icon and description support. - */ - -/* Container */ -.select { - position: relative; - display: inline-block; -} - -.select--full-width { - display: block; - width: 100%; -} - -/* Labelled variant. Mirrors `TextInput`'s root (a column of label then - control, separated by --token-paddingXS) so a labelled Select and a labelled - TextInput in the same row have identical structure and edges. Declared after - --full-width so it wins the `display` on source order while --full-width - keeps supplying the width. */ -.select--labelled { - display: flex; - flex-direction: column; - gap: var(--token-paddingXS); -} - -.select__label { - font-size: var(--token-fontSizeSM); - font-weight: 500; - color: var(--token-colorTextSecondary); -} - -/* Trigger Button */ -.select__trigger { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--token-paddingSM); - width: 100%; - min-height: var(--token-controlHeight); - padding: var(--token-paddingXS) var(--token-padding); - font-size: var(--token-fontSize); - font-family: var(--token-fontFamily); - color: var(--token-colorText); - background-color: var(--token-colorBgContainer); - border: 1px solid var(--token-colorBorder); - border-radius: var(--token-borderRadius); - cursor: pointer; - transition: all var(--token-motionDurationFast); - text-align: left; -} - -.select__trigger:hover:not(:disabled) { - border-color: var(--token-colorPrimary); -} - -.select__trigger:focus { - outline: none; - border-color: color-mix( - in srgb, - var(--token-colorPrimary) 70%, - var(--token-colorText) - ); - box-shadow: 0 0 0 2px var(--token-colorPrimaryBg); -} - -.select--open .select__trigger { - border-color: var(--token-colorPrimary); - box-shadow: 0 0 0 2px var(--token-colorPrimaryBg); - border-bottom-left-radius: 0; - border-bottom-right-radius: 0; -} - -.select--disabled .select__trigger { - cursor: not-allowed; - opacity: 0.6; - background-color: var(--token-colorFillTertiary); -} - -/* Size variants */ -.select--small .select__trigger { - min-height: var(--token-controlHeightSM); - padding: var(--token-paddingXS) var(--token-paddingSM); - font-size: var(--token-fontSizeSM); -} - -.select--medium .select__trigger { - min-height: var(--token-controlHeight); - padding: var(--token-paddingXS) var(--token-padding); - font-size: var(--token-fontSize); -} - -.select--large .select__trigger { - min-height: var(--token-controlHeightLG); - padding: var(--token-paddingSM) var(--token-padding); - font-size: var(--token-fontSize); -} - -/* Minimum width to prevent too narrow selects */ -.select__trigger { - min-width: 120px; -} - -/* Value display */ -.select__value { - display: flex; - align-items: center; - gap: var(--token-paddingSM); - flex: 1; - min-width: 0; - overflow: hidden; -} - -.select__value-icon { - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - color: var(--token-colorTextSecondary); -} - -.select__value-label { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-weight: 500; -} - -.select__placeholder { - color: var(--token-colorTextTertiary); -} - -/* Chevron */ -.select__chevron { - display: flex; - align-items: center; - justify-content: center; - color: var(--token-colorTextSecondary); - transition: transform var(--token-motionDurationFast); - flex-shrink: 0; -} - -.select__chevron--open { - transform: rotate(180deg); -} - -/* Dropdown Panel */ -.select__dropdown { - position: absolute; - top: 100%; - left: 0; - right: 0; - z-index: var(--token-zIndexPopover); - background: var(--token-colorBgElevated); - border: 1px solid var(--token-colorPrimary); - border-top: none; - border-radius: 0 0 var(--token-borderRadius) var(--token-borderRadius); - box-shadow: var(--token-boxShadowSecondary); - max-height: 240px; - overflow: hidden; - display: flex; - flex-direction: column; - animation: selectDropdownOpen 0.15s ease-out; -} - -/* Portal dropdown (rendered to document.body) */ -.select__dropdown--portal { - position: fixed; - border: 1px solid var(--token-colorBorder); - border-radius: var(--token-borderRadius); - z-index: var(--token-zIndexTooltip); -} - -.select__search { - flex-shrink: 0; - padding: var(--token-paddingSM); - background: var(--token-colorBgElevated); - border-bottom: 1px solid var(--token-colorBorderSecondary); -} - -.select__search-input { - box-sizing: border-box; - width: 100%; - padding: var(--token-paddingXS) var(--token-paddingSM); - font-family: var(--token-fontFamily); - font-size: var(--token-fontSize); - color: var(--token-colorText); - background: var(--token-colorBgContainer); - border: 1px solid var(--token-colorBorder); - border-radius: var(--token-borderRadiusSM); - transition: - border-color var(--token-motionDurationFast), - box-shadow var(--token-motionDurationFast); -} - -.select__search-input::placeholder { - color: var(--token-colorTextPlaceholder); -} - -.select__search-input:focus-visible { - outline: none; - border-color: color-mix( - in srgb, - var(--token-colorPrimary) 70%, - var(--token-colorText) - ); - box-shadow: 0 0 0 2px var(--token-colorPrimaryBg); -} - -.select__options { - min-height: 0; - overflow-y: auto; -} - -@keyframes selectDropdownOpen { - from { - opacity: 0; - transform: translateY(-4px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -/* Scrollbar styling. Same size as every other scrollable surface; only the - colors are restated here, because the dropdown sits on an elevated - background rather than on the page. */ -.select__options::-webkit-scrollbar { - width: var(--token-scrollbarSize); - height: var(--token-scrollbarSize); -} - -.select__options::-webkit-scrollbar-track { - background: transparent; -} - -.select__options::-webkit-scrollbar-thumb { - background-color: var(--token-colorBorder); - border-radius: var(--token-scrollbarRadius); -} - -.select__options::-webkit-scrollbar-thumb:hover { - background-color: var(--token-colorTextTertiary); -} - -/* Option Item */ -.select__option { - display: flex; - align-items: center; - gap: var(--token-paddingSM); - padding: var(--token-paddingSM) var(--token-padding); - font-size: var(--token-fontSize); - color: var(--token-colorText); - cursor: pointer; - transition: background-color var(--token-motionDurationFast); - user-select: none; -} - -.select__option--disabled { - color: var(--token-colorTextDisabled); - cursor: not-allowed; -} - -.select__option:first-child { - border-radius: 0; -} - -.select__option:last-child { - border-radius: 0 0 var(--token-borderRadius) var(--token-borderRadius); -} - -.select__option:hover:not(.select__option--disabled), -.select__option--focused:not(.select__option--disabled) { - background-color: var(--token-colorFillSecondary); -} - -.select__option--selected { - background-color: var(--token-colorPrimaryBg); -} - -.select__option--selected:hover, -.select__option--selected.select__option--focused { - background-color: var(--token-colorPrimaryBgHover); -} - -/* Option icon */ -.select__option-icon { - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - width: 20px; - height: 20px; - color: var(--token-colorTextSecondary); -} - -.select__option--selected .select__option-icon { - color: var(--token-colorPrimary); -} - -/* Option content */ -.select__option-content { - flex: 1; - min-width: 0; - display: flex; - flex-direction: column; - gap: 2px; -} - -.select__option-label { - font-weight: 500; -} - -.select__option--selected .select__option-label { - color: var(--token-colorPrimary); -} - -.select__option-description { - font-size: var(--token-fontSizeSM); - color: var(--token-colorTextTertiary); -} - -/* Check mark */ -.select__option-check { - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - color: var(--token-colorPrimary); -} - -/* Empty state */ -.select__empty { - padding: var(--token-padding) var(--token-paddingMD); - color: var(--token-colorTextTertiary); - font-style: italic; - text-align: center; -} - -/* Large size adjustments */ -.select--large .select__option { - padding: var(--token-paddingSM) var(--token-paddingMD); - font-size: var(--token-fontSize); -} - -.select--large .select__option-icon { - width: 24px; - height: 24px; -} - -.select--large .select__option-description { - font-size: var(--token-fontSizeSM); -} diff --git a/src/components/Select/Select.test.tsx b/src/components/Select/Select.test.tsx deleted file mode 100644 index ef63bd7..0000000 --- a/src/components/Select/Select.test.tsx +++ /dev/null @@ -1,234 +0,0 @@ -/** - * Tests for Select, written as part of its admission (issue #16). - * - * Neither product that shipped this component had a test for it, which is what - * rule 3 in CONTRIBUTING.md exists to prevent: a shared component nobody can - * safely change is worse than two copies nobody shares. - * - * The listbox is portalled to the body, so queries go through `screen` rather - * than the render container. - */ -import { describe, expect, it, vi } from "vitest"; -import { fireEvent, render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; - -import { Select } from "./Select"; -import type { SelectOption } from "./Select"; - -const OPTIONS: SelectOption[] = [ - { value: "eu", label: "Frankfurt", description: "eu-central-1" }, - { value: "us", label: "Oregon", description: "us-west-2" }, - { value: "ap", label: "Seoul", description: "ap-northeast-2" }, -]; - -function renderSelect(props: Partial[0]> = {}) { - const onChange = vi.fn(); - render( - ); - - // The label names it, so no aria-label is needed alongside. - const trigger = screen.getByRole("button", { name: /Region/ }); - expect(trigger).not.toHaveAttribute("aria-label"); - }); - - it("marks itself invalid for a form that says so", () => { - const { trigger } = renderSelect({ invalid: true }); - - expect(trigger).toHaveAttribute("aria-invalid", "true"); - }); - - it("carries a caller's description reference", () => { - const { trigger } = renderSelect({ "aria-describedby": "region-hint" }); - - expect(trigger).toHaveAttribute("aria-describedby", "region-hint"); - }); - - it("reports a blur to the form", async () => { - const user = userEvent.setup(); - const onBlur = vi.fn(); - const { trigger } = renderSelect({ onBlur }); - - await user.click(trigger); - await user.tab(); - - expect(onBlur).toHaveBeenCalled(); - }); -}); - -describe("without the filter", () => { - it("renders no search field and every option", async () => { - const user = userEvent.setup(); - const { trigger } = renderSelect(); - - await user.click(trigger); - - expect(screen.queryByRole("searchbox")).not.toBeInTheDocument(); - expect(screen.getAllByRole("option")).toHaveLength(3); - }); -}); diff --git a/src/components/Select/Select.tsx b/src/components/Select/Select.tsx deleted file mode 100644 index d91be08..0000000 --- a/src/components/Select/Select.tsx +++ /dev/null @@ -1,519 +0,0 @@ -/** - * Select Component - * - * Single-select listbox with an optional search filter, icons, descriptions and - * a disabled-but-visible option state. The listbox is portalled, so a select - * near the bottom of a scrolling panel is not clipped by it. - * - * Every string it renders arrives as a prop with an English default, so the - * component resolves no locale key of its own. - */ - -import { useState, useRef, useEffect, useCallback, useId, useMemo } from "react"; -import type { ReactNode } from "react"; -import { createPortal } from "react-dom"; -import "./Select.css"; - -export interface SelectOption { - value: T; - label: string; - description?: string; - icon?: ReactNode; - /** Keep an unavailable choice visible while preventing its selection. */ - disabled?: boolean; -} - -export interface SelectProps { - value: T; - onChange: (value: T) => void; - onBlur?: () => void; - options: SelectOption[]; - /** - * Field label rendered above the trigger, the way `TextInput` renders its - * own. Names the trigger and the listbox through `aria-labelledby`, so a - * caller that supplies it does not also need `aria-label`. - */ - label?: ReactNode; - placeholder?: string; - disabled?: boolean; - size?: "small" | "default" | "medium" | "large"; - fullWidth?: boolean; - className?: string; - searchable?: boolean; - searchPlaceholder?: string; - "aria-label"?: string; - "aria-describedby"?: string; - invalid?: boolean; - /** - * Shown in place of the list when a search matches nothing. English default, - * because this package cannot resolve a consumer's locale key: a product that - * localises passes its own words. - */ - noOptionsLabel?: string; -} - -export function Select({ - value, - onChange, - onBlur, - options, - label, - placeholder = "Select...", - disabled = false, - size = "default", - fullWidth = false, - className = "", - searchable = false, - searchPlaceholder, - "aria-label": ariaLabel, - "aria-describedby": ariaDescribedBy, - invalid = false, - noOptionsLabel = "No options", -}: SelectProps) { - const [isOpen, setIsOpen] = useState(false); - const [focusedIndex, setFocusedIndex] = useState(-1); - const [searchQuery, setSearchQuery] = useState(""); - const [dropdownPosition, setDropdownPosition] = useState({ - top: 0, - left: 0, - width: 0, - }); - - const containerRef = useRef(null); - const triggerRef = useRef(null); - const dropdownRef = useRef(null); - const searchInputRef = useRef(null); - const optionsRef = useRef<(HTMLDivElement | null)[]>([]); - const listboxId = useId(); - const optionIdPrefix = useId(); - const labelId = useId(); - const hasLabel = label !== undefined && label !== null && label !== false; - - // Find selected option - const selectedOption = options.find((opt) => opt.value === value); - const visibleOptions = useMemo(() => { - const normalizedQuery = searchQuery.trim().toLowerCase(); - if (!searchable || normalizedQuery.length === 0) return options; - return options.filter((option) => - [option.label, option.value, option.description ?? ""].some((candidate) => - candidate.toLowerCase().includes(normalizedQuery), - ), - ); - }, [options, searchQuery, searchable]); - - const findEnabledIndex = useCallback( - (start: number, step: 1 | -1): number => { - for ( - let index = start; - index >= 0 && index < visibleOptions.length; - index += step - ) { - const option = visibleOptions[index]; - if (option && !option.disabled) return index; - } - return -1; - }, - [visibleOptions], - ); - - // Generate unique option ID - const getOptionId = (index: number) => `${optionIdPrefix}-option-${String(index)}`; - - // Get active descendant ID for screen readers - const activeDescendantId = focusedIndex >= 0 ? getOptionId(focusedIndex) : undefined; - - // Calculate dropdown position - const updateDropdownPosition = useCallback(() => { - if (!triggerRef.current) return; - - const rect = triggerRef.current.getBoundingClientRect(); - setDropdownPosition({ - top: rect.bottom + 4, - left: rect.left, - width: rect.width, // Use trigger width as minimum, but allow expansion - }); - }, []); - - // Open dropdown - const openDropdown = useCallback(() => { - if (disabled) return; - updateDropdownPosition(); - setIsOpen(true); - const selectedIndex = visibleOptions.findIndex( - (opt) => opt.value === value && !opt.disabled, - ); - setFocusedIndex(selectedIndex !== -1 ? selectedIndex : findEnabledIndex(0, 1)); - }, [disabled, visibleOptions, value, updateDropdownPosition, findEnabledIndex]); - - // Close dropdown - const closeDropdown = useCallback(() => { - setIsOpen(false); - setFocusedIndex(-1); - setSearchQuery(""); - triggerRef.current?.focus(); - }, []); - - // Handle option selection - const selectOption = useCallback( - (option: SelectOption, e?: React.MouseEvent) => { - if (e) { - e.stopPropagation(); - e.preventDefault(); - } - if (option.disabled) return; - onChange(option.value); - closeDropdown(); - }, - [onChange, closeDropdown], - ); - - // Handle click outside to close - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if (!isOpen) return; - const target = event.target as Node; - // Check if click is outside both the container and the dropdown (which is portaled) - const isOutsideContainer = - containerRef.current && !containerRef.current.contains(target); - const isOutsideDropdown = - dropdownRef.current && !dropdownRef.current.contains(target); - if (isOutsideContainer && isOutsideDropdown) { - closeDropdown(); - } - }; - - const handleEscape = (event: KeyboardEvent) => { - if (event.key === "Escape" && isOpen) { - closeDropdown(); - } - }; - - if (isOpen) { - document.addEventListener("mousedown", handleClickOutside); - document.addEventListener("keydown", handleEscape); - } - - return () => { - document.removeEventListener("mousedown", handleClickOutside); - document.removeEventListener("keydown", handleEscape); - }; - }, [isOpen, closeDropdown]); - - // Handle window resize/scroll to update dropdown position - useEffect(() => { - if (!isOpen) return; - - const handlePositionUpdate = () => { - updateDropdownPosition(); - }; - - window.addEventListener("resize", handlePositionUpdate); - window.addEventListener("scroll", handlePositionUpdate, true); - - return () => { - window.removeEventListener("resize", handlePositionUpdate); - window.removeEventListener("scroll", handlePositionUpdate, true); - }; - }, [isOpen, updateDropdownPosition]); - - useEffect(() => { - if (isOpen && searchable) { - searchInputRef.current?.focus(); - } - }, [isOpen, searchable]); - - useEffect(() => { - if (!isOpen || !searchable) return; - optionsRef.current = []; - const selectedIndex = visibleOptions.findIndex( - (option) => option.value === value && !option.disabled, - ); - setFocusedIndex(selectedIndex >= 0 ? selectedIndex : findEnabledIndex(0, 1)); - }, [isOpen, searchQuery, searchable, value, visibleOptions, findEnabledIndex]); - - // Keyboard navigation - const handleKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if (disabled) return; - - switch (e.key) { - case "Enter": - case " ": - e.preventDefault(); - if (isOpen) { - const focusedOption = visibleOptions[focusedIndex]; - if (focusedOption) { - selectOption(focusedOption); - } - } else { - openDropdown(); - } - break; - - case "Escape": - e.preventDefault(); - if (isOpen) { - closeDropdown(); - } - break; - - case "ArrowDown": - e.preventDefault(); - if (!isOpen) { - openDropdown(); - } else { - const nextIndex = findEnabledIndex(focusedIndex + 1, 1); - if (nextIndex >= 0) setFocusedIndex(nextIndex); - } - break; - - case "ArrowUp": - e.preventDefault(); - if (isOpen) { - const previousIndex = findEnabledIndex(focusedIndex - 1, -1); - if (previousIndex >= 0) setFocusedIndex(previousIndex); - } - break; - - case "Tab": - if (isOpen) { - closeDropdown(); - } - break; - } - }, - [ - disabled, - isOpen, - focusedIndex, - visibleOptions, - findEnabledIndex, - openDropdown, - closeDropdown, - selectOption, - ], - ); - - const handleSearchKeyDown = useCallback( - (event: React.KeyboardEvent) => { - switch (event.key) { - case "Enter": { - event.preventDefault(); - const focusedOption = visibleOptions[focusedIndex]; - if (focusedOption) selectOption(focusedOption); - break; - } - case "Escape": - event.preventDefault(); - event.stopPropagation(); - closeDropdown(); - break; - case "ArrowDown": - event.preventDefault(); - setFocusedIndex((current) => { - const nextIndex = findEnabledIndex(current + 1, 1); - return nextIndex >= 0 ? nextIndex : current; - }); - break; - case "ArrowUp": - event.preventDefault(); - setFocusedIndex((current) => { - const previousIndex = findEnabledIndex(current - 1, -1); - return previousIndex >= 0 ? previousIndex : current; - }); - break; - case "Tab": - closeDropdown(); - break; - } - }, - [closeDropdown, focusedIndex, selectOption, visibleOptions, findEnabledIndex], - ); - - // Scroll focused option into view - useEffect(() => { - const focusedElement = optionsRef.current[focusedIndex]; - if (isOpen && focusedIndex >= 0 && focusedElement) { - focusedElement.scrollIntoView({ - block: "nearest", - behavior: "smooth", - }); - } - }, [isOpen, focusedIndex]); - - const sizeClass = size !== "default" ? `select--${size}` : ""; - const widthClass = fullWidth ? "select--full-width" : ""; - const labelledClass = hasLabel ? "select--labelled" : ""; - // aria-labelledby wins over aria-label in the accessible-name calculation, - // so a caller may pass either; the rendered label is preferred when present. - const labelledBy = hasLabel ? labelId : undefined; - const searchFieldLabel = searchPlaceholder ?? ariaLabel; - - return ( -
- {hasLabel && ( - - {label} - - )} - - - {isOpen && - createPortal( -
- {searchable && ( -
- setSearchQuery(event.currentTarget.value)} - onKeyDown={handleSearchKeyDown} - placeholder={searchPlaceholder} - aria-label={searchFieldLabel} - aria-labelledby={searchFieldLabel ? undefined : labelledBy} - aria-controls={listboxId} - aria-activedescendant={activeDescendantId} - autoComplete="off" - spellCheck={false} - /> -
- )} -
- {visibleOptions.length === 0 ? ( -
{noOptionsLabel}
- ) : ( - visibleOptions.map((option, index) => ( -
{ - optionsRef.current[index] = el; - }} - className={`select__option ${ - option.value === value ? "select__option--selected" : "" - } ${index === focusedIndex ? "select__option--focused" : ""} ${option.disabled ? "select__option--disabled" : ""}`} - role="option" - aria-selected={option.value === value} - aria-disabled={option.disabled || undefined} - aria-label={ - option.description - ? option.label + ". " + option.description - : undefined - } - onClick={(e) => { - selectOption(option, e); - }} - onMouseEnter={() => { - if (!option.disabled) setFocusedIndex(index); - }} - > - {option.icon && ( - {option.icon} - )} -
- {option.label} - {option.description && ( - - {option.description} - - )} -
- {option.value === value && ( - - - - - - )} -
- )) - )} -
-
, - document.body, - )} -
- ); -} diff --git a/src/components/Select/index.ts b/src/components/Select/index.ts deleted file mode 100644 index f326d4a..0000000 --- a/src/components/Select/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { Select } from "./Select"; -export type { SelectOption, SelectProps } from "./Select"; diff --git a/src/components/Skeleton/Skeleton.css b/src/components/Skeleton/Skeleton.css deleted file mode 100644 index 7df8228..0000000 --- a/src/components/Skeleton/Skeleton.css +++ /dev/null @@ -1,63 +0,0 @@ -/** - * Skeleton Component Styles - * - * Base skeleton styles with shimmer animation for loading states. - * Respects prefers-reduced-motion preference. - */ - -/* Base Skeleton */ -.skeleton { - position: relative; - display: inline-block; - background-color: var(--token-colorFillTertiary, #f9fafb); - border-radius: var(--token-borderRadiusSM, 0.25rem); - overflow: hidden; -} - -/* Skeleton Variants */ -.skeleton--rect { - border-radius: var(--token-borderRadiusSM, 0.25rem); -} - -.skeleton--circle { - border-radius: 50%; -} - -.skeleton--text { - border-radius: var(--token-borderRadiusSM, 0.25rem); - height: 1em; - transform: scale(1, 0.6); -} - -/* Shimmer Effect */ -.skeleton__shimmer { - position: absolute; - top: 0; - right: 0; - bottom: 0; - left: 0; - transform: translateX(-100%); - background: linear-gradient( - 90deg, - transparent 0%, - var(--token-colorBgContainer, #ffffff) 50%, - transparent 100% - ); - animation: skeleton-shimmer var(--token-motionDurationSlow, 1.5s) infinite; -} - -@keyframes skeleton-shimmer { - 0% { - transform: translateX(-100%); - } - 100% { - transform: translateX(100%); - } -} - -/* Reduced Motion Preference */ -@media (prefers-reduced-motion: reduce) { - .skeleton__shimmer { - animation: none; - } -} diff --git a/src/components/Skeleton/Skeleton.test.tsx b/src/components/Skeleton/Skeleton.test.tsx deleted file mode 100644 index b51717b..0000000 --- a/src/components/Skeleton/Skeleton.test.tsx +++ /dev/null @@ -1,176 +0,0 @@ -/** - * Tests for Skeleton component - * - * Tests cover: - * - Rendering with default props - * - Custom width and height - * - Different variants (rect, circle, text) - * - Custom className - * - Accessibility attributes (role, aria-busy, aria-label) - * - Test ID support - */ - -import { describe, it, expect } from "vitest"; -import { render, screen } from "@testing-library/react"; -import { Skeleton } from "./Skeleton"; - -describe("Skeleton", () => { - describe("Rendering", () => { - it("should render with default props", () => { - render(); - - const skeleton = screen.getByRole("status"); - expect(skeleton).toBeInTheDocument(); - expect(skeleton).toHaveClass("skeleton", "skeleton--rect"); - }); - - it("should render with custom width and height", () => { - render(); - - const skeleton = screen.getByRole("status"); - expect(skeleton).toHaveStyle({ width: "200px", height: "50px" }); - }); - - it("should render with default dimensions when not specified", () => { - render(); - - const skeleton = screen.getByRole("status"); - expect(skeleton).toHaveStyle({ width: "100%", height: "20px" }); - }); - }); - - describe("Variants", () => { - it("should render rect variant by default", () => { - render(); - - const skeleton = screen.getByRole("status"); - expect(skeleton).toHaveClass("skeleton--rect"); - }); - - it("should render circle variant", () => { - render(); - - const skeleton = screen.getByRole("status"); - expect(skeleton).toHaveClass("skeleton--circle"); - }); - - it("should render text variant", () => { - render(); - - const skeleton = screen.getByRole("status"); - expect(skeleton).toHaveClass("skeleton--text"); - }); - }); - - describe("CSS Classes", () => { - it("should include base skeleton class", () => { - render(); - - const skeleton = screen.getByRole("status"); - expect(skeleton).toHaveClass("skeleton"); - }); - - it("should include custom className", () => { - render(); - - const skeleton = screen.getByRole("status"); - expect(skeleton).toHaveClass("skeleton", "custom-class"); - }); - - it("should handle empty className", () => { - render(); - - const skeleton = screen.getByRole("status"); - expect(skeleton).toHaveClass("skeleton", "skeleton--rect"); - }); - }); - - describe("Accessibility", () => { - it("should have role status", () => { - render(); - - const skeleton = screen.getByRole("status"); - expect(skeleton).toBeInTheDocument(); - }); - - it("should have aria-busy set to true", () => { - render(); - - const skeleton = screen.getByRole("status"); - expect(skeleton).toHaveAttribute("aria-busy", "true"); - }); - - it("should have aria-label for screen readers", () => { - render(); - - const skeleton = screen.getByRole("status"); - expect(skeleton).toHaveAttribute("aria-label", "Loading"); - }); - - it("should render a custom loadingLabel when provided", () => { - render(); - - const skeleton = screen.getByRole("status"); - expect(skeleton).toHaveAttribute("aria-label", "Loading models"); - }); - }); - - describe("Shimmer Effect", () => { - it("should render shimmer element", () => { - const { container } = render(); - - const shimmer = container.querySelector(".skeleton__shimmer"); - expect(shimmer).toBeInTheDocument(); - }); - }); - - describe("Test ID Support", () => { - it("should render with data-testid when provided", () => { - render(); - - const skeleton = screen.getByTestId("skeleton-test"); - expect(skeleton).toBeInTheDocument(); - }); - - it("should not render data-testid when not provided", () => { - const { container } = render(); - - const skeleton = container.querySelector(".skeleton"); - expect(skeleton).not.toHaveAttribute("data-testid"); - }); - }); - - describe("Edge Cases", () => { - it("should handle percentage values for dimensions", () => { - render(); - - const skeleton = screen.getByRole("status"); - expect(skeleton).toHaveStyle({ width: "50%", height: "100%" }); - }); - - it("should handle rem/em values for dimensions", () => { - render(); - - const skeleton = screen.getByRole("status"); - expect(skeleton).toHaveStyle({ width: "10rem", height: "2em" }); - }); - - it("should handle combined props", () => { - render( - , - ); - - const skeleton = screen.getByTestId("custom-skeleton"); - expect(skeleton).toHaveClass("skeleton", "skeleton--circle", "my-skeleton"); - expect(skeleton).toHaveStyle({ width: "100px", height: "40px" }); - expect(skeleton).toHaveAttribute("role", "status"); - expect(skeleton).toHaveAttribute("aria-busy", "true"); - }); - }); -}); diff --git a/src/components/Skeleton/Skeleton.tsx b/src/components/Skeleton/Skeleton.tsx deleted file mode 100644 index 5366fa6..0000000 --- a/src/components/Skeleton/Skeleton.tsx +++ /dev/null @@ -1,89 +0,0 @@ -/** - * Skeleton Component - * - * Base skeleton component for loading states. - * Provides a shimmer animation effect that respects prefers-reduced-motion. - * - * @example - * // Basic usage - * - * - * @example - * // Circle skeleton - * - */ - -import "./Skeleton.css"; - -export interface SkeletonProps { - /** Width of the skeleton (CSS value) */ - width?: string; - /** Height of the skeleton (CSS value) */ - height?: string; - /** Visual variant */ - variant?: "rect" | "circle" | "text"; - /** Additional CSS class names */ - className?: string; - /** Test ID for testing */ - testId?: string; - /** Accessible label announced while the skeleton is visible. Default: "Loading" */ - loadingLabel?: string; - /** - * Render as decoration: `aria-hidden`, with no role, no `aria-busy` and no - * label. - * - * For a shape inside a composite that announces the wait itself. `role="status"` - * is an implicit polite live region, so a composite that fills its own region - * with named primitives mounts one live region per shape and announces the - * same wait once per shape. - */ - decorative?: boolean; -} - -/** - * Skeleton provides a loading placeholder with shimmer animation. - * - * Features: - * - Shimmer effect using CSS animation - * - Reduced motion preference support - * - Multiple shape variants - * - Accessible via aria-busy and aria-label - */ -export function Skeleton({ - width = "100%", - height = "20px", - variant = "rect", - className = "", - testId, - loadingLabel = "Loading", - decorative = false, -}: SkeletonProps) { - const classNames = ["skeleton", `skeleton--${variant}`, className] - .filter(Boolean) - .join(" "); - - // A decorative shape is one of several inside a composite that already - // announces the wait. It keeps its size and its shimmer and leaves the - // accessibility tree, so a card built from seven of these is one live region - // rather than seven. - const announcement = decorative - ? ({ "aria-hidden": true } as const) - : ({ - role: "status", - "aria-busy": true, - "aria-label": loadingLabel, - } as const); - - return ( -
- -
- ); -} - -export default Skeleton; diff --git a/src/components/Skeleton/index.ts b/src/components/Skeleton/index.ts index 2b5930c..2668668 100644 --- a/src/components/Skeleton/index.ts +++ b/src/components/Skeleton/index.ts @@ -1,13 +1,8 @@ /** - * Skeleton Components - * - * Loading state components. The composites are drawn with Astryx `Skeleton`; - * the 0.1 base `Skeleton` is deprecated. + * Skeleton composites: loading placeholders for a card, a text block, a list + * row and a chart, each drawn with Astryx `Skeleton`. The base shape is + * Astryx's own `Skeleton` (`@lablup/ui-common/Skeleton`). */ - -export { Skeleton } from "./Skeleton"; -export type { SkeletonProps } from "./Skeleton"; - export { SkeletonCard } from "./SkeletonCard"; export type { SkeletonCardProps } from "./SkeletonCard"; diff --git a/src/components/StatusTag/StatusTag.css b/src/components/StatusTag/StatusTag.css deleted file mode 100644 index 133c8eb..0000000 --- a/src/components/StatusTag/StatusTag.css +++ /dev/null @@ -1,46 +0,0 @@ -/** - * StatusTag styles - * - * The Badge underneath already paints the background/foreground via the - * design tokens; this file only adds: - * - tighter inline-flex layout so the optional indicator dot sits on - * the same baseline as the label, - * - a pulsing dot animation for transient states. - */ - -.status-tag { - display: inline-flex; - align-items: center; - gap: var(--token-paddingXXS, 4px); -} - -.status-tag__indicator { - display: inline-block; - width: 6px; - height: 6px; - border-radius: 50%; - background-color: currentColor; - animation: status-tag-pulse 1.6s ease-in-out infinite; -} - -@keyframes status-tag-pulse { - 0%, - 100% { - opacity: 1; - transform: scale(1); - } - 50% { - opacity: 0.45; - transform: scale(0.85); - } -} - -.status-tag__label { - display: inline-block; -} - -@media (prefers-reduced-motion: reduce) { - .status-tag__indicator { - animation: none; - } -} diff --git a/src/components/StatusTag/StatusTag.test.tsx b/src/components/StatusTag/StatusTag.test.tsx deleted file mode 100644 index c534b55..0000000 --- a/src/components/StatusTag/StatusTag.test.tsx +++ /dev/null @@ -1,34 +0,0 @@ -/** - * Tests for `StatusTag` (epic #2730 / issue #2738). - */ - -import { describe, it, expect } from "vitest"; -import { render, screen } from "@testing-library/react"; -import { StatusTag } from "./StatusTag"; - -describe("StatusTag", () => { - it("renders the provided label", () => { - render(); - expect(screen.getByText("Running")).toBeInTheDocument(); - }); - - it("applies a state-specific class", () => { - const { container } = render(); - expect(container.querySelector(".status-tag--error")).toBeInTheDocument(); - }); - - it("renders the pulse indicator for transient states by default", () => { - render(); - expect(screen.queryByTestId("status-tag-indicator")).toBeInTheDocument(); - }); - - it("hides the pulse indicator for terminal states by default", () => { - render(); - expect(screen.queryByTestId("status-tag-indicator")).not.toBeInTheDocument(); - }); - - it("respects an explicit `pulse={false}` override", () => { - render(); - expect(screen.queryByTestId("status-tag-indicator")).not.toBeInTheDocument(); - }); -}); diff --git a/src/components/StatusTag/StatusTag.tsx b/src/components/StatusTag/StatusTag.tsx deleted file mode 100644 index 9a1ea67..0000000 --- a/src/components/StatusTag/StatusTag.tsx +++ /dev/null @@ -1,125 +0,0 @@ -/** - * StatusTag Component (epic #2730 / issue #2738) - * - * Status badge with the canonical color semantics shared by every list / - * table that has to render lifecycle state. Mirrors the Backend.AI WebUI - * `SessionStatusTag` color choices so users carry a single mental model - * across products: blue for transient/preparing, green for healthy - * running, red for error, neutral for terminated, yellow for stopping. - * - * The component intentionally builds on the existing {@link Badge} - * primitive — it does not duplicate Badge's color tokens. Instead it - * adds: - * - * 1. A canonical `state` enum surface (`running`, `preparing`, - * `stopping`, `error`, `terminated`, `idle`, `busy`) so callers - * don't have to map their domain state onto Badge variants by hand. - * 2. An optional pulsing dot indicator for transient states (preparing, - * stopping) so the UI visibly communicates "in motion" alongside - * the static color. - * - * This is a domain-agnostic primitive — it MUST NOT carry Session, - * Model, Agent, or Squad-specific knowledge. - */ - -import { memo } from "react"; -import { Badge } from "../Badge"; -import type { BadgeProps } from "../Badge"; -import "./StatusTag.css"; - -/** - * Canonical lifecycle state surface accepted by {@link StatusTag}. - * - * The names are deliberately broad so domain types can map onto them: - * - SessionState `"starting"` → `"preparing"` - * - SessionState `"running"` (healthy) → `"running"` - * - SessionState `"idle"` → `"idle"` - * - SessionState `"busy"` → `"busy"` - * - SessionState `"stopping"` → `"stopping"` - * - SessionState `"terminated"` → `"terminated"` - * - SessionState `"error"` → `"error"` - */ -export type StatusKind = - "running" | "preparing" | "idle" | "busy" | "stopping" | "terminated" | "error"; - -export interface StatusTagProps { - /** Canonical lifecycle state. Drives color and indicator behavior. */ - state: StatusKind; - /** Display label. Callers are responsible for i18n at the call site. */ - label: string; - /** Override the underlying Badge size. Defaults to `"small"`. */ - size?: BadgeProps["size"]; - /** - * When `true`, render a small pulsing dot before the label to - * communicate that the state is in motion. Defaults to a sensible - * choice based on `state` (transient states pulse, terminal ones - * don't). - */ - pulse?: boolean; - /** Extra class names appended to the root span. */ - className?: string; -} - -/** - * Map a {@link StatusKind} onto a {@link BadgeProps.variant}. Centralizes - * the canonical color mapping so consumers can't drift. - */ -function variantForState(state: StatusKind): BadgeProps["variant"] { - switch (state) { - case "running": - return "success"; - case "preparing": - return "info"; - case "idle": - return "default"; - case "busy": - return "primary"; - case "stopping": - return "warning"; - case "terminated": - return "default"; - case "error": - return "danger"; - } -} - -/** - * Default for the dot-pulse indicator. Transient states pulse, terminal - * ones don't. Callers can override via the `pulse` prop. - */ -function defaultPulseForState(state: StatusKind): boolean { - return state === "preparing" || state === "stopping" || state === "busy"; -} - -function StatusTagComponent({ - state, - label, - size = "small", - pulse, - className = "", -}: StatusTagProps) { - const showPulse = pulse ?? defaultPulseForState(state); - const composedClassName = ["status-tag", `status-tag--${state}`, className] - .filter(Boolean) - .join(" "); - - return ( - - {showPulse && ( - - ); -} - -/** - * Memoized to avoid re-rendering rows that didn't change. The component - * is a pure function of its props. - */ -export const StatusTag = memo(StatusTagComponent); -export default StatusTag; diff --git a/src/components/StatusTag/index.ts b/src/components/StatusTag/index.ts deleted file mode 100644 index 54442e3..0000000 --- a/src/components/StatusTag/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { StatusTag, default } from "./StatusTag"; -export type { StatusTagProps, StatusKind } from "./StatusTag"; diff --git a/src/components/Tabs/Tabs.css b/src/components/Tabs/Tabs.css deleted file mode 100644 index 6b5d246..0000000 --- a/src/components/Tabs/Tabs.css +++ /dev/null @@ -1,763 +0,0 @@ -/** - * Tabs Component Styles - * - * Unified tab navigation with responsive overflow handling. - * Supports both dropdown mode (< 480px) and overflow menu mode (< 768px). - * - * Breakpoints: - * - > 768px: Full tabs with scroll arrows - * - 480-768px: Scrollable tabs with arrows (or overflow menu in menu mode) - * - < 480px: Dropdown mode (dropdown overflow) or compact tabs (menu overflow) - */ - -.tabs { - display: flex; - flex-direction: column; - width: 100%; -} - -/* Container for tabs and scroll arrows */ -.tabs__container { - position: relative; - display: flex; - align-items: center; - gap: var(--token-paddingXS, 0.25rem); - margin: 0 0 var(--token-paddingMD, 1rem); -} - -/* Scrollable tabs list */ -.tabs__list { - display: flex; - gap: var(--token-paddingXS, 0.25rem); - padding: var(--token-paddingSM, 0.5rem) var(--token-paddingMD, 1rem); - background-color: var(--token-colorBgContainer, #ffffff); - border: 1px solid var(--token-colorBorderSecondary, #e5e7eb); - border-radius: var(--token-borderRadiusXL, 0.75rem); - position: sticky; - top: 0; - z-index: 10; - box-shadow: var( - --token-boxShadow, - 0 1px 2px 0 rgba(0, 0, 0, 0.03), - 0 1px 6px -1px rgba(0, 0, 0, 0.02), - 0 2px 4px 0 rgba(0, 0, 0, 0.02) - ); - overflow-x: auto; - overflow-y: hidden; - scrollbar-width: none; /* Firefox */ - scroll-behavior: smooth; - flex: 1; - min-width: 0; - -webkit-overflow-scrolling: touch; -} - -.tabs__list::-webkit-scrollbar { - display: none; /* Chrome, Safari, Edge */ -} - -/* Tab list with groups uses wider gap */ -.tabs--overflow-menu .tabs__list { - gap: var(--token-paddingMD, 1rem); -} - -/* Individual tab button */ -.tabs__tab { - display: flex; - align-items: center; - gap: var(--token-paddingXS, 0.25rem); - padding: var(--token-paddingSM, 0.5rem) var(--token-paddingMD, 1rem); - font-size: var(--token-fontSize, 0.875rem); - font-weight: 500; - color: var(--token-colorTextSecondary, #595959); - background: transparent; - border: none; - border-radius: var(--token-borderRadiusLG, 0.5rem); - cursor: pointer; - transition: - color var(--token-motionDurationFast, 0.1s), - background-color var(--token-motionDurationFast, 0.1s); - white-space: nowrap; - flex-shrink: 0; -} - -.tabs__tab:hover { - color: var(--token-colorText, #141414); - background-color: var(--token-tabHoverBg, #f3f4f6); -} - -.tabs__tab:focus { - box-shadow: var(--token-tabFocusShadow, 0 0 0 3px rgba(139, 92, 246, 0.15)); -} - -.tabs__tab:focus:not(:focus-visible) { - outline: none; -} - -.tabs__tab:focus-visible { - outline: var(--token-focusRingWidth, 2px) var(--token-focusRingStyle, solid) - var(--token-focusRingColor, var(--token-colorPrimary)); - outline-offset: var(--token-focusRingOffset, 2px); -} - -.tabs__tab--active { - color: var(--token-colorPrimary, #8b5cf6); - background-color: var(--token-tabActiveBg, rgba(139, 92, 246, 0.1)); -} - -.tabs__tab--active:hover { - background-color: var(--token-tabActiveBg, rgba(139, 92, 246, 0.1)); -} - -.tabs__tab-label { - display: inline-block; -} - -/** - * Styling for a small badge a consumer renders through `TabItem.labelExtra`. - * - * The component no longer renders one itself: the four-name guide vocabulary - * that used to drive it belongs to the consumer, along with its wording. This - * class stays so the arrangement still looks right when a consumer opts into - * that shape, and it is documented in the README rather than applied here. - */ -.tabs__tag-badge { - font-size: var(--token-fontSizeXXS, 0.5rem); - line-height: 1; - padding: 1px 4px; - text-transform: uppercase; - letter-spacing: 0.03em; - pointer-events: none; -} - -/* ============================================================================ - * Group styles (used when groups prop is provided) - * ============================================================================ */ - -.tabs__group { - display: flex; - align-items: center; - gap: var(--token-paddingXS, 0.25rem); - flex-shrink: 0; -} - -.tabs__group-label { - font-size: var(--token-fontSizeSM, 0.75rem); - font-weight: 600; - color: var(--token-colorTextTertiary, #737373); - text-transform: uppercase; - letter-spacing: 0.05em; - white-space: nowrap; - padding: 0 var(--token-paddingXS, 0.25rem); - user-select: none; -} - -.tabs__separator { - width: 1px; - height: 24px; - background-color: var(--token-colorBorderSecondary, #e5e7eb); - margin: 0 var(--token-paddingSM, 0.5rem); - flex-shrink: 0; -} - -.tabs__group-tabs { - display: flex; - gap: var(--token-paddingXS, 0.25rem); - align-items: center; -} - -/* ============================================================================ - * Scroll arrows - * ============================================================================ */ - -.tabs__scroll-arrow { - display: flex; - align-items: center; - justify-content: center; - width: 32px; - height: 32px; - padding: 0; - background-color: var(--token-colorBgContainer, #ffffff); - border: 1px solid var(--token-colorBorderSecondary, #e5e7eb); - border-radius: var(--token-borderRadiusLG, 0.5rem); - color: var(--token-colorTextSecondary, #595959); - cursor: pointer; - flex-shrink: 0; - transition: - background-color var(--token-motionDurationFast, 0.1s), - color var(--token-motionDurationFast, 0.1s), - border-color var(--token-motionDurationFast, 0.1s); - box-shadow: var( - --token-boxShadow, - 0 1px 2px 0 rgba(0, 0, 0, 0.03), - 0 1px 6px -1px rgba(0, 0, 0, 0.02), - 0 2px 4px 0 rgba(0, 0, 0, 0.02) - ); -} - -.tabs__scroll-arrow:hover { - background-color: var(--token-colorFillSecondary, #f3f4f6); - color: var(--token-colorText, #141414); - border-color: var(--token-colorBorder, #d9d9d9); -} - -.tabs__scroll-arrow:active { - background-color: var(--token-colorFillTertiary, #f9fafb); -} - -.tabs__scroll-arrow:focus { - box-shadow: var(--token-tabFocusShadow, 0 0 0 3px rgba(139, 92, 246, 0.15)); -} - -.tabs__scroll-arrow:focus:not(:focus-visible) { - outline: none; -} - -.tabs__scroll-arrow:focus-visible { - outline: var(--token-focusRingWidth, 2px) var(--token-focusRingStyle, solid) - var(--token-focusRingColor, var(--token-colorPrimary)); - outline-offset: var(--token-focusRingOffset, 2px); -} - -.tabs__scroll-arrow--left { - margin-right: -4px; -} - -.tabs__scroll-arrow--right { - margin-left: -4px; -} - -/* ============================================================================ - * Tab panel - * ============================================================================ */ - -.tabs__panel { - outline: none; -} - -.tabs__panel:focus { - outline: none; -} - -/* ============================================================================ - * Mobile dropdown mode (overflowMode="dropdown", < 480px) - * ============================================================================ */ - -.tabs--mobile-dropdown { - display: flex; - flex-direction: column; - width: 100%; -} - -.tabs__dropdown-container { - position: relative; - margin: 0 0 var(--token-paddingMD, 1rem); -} - -.tabs__dropdown-trigger { - display: flex; - align-items: center; - justify-content: space-between; - width: 100%; - padding: var(--token-paddingSM, 0.5rem) var(--token-paddingMD, 1rem); - background-color: var(--token-colorBgContainer, #ffffff); - border: 1px solid var(--token-colorBorderSecondary, #e5e7eb); - border-radius: var(--token-borderRadiusLG, 0.5rem); - color: var(--token-colorText, #141414); - font-size: var(--token-fontSize, 0.875rem); - font-weight: 500; - cursor: pointer; - transition: - background-color var(--token-motionDurationFast, 0.1s), - border-color var(--token-motionDurationFast, 0.1s); - box-shadow: var( - --token-boxShadow, - 0 1px 2px 0 rgba(0, 0, 0, 0.03), - 0 1px 6px -1px rgba(0, 0, 0, 0.02), - 0 2px 4px 0 rgba(0, 0, 0, 0.02) - ); -} - -.tabs__dropdown-trigger:hover { - background-color: var(--token-colorFillSecondary, #f3f4f6); - border-color: var(--token-colorBorder, #d9d9d9); -} - -/* The border is the indicator a pointer-focused trigger gets, since the - * :focus:not(:focus-visible) rule below suppresses the outline. It uses the - * contrast-corrected accent for the reason recorded on - * `.base-card:focus-within` in ../BaseCard/BaseCard.css, including why the - * mix stays inline in the longhand instead of moving into a token. */ -.tabs__dropdown-trigger:focus { - border-color: color-mix( - in srgb, - var(--token-colorPrimary) 70%, - var(--token-colorText) - ); - box-shadow: var(--token-tabFocusShadow, 0 0 0 3px rgba(139, 92, 246, 0.15)); -} - -.tabs__dropdown-trigger:focus:not(:focus-visible) { - outline: none; -} - -.tabs__dropdown-trigger:focus-visible { - outline: var(--token-focusRingWidth, 2px) var(--token-focusRingStyle, solid) - var(--token-focusRingColor, var(--token-colorPrimary)); - outline-offset: var(--token-focusRingOffset, 2px); -} - -.tabs__dropdown-label { - flex: 1; - text-align: left; -} - -.tabs__dropdown-arrow { - flex-shrink: 0; - transition: transform var(--token-motionDurationMid, 0.2s); -} - -.tabs__dropdown-arrow--open { - transform: rotate(180deg); -} - -.tabs__dropdown-menu { - position: absolute; - top: calc(100% + 4px); - left: 0; - right: 0; - z-index: 100; - background-color: var(--token-colorBgContainer, #ffffff); - border: 1px solid var(--token-colorBorderSecondary, #e5e7eb); - border-radius: var(--token-borderRadiusLG, 0.5rem); - box-shadow: var( - --token-boxShadowSecondary, - 0 6px 16px 0 rgba(0, 0, 0, 0.08), - 0 3px 6px -4px rgba(0, 0, 0, 0.12), - 0 9px 28px 8px rgba(0, 0, 0, 0.05) - ); - overflow: hidden; - animation: tabs-dropdown-fade-in var(--token-motionDurationMid, 0.2s); -} - -@keyframes tabs-dropdown-fade-in { - from { - opacity: 0; - transform: translateY(-8px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -.tabs__dropdown-item { - display: block; - width: 100%; - padding: var(--token-paddingSM, 0.5rem) var(--token-paddingMD, 1rem); - background: transparent; - border: none; - color: var(--token-colorText, #141414); - font-size: var(--token-fontSize, 0.875rem); - font-weight: 400; - text-align: left; - cursor: pointer; - transition: background-color var(--token-motionDurationFast, 0.1s); -} - -.tabs__dropdown-item:hover { - background-color: var(--token-colorFillSecondary, #f3f4f6); -} - -.tabs__dropdown-item:focus-visible { - outline: var(--token-focusRingWidth, 2px) var(--token-focusRingStyle, solid) - var(--token-focusRingColor, var(--token-colorPrimary)); - outline-offset: -2px; -} - -.tabs__dropdown-item--active { - color: var(--token-colorPrimary, #8b5cf6); - font-weight: 500; - background-color: var(--token-tabActiveBg, rgba(139, 92, 246, 0.1)); -} - -.tabs__dropdown-item--active:hover { - background-color: var(--token-tabActiveBg, rgba(139, 92, 246, 0.1)); -} - -/* ============================================================================ - * Overflow menu mode (overflowMode="menu") - * ============================================================================ */ - -.tabs__overflow { - position: relative; - display: none; /* Hidden on desktop, shown on mobile via media query */ - flex-shrink: 0; -} - -.tabs__overflow-btn { - display: flex; - align-items: center; - justify-content: center; - width: 36px; - height: 36px; - padding: 0; - background-color: var(--token-colorBgContainer, #ffffff); - border: 1px solid var(--token-colorBorderSecondary, #e5e7eb); - border-radius: var(--token-borderRadiusLG, 0.5rem); - color: var(--token-colorTextSecondary, #595959); - cursor: pointer; - transition: - background-color var(--token-motionDurationFast, 0.1s), - color var(--token-motionDurationFast, 0.1s); - box-shadow: var( - --token-boxShadow, - 0 1px 2px 0 rgba(0, 0, 0, 0.03), - 0 1px 6px -1px rgba(0, 0, 0, 0.02), - 0 2px 4px 0 rgba(0, 0, 0, 0.02) - ); -} - -.tabs__overflow-btn:hover { - background-color: var(--token-colorFillSecondary, #f3f4f6); - color: var(--token-colorText, #141414); -} - -.tabs__overflow-btn:focus { - box-shadow: var(--token-tabFocusShadow, 0 0 0 3px rgba(139, 92, 246, 0.15)); -} - -.tabs__overflow-btn:focus:not(:focus-visible) { - outline: none; -} - -.tabs__overflow-btn:focus-visible { - outline: var(--token-focusRingWidth, 2px) var(--token-focusRingStyle, solid) - var(--token-focusRingColor, var(--token-colorPrimary)); - outline-offset: var(--token-focusRingOffset, 2px); -} - -.tabs__overflow-menu { - position: absolute; - top: calc(100% + 8px); - right: 0; - min-width: 200px; - max-width: calc(100vw - 2rem); - max-height: 400px; - overflow-y: auto; - background-color: var(--token-colorBgElevated, #ffffff); - border: 1px solid var(--token-colorBorderSecondary, #e5e7eb); - border-radius: var(--token-borderRadiusLG, 0.5rem); - box-shadow: var( - --token-boxShadowSecondary, - 0 6px 16px 0 rgba(0, 0, 0, 0.08), - 0 3px 6px -4px rgba(0, 0, 0, 0.12), - 0 9px 28px 8px rgba(0, 0, 0, 0.05) - ); - z-index: var(--token-zIndexPopover, 1150); - padding: var(--token-paddingXS, 0.25rem); -} - -.tabs__overflow-group { - display: flex; - flex-direction: column; -} - -.tabs__overflow-group + .tabs__overflow-group { - margin-top: var(--token-paddingSM, 0.5rem); - padding-top: var(--token-paddingSM, 0.5rem); - border-top: 1px solid var(--token-colorBorderSecondary, #e5e7eb); -} - -.tabs__overflow-group-label { - font-size: var(--token-fontSizeSM, 0.75rem); - font-weight: 600; - color: var(--token-colorTextTertiary, #737373); - text-transform: uppercase; - letter-spacing: 0.05em; - padding: var(--token-paddingXS, 0.25rem) var(--token-paddingSM, 0.5rem); - margin-bottom: var(--token-paddingXXS, 0.125rem); -} - -.tabs__overflow-item { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--token-paddingSM, 0.5rem); - width: 100%; - padding: var(--token-paddingSM, 0.5rem) var(--token-paddingMD, 1rem); - font-size: var(--token-fontSize, 0.875rem); - color: var(--token-colorText, #141414); - background: transparent; - border: none; - border-radius: var(--token-borderRadiusSM, 0.25rem); - text-align: left; - cursor: pointer; - transition: background-color var(--token-motionDurationFast, 0.1s); -} - -.tabs__overflow-item:hover { - background-color: var(--token-colorFillSecondary, #f3f4f6); -} - -.tabs__overflow-item:focus-visible { - outline: var(--token-focusRingWidth, 2px) var(--token-focusRingStyle, solid) - var(--token-focusRingColor, var(--token-colorPrimary)); - outline-offset: -2px; -} - -.tabs__overflow-item--active { - color: var(--token-colorPrimary, #8b5cf6); - background-color: var(--token-tabActiveBg, rgba(139, 92, 246, 0.1)); - font-weight: 600; -} - -.tabs__overflow-item--active:hover { - background-color: var(--token-tabActiveBg, rgba(139, 92, 246, 0.1)); -} - -/* ============================================================================ - * Responsive breakpoints - * ============================================================================ */ - -/* Tablet (dropdown mode) */ -@media (min-width: 480px) and (max-width: 767px) { - .tabs:not(.tabs--overflow-menu) .tabs__list { - padding: var(--token-paddingSM, 0.5rem); - } - - .tabs:not(.tabs--overflow-menu) .tabs__tab { - padding: var(--token-paddingXS, 0.25rem) var(--token-paddingSM, 0.5rem); - font-size: var(--token-fontSize, 0.875rem); - } - - .tabs:not(.tabs--overflow-menu) .tabs__scroll-arrow { - width: 28px; - height: 28px; - } -} - -/* Mobile (dropdown mode): hide scroll arrows */ -@media (max-width: 479px) { - .tabs:not(.tabs--overflow-menu) .tabs__scroll-arrow { - display: none; - } -} - -/* Mobile/tablet (menu mode): show overflow menu, hide group labels/separators */ -@media (max-width: 767px) { - .tabs--overflow-menu .tabs__overflow { - display: block; - } - - .tabs--overflow-menu .tabs__list { - gap: 0; - padding: var(--token-paddingSM, 0.5rem); - } - - .tabs--overflow-menu .tabs__group-label { - display: none; - } - - .tabs--overflow-menu .tabs__separator { - display: none; - } - - .tabs--overflow-menu .tabs__group { - gap: 0; - } - - .tabs--overflow-menu .tabs__group-tabs { - gap: 0; - } - - .tabs--overflow-menu .tabs__tab { - flex: 1; - min-width: auto; - padding: var(--token-paddingSM, 0.5rem) var(--token-padding, 0.75rem); - font-size: var(--token-fontSize, 0.875rem); - } - - /* Hide scroll buttons on mobile in menu mode */ - .tabs--overflow-menu .tabs__scroll-arrow { - display: none !important; - } -} - -/* ============================================================================ - * Variant: segmented (iOS-style segmented control) - * - * Replaces hand-rolled per-page "segmented control" markup. Gray-filled - * container, transparent inactive tabs, raised white active tab. - * ============================================================================ */ - -.tabs--variant-segmented .tabs__container { - /* No outer gap — the pill IS the container */ - gap: 0; -} - -.tabs--variant-segmented .tabs__list { - background-color: var(--token-colorFillTertiary, #f9fafb); - border: 1px solid transparent; - border-radius: var(--token-borderRadiusLG, 0.5rem); - padding: var(--token-paddingXS, 0.25rem); - gap: var(--token-paddingXS, 0.25rem); - /* Segmented pills are never sticky and never show scroll arrows */ - position: static; - top: auto; - z-index: auto; - box-shadow: none; - overflow-x: visible; -} - -.tabs--variant-segmented .tabs__tab { - background: transparent; - border: 1px solid transparent; - border-radius: var(--token-borderRadius, 0.375rem); - color: var(--token-colorTextSecondary, #595959); - font-weight: 500; -} - -.tabs--variant-segmented .tabs__tab:hover:not(.tabs__tab--active) { - background-color: var(--token-colorFillSecondary, #f3f4f6); - color: var(--token-colorText, #141414); -} - -.tabs--variant-segmented .tabs__tab--active { - color: var(--token-colorText, #141414); - background-color: var(--token-colorBgContainer, #ffffff); - box-shadow: var(--token-boxShadowTertiary, 0 1px 3px rgba(0, 0, 0, 0.1)); -} - -.tabs--variant-segmented .tabs__tab--active:hover { - background-color: var(--token-colorBgContainer, #ffffff); -} - -/* Equal-width children when fillContainer=true */ -.tabs--variant-segmented.tabs--fill-container .tabs__tab { - flex: 1; - text-align: center; - justify-content: center; -} - -/* Scroll arrows are visually incompatible with the segmented look */ -.tabs--variant-segmented .tabs__scroll-arrow { - display: none; -} - -/* ============================================================================ - * Variant: compact (small segmented control) - * - * Used when the tab bar lives in a PageHeader actions row alongside other - * controls (e.g. StatisticsPage). Smaller padding and border-radius. - * ============================================================================ */ - -.tabs--variant-compact .tabs__container { - margin: 0; - gap: 0; -} - -.tabs--variant-compact .tabs__list { - display: inline-flex; - background-color: var(--token-colorFillQuaternary, #fafafa); - border: 1px solid transparent; - border-radius: var(--token-borderRadiusSM, 0.25rem); - padding: 2px; - gap: 2px; - /* No sticky/scroll/shadow */ - position: static; - top: auto; - z-index: auto; - box-shadow: none; - overflow-x: visible; - /* Compact variant is intentionally not flex:1 so it sits next to siblings */ - flex: 0 0 auto; - min-width: 0; -} - -.tabs--variant-compact .tabs__tab { - padding: var(--token-paddingXS, 0.25rem) var(--token-paddingSM, 0.5rem); - font-size: var(--token-fontSize, 0.875rem); - font-weight: 500; - background: transparent; - border: 1px solid transparent; - border-radius: calc(var(--token-borderRadiusSM, 0.25rem) - 1px); - color: var(--token-colorTextSecondary, #595959); - min-width: 72px; -} - -.tabs--variant-compact .tabs__tab:hover:not(.tabs__tab--active) { - background-color: var(--token-colorFillSecondary, #f3f4f6); - color: var(--token-colorText, #141414); -} - -.tabs--variant-compact .tabs__tab--active { - color: var(--token-colorText, #141414); - background-color: var(--token-colorBgContainer, #ffffff); - box-shadow: var(--token-boxShadowTertiary, 0 1px 3px rgba(0, 0, 0, 0.1)); -} - -.tabs--variant-compact .tabs__tab--active:hover { - background-color: var(--token-colorBgContainer, #ffffff); -} - -.tabs--variant-compact.tabs--fill-container .tabs__tab { - flex: 1; - text-align: center; - justify-content: center; -} - -.tabs--variant-compact .tabs__scroll-arrow { - display: none; -} - -/* Segmented & compact variants: keep gap from group rule out of the picture */ -.tabs--variant-segmented .tabs__group, -.tabs--variant-compact .tabs__group { - gap: 0; -} - -.tabs--variant-segmented .tabs__group-tabs, -.tabs--variant-compact .tabs__group-tabs { - gap: var(--token-paddingXS, 0.25rem); -} - -/* Responsive: segmented variant collapses to scrollable on narrow screens */ -@media (max-width: 479px) { - .tabs--variant-segmented .tabs__list { - overflow-x: auto; - } -} - -/* ============================================================================ - * Reduced motion support - * ============================================================================ */ - -@media (prefers-reduced-motion: reduce) { - .tabs__list { - scroll-behavior: auto; - } - - .tabs__scroll-arrow, - .tabs__tab, - .tabs__dropdown-trigger, - .tabs__dropdown-item, - .tabs__dropdown-arrow, - .tabs__overflow-btn, - .tabs__overflow-item { - transition: none; - } - - .tabs__dropdown-menu { - animation: none; - } - - .tabs__scroll-arrow:active { - transform: none; - } - - /* Segmented/compact variants reuse the same .tabs__tab selector above */ - .tabs--variant-segmented .tabs__tab, - .tabs--variant-compact .tabs__tab { - transition: none; - } -} diff --git a/src/components/Tabs/Tabs.test.tsx b/src/components/Tabs/Tabs.test.tsx deleted file mode 100644 index e8c4755..0000000 --- a/src/components/Tabs/Tabs.test.tsx +++ /dev/null @@ -1,777 +0,0 @@ -/** - * Tests for the unified Tabs component - * - * Tests cover: - * - Tab rendering (flat and grouped) - * - Tab switching behavior - * - Keyboard navigation (Arrow keys, Home, End) - * - Accessibility attributes (ARIA roles, aria-selected) - * - Controlled vs uncontrolled state - * - The labelExtra slot - * - Group separators and labels - * - Overflow menu (overflowMode="menu") - * - Mobile dropdown (overflowMode="dropdown") - * - Touch swipe navigation - * - Auto-scroll to active tab - * - Edge cases - */ - -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { render, screen, waitFor, fireEvent, act } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { Tabs, type TabItem, type TabGroupMeta } from "./Tabs"; - -describe("Tabs", () => { - const mockTabs: TabItem[] = [ - { id: "tab1", label: "Tab 1", content:
Content 1
}, - { id: "tab2", label: "Tab 2", content:
Content 2
}, - { id: "tab3", label: "Tab 3", content:
Content 3
}, - ]; - - beforeEach(() => { - vi.clearAllMocks(); - Object.defineProperty(window, "innerWidth", { - writable: true, - configurable: true, - value: 1024, - }); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - describe("Rendering", () => { - it("should render all tabs", () => { - render(); - - expect(screen.getByRole("tab", { name: "Tab 1" })).toBeInTheDocument(); - expect(screen.getByRole("tab", { name: "Tab 2" })).toBeInTheDocument(); - expect(screen.getByRole("tab", { name: "Tab 3" })).toBeInTheDocument(); - }); - - it("should render first tab content by default", () => { - render(); - - expect(screen.getByText("Content 1")).toBeInTheDocument(); - expect(screen.queryByText("Content 2")).not.toBeInTheDocument(); - expect(screen.queryByText("Content 3")).not.toBeInTheDocument(); - }); - - it("should render custom default tab content", () => { - render(); - - expect(screen.getByText("Content 2")).toBeInTheDocument(); - expect(screen.queryByText("Content 1")).not.toBeInTheDocument(); - expect(screen.queryByText("Content 3")).not.toBeInTheDocument(); - }); - - it("should render with custom className", () => { - const { container } = render(); - - const tabsContainer = container.querySelector(".tabs.custom-tabs"); - expect(tabsContainer).toBeInTheDocument(); - }); - - it("should render tabpanel with custom panelClassName", () => { - render(); - - const tabpanel = screen.getByRole("tabpanel"); - expect(tabpanel).toHaveClass("tabs__panel", "custom-panel-class"); - }); - - it("should render tablist with correct role", () => { - render(); - - const tablist = screen.getByRole("tablist"); - expect(tablist).toBeInTheDocument(); - }); - - it("should render tablist with custom aria-label when provided", () => { - render(); - - const tablist = screen.getByRole("tablist"); - expect(tablist).toHaveAttribute("aria-label", "Custom navigation"); - }); - - it("should render tabpanel with correct role", () => { - render(); - - const tabpanel = screen.getByRole("tabpanel"); - expect(tabpanel).toBeInTheDocument(); - }); - - it("should link tabs to internally rendered panels", () => { - render(); - - expect(screen.getByRole("tab", { name: "Tab 1" })).toHaveAttribute( - "aria-controls", - "tabpanel-tab1", - ); - }); - - it("should not point aria-controls at missing panels when renderPanel is false", () => { - render(); - - expect(screen.queryByRole("tabpanel")).not.toBeInTheDocument(); - expect(screen.getByRole("tab", { name: "Tab 1" })).not.toHaveAttribute( - "aria-controls", - ); - }); - }); - - describe("Tab Switching", () => { - it("should switch to tab when clicked", async () => { - const user = userEvent.setup(); - render(); - - const tab2Button = screen.getByRole("tab", { name: "Tab 2" }); - await user.click(tab2Button); - - expect(screen.getByText("Content 2")).toBeInTheDocument(); - expect(screen.queryByText("Content 1")).not.toBeInTheDocument(); - }); - - it("should call onTabChange callback when tab is clicked", async () => { - const user = userEvent.setup(); - const onTabChange = vi.fn(); - render(); - - const tab2Button = screen.getByRole("tab", { name: "Tab 2" }); - await user.click(tab2Button); - - expect(onTabChange).toHaveBeenCalledWith("tab2"); - expect(onTabChange).toHaveBeenCalledTimes(1); - }); - - it("should allow multiple tab switches", async () => { - const user = userEvent.setup(); - render(); - - const tab2Button = screen.getByRole("tab", { name: "Tab 2" }); - await user.click(tab2Button); - expect(screen.getByText("Content 2")).toBeInTheDocument(); - - const tab3Button = screen.getByRole("tab", { name: "Tab 3" }); - await user.click(tab3Button); - expect(screen.getByText("Content 3")).toBeInTheDocument(); - - const tab1Button = screen.getByRole("tab", { name: "Tab 1" }); - await user.click(tab1Button); - expect(screen.getByText("Content 1")).toBeInTheDocument(); - }); - }); - - describe("Controlled Mode", () => { - it("should respect controlled activeTab prop", () => { - render(); - - expect(screen.getByText("Content 3")).toBeInTheDocument(); - expect(screen.queryByText("Content 1")).not.toBeInTheDocument(); - }); - - it("should not update internal state in controlled mode", async () => { - const user = userEvent.setup(); - const { rerender } = render(); - - const tab2Button = screen.getByRole("tab", { name: "Tab 2" }); - await user.click(tab2Button); - - expect(screen.getByText("Content 1")).toBeInTheDocument(); - - rerender(); - - expect(screen.getByText("Content 2")).toBeInTheDocument(); - }); - - it("should call onTabChange in controlled mode", async () => { - const user = userEvent.setup(); - const onTabChange = vi.fn(); - render(); - - const tab2Button = screen.getByRole("tab", { name: "Tab 2" }); - await user.click(tab2Button); - - expect(onTabChange).toHaveBeenCalledWith("tab2"); - }); - }); - - describe("Keyboard Navigation", () => { - it("should navigate to next tab with ArrowRight", async () => { - const user = userEvent.setup(); - render(); - - const tab1Button = screen.getByRole("tab", { name: "Tab 1" }); - tab1Button.focus(); - - await user.keyboard("{ArrowRight}"); - - expect(screen.getByText("Content 2")).toBeInTheDocument(); - expect(screen.getByRole("tab", { name: "Tab 2" })).toHaveFocus(); - }); - - it("should navigate to previous tab with ArrowLeft", async () => { - const user = userEvent.setup(); - render(); - - const tab2Button = screen.getByRole("tab", { name: "Tab 2" }); - tab2Button.focus(); - - await user.keyboard("{ArrowLeft}"); - - expect(screen.getByText("Content 1")).toBeInTheDocument(); - expect(screen.getByRole("tab", { name: "Tab 1" })).toHaveFocus(); - }); - - it("should wrap to first tab when ArrowRight on last tab", async () => { - const user = userEvent.setup(); - render(); - - const tab3Button = screen.getByRole("tab", { name: "Tab 3" }); - tab3Button.focus(); - - await user.keyboard("{ArrowRight}"); - - expect(screen.getByText("Content 1")).toBeInTheDocument(); - expect(screen.getByRole("tab", { name: "Tab 1" })).toHaveFocus(); - }); - - it("should wrap to last tab when ArrowLeft on first tab", async () => { - const user = userEvent.setup(); - render(); - - const tab1Button = screen.getByRole("tab", { name: "Tab 1" }); - tab1Button.focus(); - - await user.keyboard("{ArrowLeft}"); - - expect(screen.getByText("Content 3")).toBeInTheDocument(); - expect(screen.getByRole("tab", { name: "Tab 3" })).toHaveFocus(); - }); - - it("should navigate to first tab with Home key", async () => { - const user = userEvent.setup(); - render(); - - const tab2Button = screen.getByRole("tab", { name: "Tab 2" }); - tab2Button.focus(); - - await user.keyboard("{Home}"); - - expect(screen.getByText("Content 1")).toBeInTheDocument(); - expect(screen.getByRole("tab", { name: "Tab 1" })).toHaveFocus(); - }); - - it("should navigate to last tab with End key", async () => { - const user = userEvent.setup(); - render(); - - const tab1Button = screen.getByRole("tab", { name: "Tab 1" }); - tab1Button.focus(); - - await user.keyboard("{End}"); - - expect(screen.getByText("Content 3")).toBeInTheDocument(); - expect(screen.getByRole("tab", { name: "Tab 3" })).toHaveFocus(); - }); - - it("should not respond to other keys", async () => { - const user = userEvent.setup(); - render(); - - const tab1Button = screen.getByRole("tab", { name: "Tab 1" }); - tab1Button.focus(); - - await user.keyboard("{Space}"); - await user.keyboard("{Enter}"); - await user.keyboard("a"); - - expect(screen.getByText("Content 1")).toBeInTheDocument(); - }); - }); - - describe("Accessibility", () => { - it("should have correct ARIA attributes for active tab", () => { - render(); - - const activeTab = screen.getByRole("tab", { name: "Tab 1" }); - expect(activeTab).toHaveAttribute("aria-selected", "true"); - expect(activeTab).toHaveAttribute("aria-controls", "tabpanel-tab1"); - expect(activeTab).toHaveAttribute("id", "tab-tab1"); - expect(activeTab).toHaveAttribute("tabIndex", "0"); - }); - - it("should have correct ARIA attributes for inactive tabs", () => { - render(); - - const inactiveTab = screen.getByRole("tab", { name: "Tab 2" }); - expect(inactiveTab).toHaveAttribute("aria-selected", "false"); - expect(inactiveTab).toHaveAttribute("aria-controls", "tabpanel-tab2"); - expect(inactiveTab).toHaveAttribute("id", "tab-tab2"); - expect(inactiveTab).toHaveAttribute("tabIndex", "-1"); - }); - - it("should have correct ARIA attributes for tabpanel", () => { - render(); - - const tabpanel = screen.getByRole("tabpanel"); - expect(tabpanel).toHaveAttribute("id", "tabpanel-tab1"); - expect(tabpanel).toHaveAttribute("aria-labelledby", "tab-tab1"); - expect(tabpanel).toHaveAttribute("tabIndex", "0"); - }); - - it("should update ARIA attributes when tab changes", async () => { - const user = userEvent.setup(); - render(); - - const tab2Button = screen.getByRole("tab", { name: "Tab 2" }); - await user.click(tab2Button); - - const tabpanel = screen.getByRole("tabpanel"); - expect(tabpanel).toHaveAttribute("id", "tabpanel-tab2"); - expect(tabpanel).toHaveAttribute("aria-labelledby", "tab-tab2"); - - expect(tab2Button).toHaveAttribute("aria-selected", "true"); - expect(tab2Button).toHaveAttribute("tabIndex", "0"); - - const tab1Button = screen.getByRole("tab", { name: "Tab 1" }); - expect(tab1Button).toHaveAttribute("aria-selected", "false"); - expect(tab1Button).toHaveAttribute("tabIndex", "-1"); - }); - - it("should have proper CSS classes for active tab", () => { - render(); - - const activeTab = screen.getByRole("tab", { name: "Tab 1" }); - expect(activeTab).toHaveClass("tabs__tab", "tabs__tab--active"); - - const inactiveTab = screen.getByRole("tab", { name: "Tab 2" }); - expect(inactiveTab).toHaveClass("tabs__tab"); - expect(inactiveTab).not.toHaveClass("tabs__tab--active"); - }); - }); - - describe("Tab Grouping", () => { - const groupedTabs: TabItem[] = [ - { - id: "tab1", - label: "Tab 1", - content:
Content 1
, - groupId: "group1", - }, - { - id: "tab2", - label: "Tab 2", - content:
Content 2
, - groupId: "group1", - }, - { - id: "tab3", - label: "Tab 3", - content:
Content 3
, - groupId: "group2", - }, - { - id: "tab4", - label: "Tab 4", - content:
Content 4
, - groupId: "group2", - }, - ]; - - const groups: TabGroupMeta[] = [ - { id: "group1", label: "Group One" }, - { id: "group2", label: "Group Two" }, - ]; - - it("should render group labels", () => { - render(); - - expect(screen.getByText("Group One")).toBeInTheDocument(); - expect(screen.getByText("Group Two")).toBeInTheDocument(); - }); - - it("should not render group labels when showGroupLabels is false", () => { - render(); - - expect(screen.queryByText("Group One")).not.toBeInTheDocument(); - expect(screen.queryByText("Group Two")).not.toBeInTheDocument(); - }); - - it("should render separator between groups", () => { - const { container } = render(); - - const separators = container.querySelectorAll(".tabs__separator"); - expect(separators.length).toBe(1); - }); - - it("should render separator with correct attributes", () => { - const { container } = render(); - - const separator = container.querySelector(".tabs__separator"); - expect(separator).toHaveAttribute("role", "separator"); - expect(separator).toHaveAttribute("aria-hidden", "true"); - }); - }); - - describe("Overflow Menu (overflowMode='menu')", () => { - it("should render overflow button when overflowMode is menu", () => { - const { container } = render(); - - const overflowButton = container.querySelector(".tabs__overflow-btn"); - expect(overflowButton).toBeInTheDocument(); - }); - - it("should not render overflow button when showOverflowControls is false", () => { - const { container } = render( - , - ); - - const overflowButton = container.querySelector(".tabs__overflow-btn"); - expect(overflowButton).not.toBeInTheDocument(); - }); - - it("should toggle overflow menu when button is clicked", async () => { - const { container } = render(); - - const overflowButton = container.querySelector( - ".tabs__overflow-btn", - ) as HTMLButtonElement; - expect(overflowButton).toHaveAttribute("aria-expanded", "false"); - - act(() => { - fireEvent.click(overflowButton); - }); - - await waitFor(() => { - expect(overflowButton).toHaveAttribute("aria-expanded", "true"); - const menu = container.querySelector(".tabs__overflow-menu"); - expect(menu).toBeInTheDocument(); - }); - }); - - it("should render all tabs in overflow menu", async () => { - const { container } = render(); - - const overflowButton = container.querySelector( - ".tabs__overflow-btn", - ) as HTMLButtonElement; - - act(() => { - fireEvent.click(overflowButton); - }); - - await waitFor(() => { - const menuItems = container.querySelectorAll(".tabs__overflow-item"); - expect(menuItems.length).toBe(3); - }); - }); - - it("should close overflow menu on Escape key", async () => { - const user = userEvent.setup(); - const { container } = render(); - - const overflowButton = container.querySelector( - ".tabs__overflow-btn", - ) as HTMLButtonElement; - - act(() => { - fireEvent.click(overflowButton); - }); - - await waitFor(() => { - const menu = container.querySelector(".tabs__overflow-menu"); - expect(menu).toBeInTheDocument(); - }); - - await user.keyboard("{Escape}"); - - await waitFor(() => { - const menu = container.querySelector(".tabs__overflow-menu"); - expect(menu).not.toBeInTheDocument(); - }); - }); - - it("should highlight active tab in overflow menu", async () => { - const { container } = render( - , - ); - - const overflowButton = container.querySelector( - ".tabs__overflow-btn", - ) as HTMLButtonElement; - - act(() => { - fireEvent.click(overflowButton); - }); - - await waitFor(() => { - const menu = container.querySelector(".tabs__overflow-menu"); - expect(menu).toBeInTheDocument(); - }); - - const menuItems = container.querySelectorAll(".tabs__overflow-item"); - expect(menuItems[1]).toHaveClass("tabs__overflow-item--active"); - expect(menuItems[0]).not.toHaveClass("tabs__overflow-item--active"); - }); - }); - - describe("Edge Cases", () => { - it("should handle empty tabs array", () => { - render(); - - const tablist = screen.getByRole("tablist"); - expect(tablist).toBeInTheDocument(); - expect(screen.queryByRole("tab")).not.toBeInTheDocument(); - }); - - it("should handle single tab", () => { - const singleTab = mockTabs.slice(0, 1); - render(); - - expect(screen.getByRole("tab", { name: "Tab 1" })).toBeInTheDocument(); - expect(screen.getByText("Content 1")).toBeInTheDocument(); - }); - - it("should handle invalid defaultTab gracefully", () => { - render(); - - expect(screen.getByText("Content 1")).toBeInTheDocument(); - }); - - it("should update when tabs array changes", () => { - const { rerender } = render(); - - const newTabs: TabItem[] = [ - { id: "new1", label: "New Tab 1", content:
New Content 1
}, - { id: "new2", label: "New Tab 2", content:
New Content 2
}, - ]; - - rerender(); - - expect(screen.getByRole("tab", { name: "New Tab 1" })).toBeInTheDocument(); - expect(screen.getByRole("tab", { name: "New Tab 2" })).toBeInTheDocument(); - expect(screen.queryByRole("tab", { name: "Tab 1" })).not.toBeInTheDocument(); - }); - - it("should handle tabs with special characters in labels", () => { - const specialTabs: TabItem[] = [ - { - id: "tab1", - label: "Tab & Special ", - content:
Content 1
, - }, - { - id: "tab2", - label: 'Tab with "quotes"', - content:
Content 2
, - }, - ]; - - render(); - - expect( - screen.getByRole("tab", { name: "Tab & Special " }), - ).toBeInTheDocument(); - expect( - screen.getByRole("tab", { name: 'Tab with "quotes"' }), - ).toBeInTheDocument(); - }); - - it("should handle content with complex React nodes", () => { - const complexTabs: TabItem[] = [ - { - id: "complex", - label: "Complex Tab", - content: ( -
-

Heading

-

- Paragraph with bold text -

-
    -
  • Item 1
  • -
  • Item 2
  • -
-
- ), - }, - ]; - - render(); - - expect(screen.getByRole("heading", { name: "Heading" })).toBeInTheDocument(); - expect(screen.getByText("bold")).toBeInTheDocument(); - expect(screen.getByText("Item 1")).toBeInTheDocument(); - }); - - it("should handle rapid tab switching", async () => { - const user = userEvent.setup(); - const onTabChange = vi.fn(); - render(); - - const tab2Button = screen.getByRole("tab", { name: "Tab 2" }); - const tab3Button = screen.getByRole("tab", { name: "Tab 3" }); - const tab1Button = screen.getByRole("tab", { name: "Tab 1" }); - - await user.click(tab2Button); - await user.click(tab3Button); - await user.click(tab1Button); - - expect(onTabChange).toHaveBeenCalledTimes(3); - expect(screen.getByText("Content 1")).toBeInTheDocument(); - }); - }); - - describe("Focus Management", () => { - it("should maintain focus on active tab button", () => { - render(); - - const tab1Button = screen.getByRole("tab", { name: "Tab 1" }); - expect(tab1Button).toHaveAttribute("tabIndex", "0"); - - const tab2Button = screen.getByRole("tab", { name: "Tab 2" }); - expect(tab2Button).toHaveAttribute("tabIndex", "-1"); - }); - - it("should update tabIndex when tab changes", async () => { - const user = userEvent.setup(); - render(); - - const tab2Button = screen.getByRole("tab", { name: "Tab 2" }); - await user.click(tab2Button); - - expect(tab2Button).toHaveAttribute("tabIndex", "0"); - - const tab1Button = screen.getByRole("tab", { name: "Tab 1" }); - expect(tab1Button).toHaveAttribute("tabIndex", "-1"); - }); - }); - - describe("Responsive Behavior", () => { - describe("Mobile Dropdown Mode", () => { - beforeEach(() => { - Object.defineProperty(window, "innerWidth", { - writable: true, - configurable: true, - value: 375, - }); - }); - - it("should render dropdown trigger in mobile mode", () => { - const { rerender } = render(); - - window.dispatchEvent(new Event("resize")); - rerender(); - - const dropdown = document.querySelector(".tabs__dropdown-trigger"); - if (dropdown) { - expect(dropdown).toBeInTheDocument(); - } - }); - - it("should display active tab label in dropdown trigger", () => { - const { container, rerender } = render( - , - ); - - window.dispatchEvent(new Event("resize")); - rerender(); - - const dropdownLabel = container.querySelector(".tabs__dropdown-label"); - if (dropdownLabel) { - expect(dropdownLabel).toHaveTextContent("Tab 2"); - } - }); - - it("should maintain active tab across mode changes", () => { - const { rerender } = render(); - - Object.defineProperty(window, "innerWidth", { - writable: true, - configurable: true, - value: 375, - }); - window.dispatchEvent(new Event("resize")); - rerender(); - - expect(screen.getByText("Content 2")).toBeInTheDocument(); - }); - }); - - describe("Auto-scroll to Active Tab", () => { - it("should scroll active tab into view when changed", () => { - const manyTabs: TabItem[] = Array.from({ length: 15 }, (_, i) => ({ - id: `tab${String(i)}`, - label: `Tab ${String(i + 1)}`, - content:
Content {i + 1}
, - })); - - const { rerender } = render(); - - rerender(); - - const activeTab = screen.getByRole("tab", { name: "Tab 11" }); - expect(activeTab).toHaveAttribute("aria-selected", "true"); - }); - }); - }); - - describe("labelExtra", () => { - it("renders a trailing node after the tab label", () => { - const tabs: TabItem[] = [ - { - id: "tab-exp", - label: "ACP", - content:
ACP Content
, - labelExtra: Experimental, - }, - { id: "tab-normal", label: "Normal", content:
Normal
}, - ]; - - render(); - - // The four-name guide vocabulary this replaced was one product's, and - // its label text had to come from that product's locale bundle anyway. - // A slot is what a shared component can offer. - expect(screen.getByTestId("trailing")).toHaveTextContent("Experimental"); - }); - }); - - describe("overflowMode auto-detection", () => { - it("should default to dropdown mode when no groups", () => { - const { container } = render(); - - // No overflow button should exist (dropdown mode) - const overflowButton = container.querySelector(".tabs__overflow-btn"); - expect(overflowButton).not.toBeInTheDocument(); - }); - - it("should default to menu mode when groups are provided", () => { - const groups: TabGroupMeta[] = [{ id: "g1", label: "Group" }]; - const tabsWithGroup: TabItem[] = [ - { id: "t1", label: "T1", content:
C1
, groupId: "g1" }, - ]; - - const { container } = render(); - - const overflowButton = container.querySelector(".tabs__overflow-btn"); - expect(overflowButton).toBeInTheDocument(); - }); - - it("should allow explicit override of auto-detected mode", () => { - const groups: TabGroupMeta[] = [{ id: "g1", label: "Group" }]; - const tabsWithGroup: TabItem[] = [ - { id: "t1", label: "T1", content:
C1
, groupId: "g1" }, - ]; - - const { container } = render( - , - ); - - // With explicit dropdown mode, no overflow menu - const overflowButton = container.querySelector(".tabs__overflow-btn"); - expect(overflowButton).not.toBeInTheDocument(); - }); - }); -}); diff --git a/src/components/Tabs/Tabs.tsx b/src/components/Tabs/Tabs.tsx deleted file mode 100644 index 50c3be4..0000000 --- a/src/components/Tabs/Tabs.tsx +++ /dev/null @@ -1,889 +0,0 @@ -/** - * Tabs Component - * - * Unified tab navigation component with optional grouping, guide tag badges, - * and responsive overflow handling. Follows WCAG 2.1 ARIA tab pattern. - * - * Features: - * - Scroll arrows for overflow - * - Mobile dropdown mode (overflowMode="dropdown", < 480px) - * - Overflow menu mode (overflowMode="menu", < 768px) - * - Tab grouping with separators and labels - * - Guide tag badge support (required, recommended, beta, experimental) - * - Touch swipe navigation (dropdown mode) - * - Auto-scroll to active tab - * - Full keyboard navigation (Arrow keys, Home, End) - */ - -import { - useState, - useCallback, - useRef, - useEffect, - useMemo, - type ReactNode, - type KeyboardEvent, - type TouchEvent, -} from "react"; -import "./Tabs.css"; - -export interface TabItem { - id: string; - label: string; - content: ReactNode; - /** - * Trailing node rendered next to the tab label: a count pill, a status - * badge, whatever the consumer's vocabulary calls for. - * - * This used to sit beside a `tag` prop that took one of four fixed names, - * required / recommended / beta / experimental, and rendered a Badge for - * it. Those are one product's guide vocabulary, and the arrangement had - * already split across the boundary: the badge variant lived here while the - * label text had to be passed in from the consumer's locale bundle. A - * consumer that wants that badge renders it here, and owns both halves. - */ - labelExtra?: ReactNode; - /** - * Optional leading node rendered before the tab label (e.g. an icon). - * Useful when migrating segmented controls that prefix the label with - * an SVG glyph (e.g. ImportExportDialog source toggle: File / Paste / URL). - */ - labelPrefix?: ReactNode; - /** Assign this tab to a group (used with the `groups` prop) */ - groupId?: string; -} - -export interface TabGroupMeta { - id: string; - label: string; -} - -export type TabOverflowMode = "dropdown" | "menu"; - -/** - * Visual variant of the tab bar. - * - * - `"underlined"` (default): pill container with raised active tab and scroll - * arrows; used for primary page-level navigation that may overflow. - * - `"segmented"`: gray-background segmented control with raised active tab; - * intended for short, fixed-arity in-page mode toggles. - * - `"compact"`: smaller segmented control with reduced padding; used where - * the tab bar shares a row with other controls (e.g. StatisticsPage). - */ -export type TabVariant = "underlined" | "segmented" | "compact"; - -export interface TabsProps { - tabs: TabItem[]; - /** Group definitions for organizing tabs with separators and labels */ - groups?: TabGroupMeta[]; - defaultTab?: string; - activeTab?: string; - onTabChange?: (tabId: string) => void; - className?: string; - panelClassName?: string; - ariaLabel?: string; - /** Show scroll arrows and overflow controls (default: true) */ - showOverflowControls?: boolean; - /** Show group labels in the tab bar and overflow menu (default: true) */ - showGroupLabels?: boolean; - /** - * Overflow strategy for small screens: - * - "dropdown": mobile dropdown trigger (< 480px) — default when no groups - * - "menu": overflow menu button (< 768px) — default when groups are provided - */ - overflowMode?: TabOverflowMode; - /** - * Visual variant. Defaults to `"underlined"` (the original Tabs look). - * Use `"segmented"` for iOS-style pill toggles, `"compact"` for a smaller - * variant suitable for in-header use alongside other controls. - */ - variant?: TabVariant; - /** - * When using `variant="segmented"` or `"compact"`, make each tab fill the - * container evenly (flex: 1 per tab). Useful for short, equal-arity mode - * toggles like the Models page tab bar. Defaults to false. - */ - fillContainer?: boolean; - /** - * Render the tab panel (the active tab's `content`) below the tab list. - * Defaults to true. Set to false when the consumer manages its own panel - * rendering outside the Tabs component (e.g. pages that interleave panels - * with other in-header elements). - * - * When false, each `TabItem.content` is ignored — the consumer is expected - * to inspect `activeTab` / `onTabChange` and render its own panel. - */ - renderPanel?: boolean; - /** Accessible label for the overflow-menu trigger button. Default: "More tabs" */ - moreTabsLabel?: string; - /** Accessible label for the mobile dropdown trigger. Default: "Select tab" */ - selectTabLabel?: string; - /** Accessible label for the left scroll-arrow button. Default: "Scroll left" */ - scrollLeftLabel?: string; - /** Accessible label for the right scroll-arrow button. Default: "Scroll right" */ - scrollRightLabel?: string; -} - -// ============================================================================ -// Component -// ============================================================================ - -export function Tabs({ - tabs, - groups = [], - defaultTab, - activeTab: controlledTab, - onTabChange, - className = "", - panelClassName = "", - ariaLabel, - showOverflowControls = true, - showGroupLabels = true, - overflowMode: explicitOverflowMode, - variant = "underlined", - fillContainer = false, - renderPanel = true, - moreTabsLabel = "More tabs", - selectTabLabel = "Select tab", - scrollLeftLabel = "Scroll left", - scrollRightLabel = "Scroll right", -}: TabsProps) { - // Auto-detect overflow mode from groups presence - const overflowMode = - explicitOverflowMode ?? (groups.length > 0 ? "menu" : "dropdown"); - - // ---- State ---- - const [internalActiveTab, setInternalActiveTab] = useState( - defaultTab || tabs[0]?.id || "", - ); - const [canScrollLeft, setCanScrollLeft] = useState(false); - const [canScrollRight, setCanScrollRight] = useState(false); - - // Dropdown mode state (overflowMode === "dropdown") - const [isMobileDropdown, setIsMobileDropdown] = useState(false); - const [isDropdownOpen, setIsDropdownOpen] = useState(false); - - // Overflow menu state (overflowMode === "menu") - const [showOverflowMenu, setShowOverflowMenu] = useState(false); - const [focusedMenuIndex, setFocusedMenuIndex] = useState(-1); - - // ---- Refs ---- - const tabListRef = useRef(null); - const tabRefs = useRef>(new Map()); - const dropdownContainerRef = useRef(null); - const overflowMenuRef = useRef(null); - const overflowButtonRef = useRef(null); - const menuItemRefs = useRef([]); - const scrollTimeoutRef = useRef | null>(null); - const touchStartX = useRef(0); - const touchEndX = useRef(0); - - // ---- Derived state ---- - const activeTab = controlledTab ?? internalActiveTab; - - // Group tabs by groupId - const groupedTabs = useMemo(() => { - return groups.length > 0 - ? groups.map((group) => ({ - group, - tabs: tabs.filter((tab) => tab.groupId === group.id), - })) - : [{ group: null as TabGroupMeta | null, tabs }]; - }, [groups, tabs]); - - // Flatten menu items for keyboard navigation - const flattenedMenuTabs = useMemo(() => { - return groupedTabs.flatMap(({ tabs: groupTabs }) => groupTabs); - }, [groupedTabs]); - - // ---- Handlers ---- - - const handleTabClick = useCallback( - (tabId: string) => { - if (!controlledTab) { - setInternalActiveTab(tabId); - } - onTabChange?.(tabId); - setShowOverflowMenu(false); - }, - [controlledTab, onTabChange], - ); - - const handleKeyDown = useCallback( - ( - e: KeyboardEvent | KeyboardEvent, - currentIndex: number, - ) => { - let targetIndex: number; - - switch (e.key) { - case "ArrowRight": - e.preventDefault(); - targetIndex = currentIndex === tabs.length - 1 ? 0 : currentIndex + 1; - break; - case "ArrowLeft": - e.preventDefault(); - targetIndex = currentIndex === 0 ? tabs.length - 1 : currentIndex - 1; - break; - case "Home": - e.preventDefault(); - targetIndex = 0; - break; - case "End": - e.preventDefault(); - targetIndex = tabs.length - 1; - break; - default: - return; - } - - const targetTab = tabs[targetIndex]; - if (targetTab) { - handleTabClick(targetTab.id); - tabRefs.current.get(targetTab.id)?.focus(); - } - }, - [tabs, handleTabClick], - ); - - const setTabRef = useCallback((id: string, element: HTMLButtonElement | null) => { - if (element) { - tabRefs.current.set(id, element); - } else { - tabRefs.current.delete(id); - } - }, []); - - // ---- Scroll controls ---- - - const checkScroll = useCallback(() => { - const container = tabListRef.current; - if (!container || !showOverflowControls) { - setCanScrollLeft(false); - setCanScrollRight(false); - return; - } - - const { scrollLeft, scrollWidth, clientWidth } = container; - setCanScrollLeft(scrollLeft > 0); - setCanScrollRight(scrollLeft + clientWidth < scrollWidth - 1); - }, [showOverflowControls]); - - const scrollTabs = useCallback( - (direction: "left" | "right") => { - const container = tabListRef.current; - if (!container) return; - - const scrollAmount = container.clientWidth * 0.75; - container.scrollBy({ - left: direction === "left" ? -scrollAmount : scrollAmount, - behavior: "smooth", - }); - - if (scrollTimeoutRef.current !== null) { - clearTimeout(scrollTimeoutRef.current); - } - scrollTimeoutRef.current = setTimeout(checkScroll, 300); - }, - [checkScroll], - ); - - // ---- Auto-scroll to active tab ---- - - const scrollToActiveTab = useCallback(() => { - if (!tabListRef.current || isMobileDropdown) return; - - const activeButton = tabRefs.current.get(activeTab); - if (!activeButton) return; - - const container = tabListRef.current; - const buttonRect = activeButton.getBoundingClientRect(); - const containerRect = container.getBoundingClientRect(); - - if (buttonRect.left < containerRect.left) { - container.scrollLeft -= containerRect.left - buttonRect.left + 8; - } else if (buttonRect.right > containerRect.right) { - container.scrollLeft += buttonRect.right - containerRect.right + 8; - } - }, [activeTab, isMobileDropdown]); - - // ---- Mobile dropdown mode ---- - - const checkMobileMode = useCallback(() => { - if (typeof window === "undefined" || overflowMode !== "dropdown") return; - setIsMobileDropdown(window.innerWidth < 480); - }, [overflowMode]); - - const toggleDropdown = useCallback(() => { - setIsDropdownOpen((prev) => !prev); - }, []); - - const handleDropdownKeyDown = useCallback( - (e: KeyboardEvent) => { - if (e.key === "Escape" && isDropdownOpen) { - e.preventDefault(); - setIsDropdownOpen(false); - } - }, - [isDropdownOpen], - ); - - // ---- Touch swipe (dropdown mode) ---- - - const handleTouchStart = useCallback((e: TouchEvent) => { - if (e.touches[0]) { - touchStartX.current = e.touches[0].clientX; - } - }, []); - - const handleTouchMove = useCallback((e: TouchEvent) => { - if (e.touches[0]) { - touchEndX.current = e.touches[0].clientX; - } - }, []); - - const handleTouchEnd = useCallback(() => { - if (!touchStartX.current || !touchEndX.current) return; - - const diff = touchStartX.current - touchEndX.current; - const threshold = 50; - - if (Math.abs(diff) > threshold) { - const currentIndex = tabs.findIndex((tab) => tab.id === activeTab); - const nextTab = tabs[currentIndex + 1]; - const prevTab = tabs[currentIndex - 1]; - - if (diff > 0 && nextTab) { - handleTabClick(nextTab.id); - } else if (diff < 0 && prevTab) { - handleTabClick(prevTab.id); - } - } - - touchStartX.current = 0; - touchEndX.current = 0; - }, [activeTab, tabs, handleTabClick]); - - // ---- Overflow menu (menu mode) ---- - - const handleOverflowMenuKeyDown = useCallback( - (e: KeyboardEvent) => { - const menuItemCount = flattenedMenuTabs.length; - - switch (e.key) { - case "Escape": - e.preventDefault(); - setShowOverflowMenu(false); - setFocusedMenuIndex(-1); - overflowButtonRef.current?.focus(); - break; - case "ArrowDown": - e.preventDefault(); - setFocusedMenuIndex((prev) => { - const next = prev >= menuItemCount - 1 ? 0 : prev + 1; - menuItemRefs.current[next]?.focus(); - return next; - }); - break; - case "ArrowUp": - e.preventDefault(); - setFocusedMenuIndex((prev) => { - const next = prev <= 0 ? menuItemCount - 1 : prev - 1; - menuItemRefs.current[next]?.focus(); - return next; - }); - break; - case "Home": - e.preventDefault(); - setFocusedMenuIndex(0); - menuItemRefs.current[0]?.focus(); - break; - case "End": - e.preventDefault(); - setFocusedMenuIndex(menuItemCount - 1); - menuItemRefs.current[menuItemCount - 1]?.focus(); - break; - default: - break; - } - }, - [flattenedMenuTabs.length], - ); - - const handleOverflowButtonKeyDown = useCallback( - (e: KeyboardEvent) => { - if (e.key === "Escape" && showOverflowMenu) { - e.preventDefault(); - setShowOverflowMenu(false); - setFocusedMenuIndex(-1); - } - }, - [showOverflowMenu], - ); - - // ---- Effects ---- - - // Close dropdown when clicking outside - useEffect(() => { - if (!isDropdownOpen) return; - - const handleClickOutside = (event: MouseEvent) => { - if ( - dropdownContainerRef.current && - !dropdownContainerRef.current.contains(event.target as Node) - ) { - setIsDropdownOpen(false); - } - }; - - document.addEventListener("mousedown", handleClickOutside); - return () => { - document.removeEventListener("mousedown", handleClickOutside); - }; - }, [isDropdownOpen]); - - // Close overflow menu when clicking outside - useEffect(() => { - if (!showOverflowMenu) return undefined; - - const handleClickOutside = (event: MouseEvent) => { - if ( - overflowMenuRef.current && - !overflowMenuRef.current.contains(event.target as Node) - ) { - setShowOverflowMenu(false); - setFocusedMenuIndex(-1); - } - }; - - const handleEscapeKey = (event: globalThis.KeyboardEvent) => { - if (event.key === "Escape") { - setShowOverflowMenu(false); - setFocusedMenuIndex(-1); - overflowButtonRef.current?.focus(); - } - }; - - document.addEventListener("mousedown", handleClickOutside); - document.addEventListener("keydown", handleEscapeKey); - return () => { - document.removeEventListener("mousedown", handleClickOutside); - document.removeEventListener("keydown", handleEscapeKey); - }; - }, [showOverflowMenu]); - - // Focus menu item when overflow menu opens - useEffect(() => { - if (showOverflowMenu && menuItemRefs.current.length > 0) { - const activeIndex = flattenedMenuTabs.findIndex((tab) => tab.id === activeTab); - const indexToFocus = activeIndex >= 0 ? activeIndex : 0; - setFocusedMenuIndex(indexToFocus); - requestAnimationFrame(() => { - menuItemRefs.current[indexToFocus]?.focus(); - }); - } - }, [showOverflowMenu, flattenedMenuTabs, activeTab]); - - // Ensure active tab is valid - useEffect(() => { - if (!tabs.find((tab) => tab.id === activeTab)) { - const firstTab = tabs[0]; - if (firstTab) { - handleTabClick(firstTab.id); - } - } - }, [tabs, activeTab, handleTabClick]); - - // Check mobile mode and scroll on mount/resize - useEffect(() => { - checkMobileMode(); - checkScroll(); - - const handleResize = () => { - checkMobileMode(); - checkScroll(); - }; - - window.addEventListener("resize", handleResize); - return () => { - window.removeEventListener("resize", handleResize); - }; - }, [checkMobileMode, checkScroll]); - - // Scroll to active tab when it changes - useEffect(() => { - scrollToActiveTab(); - }, [activeTab, scrollToActiveTab]); - - // Check scroll when tabs change - useEffect(() => { - checkScroll(); - }, [tabs, checkScroll]); - - // Cleanup scroll timeout on unmount - useEffect(() => { - return () => { - if (scrollTimeoutRef.current !== null) { - clearTimeout(scrollTimeoutRef.current); - } - }; - }, []); - - // ---- Render helpers ---- - - const activeTabContent = tabs.find((tab) => tab.id === activeTab)?.content; - const activeTabLabel = tabs.find((tab) => tab.id === activeTab)?.label; - - const hasGroups = groups.length > 0; - - // Reset menu item refs array each render - menuItemRefs.current = []; - - /** Render a single tab button */ - const renderTabButton = (tab: TabItem, globalIndex: number) => { - const isActive = tab.id === activeTab; - - return ( - - ); - }; - - /** Render the tab list content (flat or grouped) */ - const renderTabListContent = () => { - if (hasGroups) { - return groupedTabs.map(({ group, tabs: groupTabs }, groupIndex) => ( -
- {group && ( - <> - {groupIndex > 0 && ( - - )); - } - - return tabs.map((tab, index) => renderTabButton(tab, index)); - }; - - /** Render the overflow menu (menu mode) */ - const renderOverflowMenu = () => { - if (overflowMode !== "menu" || !showOverflowControls) return null; - - return ( -
- - - {showOverflowMenu && ( -
- {groupedTabs.map(({ group, tabs: groupTabs }) => ( -
- {group && showGroupLabels && ( -
{group.label}
- )} - {groupTabs.map((tab) => { - const isActive = tab.id === activeTab; - const flatIndex = flattenedMenuTabs.findIndex((t) => t.id === tab.id); - return ( - - ); - })} -
- ))} -
- )} -
- ); - }; - - // ---- Variant class helpers ---- - - // For segmented/compact variants we always want a single horizontal pill — - // there is no scrollable arrow affordance and the mobile dropdown trigger - // would visually conflict with the segmented look. Underlined remains the - // only variant that follows the historical "scroll + dropdown" behavior. - const isSegmentedFamily = variant === "segmented" || variant === "compact"; - const variantClass = - variant === "segmented" - ? "tabs--variant-segmented" - : variant === "compact" - ? "tabs--variant-compact" - : ""; - const fillContainerClass = - isSegmentedFamily && fillContainer ? "tabs--fill-container" : ""; - - // ---- Mobile dropdown render (overflowMode === "dropdown") ---- - - // Segmented/compact variants always render as a single inline tab bar — we - // skip the mobile-dropdown branch even when the viewport is narrow so the - // visual matches the existing per-page "segmented control" markup the - // migration replaces. - if (overflowMode === "dropdown" && isMobileDropdown && !isSegmentedFamily) { - return ( -
-
- - - {isDropdownOpen && ( -
- {tabs.map((tab) => ( - - ))} -
- )} -
- - {renderPanel && ( -
- {activeTabContent} -
- )} -
- ); - } - - // ---- Desktop/tablet render ---- - - const modeClass = overflowMode === "menu" ? "tabs--overflow-menu" : ""; - - return ( -
-
- {/* Scroll left arrow */} - {showOverflowControls && canScrollLeft && ( - - )} - -
{ - // Mirror the per-button handler at the tablist level so synthetic - // tests (and unusual focus setups where the tablist itself owns - // the keyboard focus) get the same Arrow/Home/End behavior. Tab - // buttons are still the primary handler; this only fires when - // the event reaches the tablist without being handled by a tab. - const currentIndex = tabs.findIndex((tab) => tab.id === activeTab); - if (currentIndex < 0) return; - handleKeyDown(e, currentIndex); - }} - onTouchStart={overflowMode === "dropdown" ? handleTouchStart : undefined} - onTouchMove={overflowMode === "dropdown" ? handleTouchMove : undefined} - onTouchEnd={overflowMode === "dropdown" ? handleTouchEnd : undefined} - > - {renderTabListContent()} -
- - {/* Scroll right arrow */} - {showOverflowControls && canScrollRight && ( - - )} - - {/* Overflow menu (menu mode) */} - {renderOverflowMenu()} -
- - {renderPanel && ( -
- {activeTabContent} -
- )} -
- ); -} diff --git a/src/components/Tabs/index.ts b/src/components/Tabs/index.ts deleted file mode 100644 index 66d087d..0000000 --- a/src/components/Tabs/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -export { Tabs } from "./Tabs"; -export type { - TabsProps, - TabItem, - TabGroupMeta, - TabOverflowMode, - TabVariant, -} from "./Tabs"; diff --git a/src/components/Tooltip/Tooltip.css b/src/components/Tooltip/Tooltip.css deleted file mode 100644 index f6f6593..0000000 --- a/src/components/Tooltip/Tooltip.css +++ /dev/null @@ -1,50 +0,0 @@ -/** - * Tooltip Component Styles - * - * Base tooltip styles used across the app. - * Can be extended with contentClassName for specific use cases. - */ - -.tooltip__wrapper { - display: inline-flex; -} - -.tooltip__wrapper:focus-visible { - outline: var(--token-focusRingWidth, 2px) var(--token-focusRingStyle, solid) - var(--token-focusRingColor, var(--token-colorPrimary)); - outline-offset: var(--token-focusRingOffset, 2px); - border-radius: var(--token-borderRadiusSM, 0.25rem); -} - -/* Allow children to handle their own focus styling if needed */ -.tooltip__wrapper:focus:not(:focus-visible) { - outline: none; -} - -.tooltip__content { - position: fixed; - z-index: var(--token-zIndexTooltip, 1300); - max-width: 280px; - padding: 0.5rem 0.75rem; - background-color: #1f2937; - color: #f3f4f6; - border-radius: 6px; - font-size: var(--token-fontSize, 0.875rem); - line-height: 1.4; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); -} - -.tooltip__content p, -.tooltip__content span { - color: #f3f4f6; -} - -.tooltip__text { - margin: 0; - color: #f3f4f6; -} - -/* ParamTooltip specific styles - width override */ -.tooltip__content.param-tooltip__content { - width: 280px; -} diff --git a/src/components/Tooltip/Tooltip.css.test.ts b/src/components/Tooltip/Tooltip.css.test.ts deleted file mode 100644 index cfa1aa2..0000000 --- a/src/components/Tooltip/Tooltip.css.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * The tooltip content has to be able to receive pointer events. - * - * WCAG 2.1 SC 1.4.13 (Hoverable) requires the pointer to be able to move onto - * the content without it disappearing. `pointer-events: none` makes that - * impossible from CSS alone, whatever the component does: the element is never - * the target of a pointer event, so arriving on it fires nothing, the pending - * hide is never cancelled, and the content goes away while the pointer is over - * it. It also cannot be selected, which is the other half of why a reader - * moves onto a tooltip. - * - * This is a separate guard from the behaviour tests because jsdom does not - * implement `pointer-events`, so every one of those tests passes with the - * declaration in place. The component shipped in exactly that state. - */ -import { readFileSync } from "node:fs"; -import { join } from "node:path"; -import { describe, expect, it } from "vitest"; - -const CSS = readFileSync(join(__dirname, "Tooltip.css"), "utf8"); - -/** Comments blanked out, so prose about the property is not read as one. */ -function withoutComments(source: string): string { - return source.replace(/\/\*[\s\S]*?\*\//g, (block) => block.replace(/[^\n]/g, " ")); -} - -describe("Tooltip.css", () => { - it("does not disable pointer events anywhere", () => { - const declarations = - withoutComments(CSS).match(/pointer-events\s*:\s*[^;}]+/g) ?? []; - - expect(declarations.filter((d) => /none/.test(d))).toEqual([]); - }); -}); diff --git a/src/components/Tooltip/Tooltip.test.tsx b/src/components/Tooltip/Tooltip.test.tsx deleted file mode 100644 index ca478c9..0000000 --- a/src/components/Tooltip/Tooltip.test.tsx +++ /dev/null @@ -1,528 +0,0 @@ -/** - * Tooltip Component Tests - * - * Tests cover: - * - Rendering the trigger (children) without showing the tooltip initially - * - Showing/hiding on mouse hover - * - Showing/hiding on keyboard focus - * - All three parts of WCAG 2.1 SC 1.4.13, which the component's docblock cites - * - String content wrapped in a paragraph vs. ReactNode content rendered directly - * - aria-describedby association while visible - * - Custom className, contentClassName, tooltipId, and tabIndex - */ - -import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; -import { act, render, screen, fireEvent, waitFor } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { Tooltip } from "./Tooltip"; - -describe("Tooltip", () => { - describe("rendering", () => { - it("renders the trigger children", () => { - render( - - - , - ); - - expect(screen.getByRole("button", { name: "Trigger" })).toBeInTheDocument(); - }); - - it("does not render the tooltip content until triggered", () => { - render( - - Trigger - , - ); - - expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); - }); - }); - - describe("hover interaction", () => { - it("shows the tooltip on mouse hover", async () => { - const user = userEvent.setup(); - render( - - Trigger - , - ); - - await user.hover(screen.getByText("Trigger")); - - await waitFor(() => { - expect(screen.getByRole("tooltip")).toHaveTextContent("Helpful hint"); - }); - }); - - it("hides the tooltip when the mouse leaves", async () => { - const user = userEvent.setup(); - render( - - Trigger - , - ); - - await user.hover(screen.getByText("Trigger")); - await waitFor(() => { - expect(screen.getByRole("tooltip")).toBeInTheDocument(); - }); - - // The hide is deferred by a grace period so the pointer can reach the - // portalled content, so this is a waitFor rather than a bare assertion. - await user.unhover(screen.getByText("Trigger")); - await waitFor(() => { - expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); - }); - }); - }); - - describe("keyboard focus interaction", () => { - it("shows the tooltip when a child receives focus", async () => { - render( - - - , - ); - - const wrapper = screen.getByRole("button", { name: "Trigger" }) - .parentElement as HTMLElement; - fireEvent.focusIn(wrapper); - - await waitFor(() => { - expect(screen.getByRole("tooltip")).toBeInTheDocument(); - }); - }); - - it("hides the tooltip when focus leaves", async () => { - render( - - - , - ); - - const wrapper = screen.getByRole("button", { name: "Trigger" }) - .parentElement as HTMLElement; - fireEvent.focusIn(wrapper); - await waitFor(() => { - expect(screen.getByRole("tooltip")).toBeInTheDocument(); - }); - - fireEvent.focusOut(wrapper); - expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); - }); - }); - - describe("content", () => { - it("wraps string content in a paragraph", async () => { - const user = userEvent.setup(); - render( - - Trigger - , - ); - - await user.hover(screen.getByText("Trigger")); - - await waitFor(() => { - const tooltip = screen.getByRole("tooltip"); - expect(tooltip.querySelector("p.tooltip__text")).toHaveTextContent( - "Plain text hint", - ); - }); - }); - - it("renders ReactNode content directly without a wrapping paragraph", async () => { - const user = userEvent.setup(); - render( - Rich hint}> - Trigger - , - ); - - await user.hover(screen.getByText("Trigger")); - - await waitFor(() => { - expect(screen.getByTestId("rich-content")).toBeInTheDocument(); - }); - - const tooltip = await screen.findByRole("tooltip"); - expect(tooltip.querySelector("p.tooltip__text")).not.toBeInTheDocument(); - }); - }); - - describe("accessibility", () => { - it("associates the wrapper with the tooltip via aria-describedby while visible", async () => { - const user = userEvent.setup(); - render( - - Trigger - , - ); - - const wrapper = screen.getByText("Trigger").parentElement as HTMLElement; - expect(wrapper).not.toHaveAttribute("aria-describedby"); - - await user.hover(screen.getByText("Trigger")); - - await waitFor(() => { - expect(wrapper).toHaveAttribute("aria-describedby", "my-tooltip"); - }); - - const tooltip = await screen.findByRole("tooltip"); - expect(tooltip).toHaveAttribute("id", "my-tooltip"); - }); - - it("defaults the wrapper tabIndex to 0", () => { - render( - - Trigger - , - ); - - const wrapper = screen.getByText("Trigger").parentElement as HTMLElement; - expect(wrapper).toHaveAttribute("tabIndex", "0"); - }); - - it("respects a custom tabIndex", () => { - render( - - - , - ); - - const wrapper = screen.getByRole("button", { name: "Trigger" }) - .parentElement as HTMLElement; - expect(wrapper).toHaveAttribute("tabIndex", "-1"); - }); - }); - - describe("custom props", () => { - it("applies a custom className to the wrapper", () => { - render( - - Trigger - , - ); - - const wrapper = screen.getByText("Trigger").parentElement as HTMLElement; - expect(wrapper).toHaveClass("tooltip__wrapper"); - expect(wrapper).toHaveClass("custom-wrapper"); - }); - - it("applies a custom contentClassName to the tooltip content", async () => { - const user = userEvent.setup(); - render( - - Trigger - , - ); - - await user.hover(screen.getByText("Trigger")); - - await waitFor(() => { - expect(screen.getByRole("tooltip")).toHaveClass("custom-content"); - }); - }); - }); - /** - * The component's docblock cites WCAG 2.1 SC 1.4.13, which has three parts. - * Dismissible and Hoverable were both missing: there was no Escape handler, - * and the pointer could not reach the content across the gap that separates - * it from the trigger. - * - * These use fake timers because the grace period is the thing under test. - */ - describe("WCAG 2.1 SC 1.4.13", () => { - beforeEach(() => { - vi.useFakeTimers(); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - function open() { - render( - - Trigger - , - ); - const trigger = screen.getByText("Trigger"); - fireEvent.mouseEnter(trigger.parentElement as HTMLElement); - return trigger; - } - - it("closes on Escape without moving the pointer or focus", () => { - const trigger = open(); - expect(screen.getByRole("tooltip")).toBeInTheDocument(); - - fireEvent.keyDown(document, { key: "Escape" }); - - expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); - expect(trigger).toBeInTheDocument(); - }); - - it("leaves other keys alone", () => { - open(); - - fireEvent.keyDown(document, { key: "a" }); - - expect(screen.getByRole("tooltip")).toBeInTheDocument(); - }); - - /** - * These transitions carry `relatedTarget` because that is what decides the - * outcome. React propagates enter and leave through the portal, so a hop - * straight from the trigger onto the content never leaves the component and - * needs no help. What breaks is the 8px gap, where the pointer passes over - * the body on the way across. - */ - it("stays open while the pointer crosses the gap to the tooltip", () => { - const trigger = open(); - const tooltip = screen.getByRole("tooltip"); - - fireEvent.mouseOut(trigger, { relatedTarget: document.body }); - act(() => { - vi.advanceTimersByTime(60); - }); - fireEvent.mouseOver(tooltip, { relatedTarget: document.body }); - act(() => { - vi.advanceTimersByTime(1000); - }); - - expect(screen.getByRole("tooltip")).toBeInTheDocument(); - }); - - it("closes once the pointer leaves the tooltip too", () => { - const trigger = open(); - const tooltip = screen.getByRole("tooltip"); - - fireEvent.mouseOut(trigger, { relatedTarget: document.body }); - fireEvent.mouseOver(tooltip, { relatedTarget: document.body }); - fireEvent.mouseOut(tooltip, { relatedTarget: document.body }); - act(() => { - vi.advanceTimersByTime(1000); - }); - - expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); - }); - - it("closes when the pointer leaves the trigger and goes nowhere near it", () => { - const trigger = open(); - - fireEvent.mouseOut(trigger, { relatedTarget: document.body }); - act(() => { - vi.advanceTimersByTime(1000); - }); - - expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); - }); - - it("stays open while the trigger is still hovered", () => { - open(); - - act(() => { - vi.advanceTimersByTime(5000); - }); - - expect(screen.getByRole("tooltip")).toBeInTheDocument(); - }); - }); - /** - * The content is `position: fixed` in a portal, so it does not travel with - * the trigger the way an absolutely positioned child would. Anything that - * moves the trigger under the viewport has to be measured again, or the - * tooltip points at where the trigger used to be. - * - * The trigger's box is stubbed because jsdom lays nothing out: every real - * `getBoundingClientRect` here is zero, which makes every placement identical - * and every one of these assertions vacuous. - */ - describe("anchoring", () => { - let triggerTop = 300; - - function box(top: number, height: number, left: number, width: number): DOMRect { - return { - top, - bottom: top + height, - height, - left, - right: left + width, - width, - x: left, - y: top, - toJSON: () => ({}), - } as DOMRect; - } - - beforeEach(() => { - triggerTop = 300; - vi.spyOn(Element.prototype, "getBoundingClientRect").mockImplementation(function ( - this: Element, - ) { - return this.classList.contains("tooltip__content") - ? box(0, 40, 0, 120) - : box(triggerTop, 20, 100, 60); - }); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - function open() { - render( - - Trigger - , - ); - fireEvent.mouseEnter(screen.getByText("Trigger").parentElement as HTMLElement); - return screen.getByRole("tooltip"); - } - - it("places the tooltip above the trigger when there is room", () => { - const tooltip = open(); - - // 300 (trigger top) - 40 (tooltip height) - 8 (gap) - expect(tooltip.style.top).toBe("252px"); - expect(tooltip).toHaveClass("tooltip__content--top"); - }); - - it("follows the trigger when the page scrolls under it", () => { - const tooltip = open(); - expect(tooltip.style.top).toBe("252px"); - - triggerTop = 100; - fireEvent.scroll(window); - - expect(tooltip.style.top).toBe("52px"); - }); - - it("follows a scroll on a container between the two", () => { - const tooltip = open(); - const trigger = screen.getByText("Trigger"); - - triggerTop = 500; - // Container scrolls do not bubble, so this only arrives in the capture phase. - fireEvent.scroll(trigger); - - expect(tooltip.style.top).toBe("452px"); - }); - - it("re-places on resize", () => { - const tooltip = open(); - - triggerTop = 20; - fireEvent(window, new Event("resize")); - - // No room above for the tooltip plus its margin, so it flips below. - expect(tooltip.style.top).toBe("48px"); - expect(tooltip).toHaveClass("tooltip__content--bottom"); - }); - }); - /** - * A glossary term is the case `toggleable` exists for: the phrase carries a - * definition a reader summons deliberately, which makes the trigger a control - * and not incidental hover help. Two products had built that contract around - * one before this component existed, and both would have lost it here. - */ - describe("toggleable trigger", () => { - beforeEach(() => {}); - - function renderTrigger(toggleable: boolean) { - const { container } = render( - - Trigger - , - ); - const wrapper = container.querySelector(".tooltip__wrapper"); - if (!(wrapper instanceof HTMLElement)) throw new Error("no wrapper"); - return wrapper; - } - - it("announces the trigger as a button and reports its state", () => { - const wrapper = renderTrigger(true); - expect(screen.getByRole("button", { name: "Trigger" })).toBe(wrapper); - expect(wrapper).toHaveAttribute("aria-expanded", "false"); - - fireEvent.mouseEnter(wrapper); - - expect(wrapper).toHaveAttribute("aria-expanded", "true"); - }); - - it("toggles on Enter and on Space", () => { - const wrapper = renderTrigger(true); - - for (const key of ["Enter", " "]) { - fireEvent.keyDown(wrapper, { key }); - expect(screen.getByRole("tooltip")).toBeInTheDocument(); - expect(wrapper).toHaveAttribute("aria-expanded", "true"); - - fireEvent.keyDown(wrapper, { key }); - expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); - expect(wrapper).toHaveAttribute("aria-expanded", "false"); - } - }); - - it("prevents the default, since Space scrolls and Enter submits", () => { - const wrapper = renderTrigger(true); - - for (const key of ["Enter", " "]) { - const notPrevented = fireEvent.keyDown(wrapper, { key }); - expect(notPrevented).toBe(false); - fireEvent.keyDown(wrapper, { key }); - } - }); - - it("leaves other keys to the page", () => { - const wrapper = renderTrigger(true); - - const notPrevented = fireEvent.keyDown(wrapper, { key: "ArrowDown" }); - - expect(notPrevented).toBe(true); - expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); - }); - - it("claims nothing by default", () => { - const wrapper = renderTrigger(false); - expect(wrapper).not.toHaveAttribute("role"); - expect(wrapper).not.toHaveAttribute("aria-expanded"); - - const notPrevented = fireEvent.keyDown(wrapper, { key: " " }); - - expect(notPrevented).toBe(true); - expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); - }); - - it("still reports expanded when opened by hover rather than by key", () => { - const wrapper = renderTrigger(true); - - fireEvent.mouseEnter(wrapper); - expect(wrapper).toHaveAttribute("aria-expanded", "true"); - - fireEvent.keyDown(wrapper, { key: "Escape" }); - expect(wrapper).toHaveAttribute("aria-expanded", "false"); - }); - }); - /** - * Placement is a layout effect, so the content is placed in the same commit - * that mounts it and is never in the accessibility tree unplaced. Measuring a - * frame later, as this used to, left it mounted and `visibility: hidden` for - * that frame: present in the DOM, absent from the tree, and unfindable by any - * query that respects the tree until the frame ran. Every test above would - * pass either way given a stubbed frame; this one is the difference. - */ - it("is in the accessibility tree in the same tick it opens", () => { - const { container } = render( - - Trigger - , - ); - - fireEvent.mouseEnter(container.querySelector(".tooltip__wrapper") as HTMLElement); - - expect(screen.getByRole("tooltip")).toBeInTheDocument(); - expect(screen.getByRole("tooltip").style.visibility).not.toBe("hidden"); - }); -}); diff --git a/src/components/Tooltip/Tooltip.tsx b/src/components/Tooltip/Tooltip.tsx deleted file mode 100644 index 8917c7e..0000000 --- a/src/components/Tooltip/Tooltip.tsx +++ /dev/null @@ -1,306 +0,0 @@ -/** - * Tooltip Component - * - * A reusable tooltip component that displays content on hover. - * Supports both simple text and complex ReactNode content. - * - * Accessibility, against the three parts of WCAG 2.1 SC 1.4.13: - * - Hoverable: the pointer can move onto the tooltip itself without it - * disappearing. The content sits 8px away from the trigger, so the pointer - * crosses a gap to get there and the hide is deferred over that crossing. - * - Dismissible: Escape closes it without moving the pointer or focus. - * - Persistent: it stays while the trigger is hovered or focused. - * - * Also uses role="tooltip" and aria-describedby so a screen reader announces - * the content with the trigger. `toggleable` additionally makes the trigger a - * button with aria-expanded that Enter and Space operate, for content a reader - * summons rather than passes over. - */ - -import { - useState, - useRef, - useEffect, - useLayoutEffect, - useCallback, - useId, - type KeyboardEvent as ReactKeyboardEvent, - type ReactNode, -} from "react"; -import { createPortal } from "react-dom"; -import "./Tooltip.css"; - -export interface TooltipProps { - /** Tooltip content - can be string or ReactNode */ - content: ReactNode; - /** Children to wrap with tooltip (trigger element) */ - children: ReactNode; - /** Additional class name for the wrapper */ - className?: string; - /** Additional class name for the tooltip content */ - contentClassName?: string; - /** Custom ID for the tooltip (for aria-describedby association) */ - tooltipId?: string; - /** Tab index for the wrapper. Use -1 when wrapping already-focusable elements like buttons. Defaults to 0. */ - tabIndex?: number; - /** - * Announce the trigger as a button carrying `aria-expanded`, and let Enter - * and Space toggle the content. - * - * Off by default: a trigger whose content only supplements what is already - * on screen is not a control and should not claim to be one. Turn it on - * where the content is something a reader summons deliberately. A glossary - * term is the case this exists for, and two products had independently built - * the same trigger contract around one. - * - * Do not combine with `tabIndex={-1}`, which is for wrapping an element that - * is already focusable and already owns its role. - */ - toggleable?: boolean; -} - -/** Long enough to cross the 8px gap to the tooltip, short enough not to linger. */ -const HIDE_GRACE_MS = 120; - -interface TooltipPosition { - top: number; - left: number; - placement: "top" | "bottom"; -} - -export function Tooltip({ - content, - children, - className, - contentClassName, - tooltipId, - tabIndex = 0, - toggleable = false, -}: TooltipProps) { - const [isVisible, setIsVisible] = useState(false); - const [tooltipPosition, setTooltipPosition] = useState(null); - const tooltipRef = useRef(null); - const wrapperRef = useRef(null); - const isMountedRef = useRef(true); - const hideTimerRef = useRef | null>(null); - const generatedId = useId(); - const effectiveTooltipId = tooltipId ?? `tooltip-${generatedId}`; - - // Track mount state - useEffect(() => { - isMountedRef.current = true; - return () => { - isMountedRef.current = false; - if (hideTimerRef.current !== null) clearTimeout(hideTimerRef.current); - }; - }, []); - - // Handle focus events using focusin/focusout which bubble from child elements - // This ensures tooltips show when keyboard focus moves to children (WCAG 1.4.13) - useEffect(() => { - const wrapper = wrapperRef.current; - if (!wrapper) return; - - // focusin/focusout bubble from child elements, unlike focus/blur - const handleFocusIn = () => { - if (isMountedRef.current) { - setIsVisible(true); - } - }; - - const handleFocusOut = () => { - if (isMountedRef.current) { - setIsVisible(false); - setTooltipPosition(null); - } - }; - - wrapper.addEventListener("focusin", handleFocusIn); - wrapper.addEventListener("focusout", handleFocusOut); - - return () => { - wrapper.removeEventListener("focusin", handleFocusIn); - wrapper.removeEventListener("focusout", handleFocusOut); - }; - }, []); - - /** - * Place the tooltip beside the trigger, in whichever direction has room. - * - * Reads both boxes at call time rather than closing over them, because this - * runs again every time the trigger moves under the viewport. - */ - const measure = useCallback(() => { - const wrapper = wrapperRef.current; - const tooltip = tooltipRef.current; - if (!isMountedRef.current || !wrapper || !tooltip) return; - - const wrapperRect = wrapper.getBoundingClientRect(); - const tooltipRect = tooltip.getBoundingClientRect(); - const spaceAbove = wrapperRect.top; - const spaceBelow = window.innerHeight - wrapperRect.bottom; - - const tooltipHeight = tooltipRect.height; - const margin = 10; - - // Determine placement - const placement: "top" | "bottom" = - spaceAbove < tooltipHeight + margin && spaceBelow > spaceAbove ? "bottom" : "top"; - - // Calculate left position (center on wrapper, but ensure it stays within viewport) - const wrapperCenter = wrapperRect.left + wrapperRect.width / 2; - const tooltipWidth = tooltipRect.width; - let left = wrapperCenter - tooltipWidth / 2; - const viewportWidth = window.innerWidth; - - // Keep tooltip within viewport bounds - if (left + tooltipWidth > viewportWidth - 16) { - left = viewportWidth - tooltipWidth - 16; - } - if (left < 16) { - left = 16; - } - - // Calculate top position - const top = - placement === "top" - ? wrapperRect.top - tooltipHeight - 8 - : wrapperRect.bottom + 8; - - setTooltipPosition({ top, left, placement }); - }, []); - - /** - * The content is `position: fixed` and lives in a portal, so nothing moves it - * when the trigger moves. Any scroll between the two, on the page or on a - * container in between, leaves it pointing at where the trigger used to be, - * and a resize can leave it off the edge. Scroll events from a container do - * not bubble, hence the capture phase. - * - * A layout effect, not an effect plus a frame. The content is in the DOM by - * the time this runs and can be measured, and the state it sets is flushed - * before the browser paints, so the tooltip is never painted unplaced. The - * frame it used to wait for was a frame the tooltip spent mounted and - * `visibility: hidden`, which is out of the accessibility tree: present but - * unreadable, and unfindable by any query that respects that tree. - */ - useLayoutEffect(() => { - if (!isVisible) return; - - measure(); - window.addEventListener("scroll", measure, { capture: true, passive: true }); - window.addEventListener("resize", measure); - - return () => { - window.removeEventListener("scroll", measure, { capture: true }); - window.removeEventListener("resize", measure); - }; - }, [isVisible, measure]); - - const handleShow = useCallback(() => { - if (hideTimerRef.current !== null) { - clearTimeout(hideTimerRef.current); - hideTimerRef.current = null; - } - setIsVisible(true); - }, []); - - const hideNow = useCallback(() => { - if (hideTimerRef.current !== null) { - clearTimeout(hideTimerRef.current); - hideTimerRef.current = null; - } - setIsVisible(false); - setTooltipPosition(null); - }, []); - - /** - * Deferred so the pointer can cross the 8px gap between the trigger and the - * tooltip. React propagates enter and leave through the portal, so a direct - * hop onto the content never fires this; the gap does, because the pointer - * passes over a node that belongs to neither element. Hiding on that would - * put the content out of reach (WCAG 2.1 SC 1.4.13, Hoverable). Arriving on - * either element cancels the pending hide. - */ - const handleHide = useCallback(() => { - if (hideTimerRef.current !== null) clearTimeout(hideTimerRef.current); - hideTimerRef.current = setTimeout(() => { - hideTimerRef.current = null; - if (!isMountedRef.current) return; - setIsVisible(false); - setTooltipPosition(null); - }, HIDE_GRACE_MS); - }, []); - - // Dismissible: Escape closes without moving the pointer or focus. Bound to - // the document because focus may sit on a child of the trigger, or nowhere - // at all when the tooltip was opened by hover. - useEffect(() => { - if (!isVisible) return; - const onKeyDown = (e: KeyboardEvent) => { - if (e.key === "Escape") hideNow(); - }; - document.addEventListener("keydown", onKeyDown); - return () => { - document.removeEventListener("keydown", onKeyDown); - }; - }, [isVisible, hideNow]); - - /** - * Enter and Space toggle, for a trigger that is a control rather than - * incidental hover help. Both default to something else, Space scrolling the - * page and Enter submitting an enclosing form, so both are prevented. - */ - const handleKeyDown = useCallback( - (e: ReactKeyboardEvent) => { - if (e.key !== "Enter" && e.key !== " ") return; - e.preventDefault(); - if (isVisible) { - hideNow(); - } else { - handleShow(); - } - }, - [isVisible, hideNow, handleShow], - ); - - const tooltipElement = isVisible && ( - - ); - - return ( -
- {children} - {tooltipElement && createPortal(tooltipElement, document.body)} -
- ); -} diff --git a/src/components/Tooltip/index.ts b/src/components/Tooltip/index.ts deleted file mode 100644 index cf5cc01..0000000 --- a/src/components/Tooltip/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { Tooltip } from "./Tooltip"; -export type { TooltipProps } from "./Tooltip"; diff --git a/src/components/componentStyles.test.ts b/src/components/componentStyles.test.ts new file mode 100644 index 0000000..cc959d1 --- /dev/null +++ b/src/components/componentStyles.test.ts @@ -0,0 +1,107 @@ +/** + * Rules for every ui-common component stylesheet (CONTRIBUTING, "Styling"). + * + * - The whole sheet sits inside `@layer ui-common`, so Astryx's layers lose + * to it and the app's `components` layer and unlayered rules beat it. + * - Every class selector is a `uic-` BEM name. Astryx's own classes are + * not restyled from here. + * - Every `var()` names an Astryx token or a `--uic-` property of ui-common's + * own. No 0.1 `--token-*` name, and no colour literal: a colour comes from + * the theme. + * - No focus styling. Astryx primitives draw focus; a second indicator here + * is the shape of the 0.1 contrast defect (issue #7). + */ +import { readFileSync, readdirSync } from "node:fs"; +import { join, relative } from "node:path"; +import { describe, expect, it } from "vitest"; + +const COMPONENTS_DIR = __dirname; +const ROOT = join(COMPONENTS_DIR, "..", ".."); + +const ASTRYX_TOKENS = new Set( + [ + ...readFileSync( + join(ROOT, "node_modules/@astryxdesign/core/dist/theme/tokens.stylex.js"), + "utf8", + ).matchAll(/"(--[a-z0-9-]+)":/g), + ].map((m) => m[1] ?? ""), +); + +function cssFiles(dir: string): string[] { + const out: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const path = join(dir, entry.name); + if (entry.isDirectory()) out.push(...cssFiles(path)); + else if (entry.name.endsWith(".css")) out.push(path); + } + return out.sort(); +} + +function withoutComments(source: string): string { + return source.replace(/\/\*[\s\S]*?\*\//g, ""); +} + +const FILES = cssFiles(COMPONENTS_DIR).map((path) => ({ + name: relative(COMPONENTS_DIR, path), + css: withoutComments(readFileSync(path, "utf8")), +})); + +describe("component stylesheets", () => { + it("finds them", () => { + expect(FILES.length).toBeGreaterThan(10); + }); + + it.each(FILES)("$name sits wholly inside @layer ui-common", ({ css }) => { + expect(css.trim()).toMatch(/^@layer ui-common\s*\{[\s\S]*\}$/); + expect(css.match(/@layer\b/g)).toHaveLength(1); + }); + + it.each(FILES)("$name uses uic- BEM class names only", ({ css }) => { + const classes = [...css.matchAll(/\.(-?[_a-zA-Z][\w-]*)/g)].map((m) => m[1] ?? ""); + const foreign = classes.filter((c) => !c.startsWith("uic-")); + expect(foreign).toEqual([]); + }); + + it.each(FILES)("$name reads Astryx tokens and --uic- properties only", ({ css }) => { + const names = [...css.matchAll(/var\(\s*(--[A-Za-z0-9-]+)/g)].map( + (m) => m[1] ?? "", + ); + const unknown = names.filter( + (n) => !ASTRYX_TOKENS.has(n) && !n.startsWith("--uic-"), + ); + expect(unknown).toEqual([]); + }); + + it.each(FILES)("$name declares no colour literal", ({ css }) => { + const literals = [ + ...css.matchAll(/#[0-9a-fA-F]{3,8}\b|\b(?:rgba?|hsla?|oklch|lab)\(/g), + ].map((m) => m[0]); + expect(literals).toEqual([]); + }); + + it.each(FILES)("$name leaves focus to Astryx", ({ css }) => { + expect(css).not.toMatch(/:focus/); + expect(css).not.toMatch(/\boutline\s*:/); + }); +}); + +describe("StatCard value sizing", () => { + const css = FILES.find((f) => f.name === join("StatCard", "StatCard.css"))?.css ?? ""; + + /** The declarations of one rule, by exact selector. */ + function ruleBody(selector: string): string { + const match = [...css.matchAll(/([^{}]+)\{([^{}]*)\}/g)].find( + (m) => (m[1] ?? "").trim() === selector, + ); + if (match === undefined) throw new Error(`no rule for "${selector}"`); + return match[2] ?? ""; + } + + // A stat value is a numeric display. Sizing it from a heading token hands + // the number to the consumer's heading ladder, a decision about prose. + it("sizes the value from the font-size ladder, not a heading token", () => { + const body = ruleBody(".uic-stat-card__value"); + expect(body).toMatch(/font-size:\s*var\(--font-size-2xl\)/); + expect(body).not.toMatch(/--text-heading/); + }); +}); diff --git a/src/exports.test.ts b/src/exports.test.ts index 25449d4..871ace4 100644 --- a/src/exports.test.ts +++ b/src/exports.test.ts @@ -104,9 +104,13 @@ describe("export surface rules", () => { } }); - it("a deprecated custom whose name Astryx owns yields to Astryx in the root barrel", () => { + it("no custom shadows an Astryx name: the 0.1 look-alikes are gone", () => { const barrel = result.files.get("src/index.ts") ?? ""; - expect(result.report.legacyCollisions).toContain("Button"); - expect(barrel).not.toMatch(/export \{[^}]*\bButton\b[^}]*\} from "\.\/components/); + expect(result.report.legacyCollisions).toEqual([]); + for (const name of ["Button", "Badge", "Skeleton", "Tooltip", "EmptyState"]) { + expect(barrel).not.toMatch( + new RegExp(`export \\{[^}]*\\b${name}\\b[^}]*\\} from "\\./components`), + ); + } }); }); diff --git a/src/icons/AlertCircleIcon.tsx b/src/icons/AlertCircleIcon.tsx deleted file mode 100644 index f6e95d8..0000000 --- a/src/icons/AlertCircleIcon.tsx +++ /dev/null @@ -1,34 +0,0 @@ -/** - * Alert Circle Icon - * Used for warnings and info alerts - */ - -interface IconProps { - className?: string; - style?: React.CSSProperties; - size?: number; -} - -export function AlertCircleIcon({ className, style, size }: IconProps) { - const sizeValue = size ?? "1em"; - return ( - - ); -} diff --git a/src/index.ts b/src/index.ts index 756f5dd..778b2ee 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,12 +9,6 @@ * Dialog, DialogHeader, DialogHeaderProps, DialogPosition, DialogProps, * DialogPurpose, DialogVariant, DialogVariantMap, ImperativeDialogReturn, * useImperativeDialog. - * - * Deprecated customs whose names Astryx owns. The Astryx export wins - * here; the custom stays at @lablup/ui-common/components/ until 0.3: - * Badge, BadgeProps, Button, ButtonProps, EmptyState, EmptyStateProps, - * ProgressBar, ProgressBarProps, ProgressBarVariant, Skeleton, SkeletonProps, - * Tooltip, TooltipProps. */ export { @@ -1314,41 +1308,10 @@ export type { WordBreak, } from "@astryxdesign/core"; -// BaseCard -// Deprecated, removed in 0.3: this module. Replaced by Card. -export { BaseCard } from "./components/BaseCard"; -export type { - BaseCardProps, - BaseCardState, - BaseCardVariant, -} from "./components/BaseCard"; - -// DataTable -// Deprecated, removed in 0.3: this module. Replaced by Table. -export { DataTable } from "./components/DataTable"; -export type { - DataTableColumn, - DataTablePersistedState, - DataTableProps, - SortDirection, -} from "./components/DataTable"; - // DigitPopIn export { DigitPopIn } from "./components/DigitPopIn"; export type { DigitPopInProps } from "./components/DigitPopIn"; -// Drawer -// Deprecated, removed in 0.3: this module. Replaced by lab/Drawer. -export { Drawer } from "./components/Drawer"; -export type { DrawerProps } from "./components/Drawer"; - -// EmptyState -// Deprecated, removed in 0.3: this module. Replaced by EmptyState. -export type { - EmptyStateAction, - EmptyStateSecondaryAction, -} from "./components/EmptyState"; - // ErrorState export { ErrorState } from "./components/ErrorState"; export type { ErrorAction, ErrorStateProps, ErrorTone } from "./components/ErrorState"; @@ -1361,12 +1324,7 @@ export type { PageHeaderProps } from "./components/PageHeader"; export { PageLayout } from "./components/PageLayout"; export type { PageLayoutProps, PageLayoutVariant } from "./components/PageLayout"; -// ProgressBar -// Deprecated, removed in 0.3: this module. Replaced by ProgressBar. -export type { ProgressBarSize } from "./components/ProgressBar"; - -// Skeleton -// Deprecated, removed in 0.3: Skeleton, SkeletonProps. Replaced by Skeleton. +// Skeleton composites export { SkeletonCard, SkeletonChart, @@ -1384,11 +1342,6 @@ export type { export { SmoothHeight } from "./components/SmoothHeight"; export type { SmoothHeightProps } from "./components/SmoothHeight"; -// Select -// Deprecated, removed in 0.3: this module. Replaced by Selector. -export { Select } from "./components/Select"; -export type { SelectOption, SelectProps } from "./components/Select"; - // StatCard export { StatCard, formatCompactNumber } from "./components/StatCard"; export type { @@ -1400,21 +1353,5 @@ export type { StatCardTrendDirection, } from "./components/StatCard"; -// StatusTag -// Deprecated, removed in 0.3: this module. Replaced by StatusDot. -export { StatusTag } from "./components/StatusTag"; -export type { StatusKind, StatusTagProps } from "./components/StatusTag"; - -// Tabs -// Deprecated, removed in 0.3: this module. Replaced by TabList. -export { Tabs } from "./components/Tabs"; -export type { - TabGroupMeta, - TabItem, - TabOverflowMode, - TabVariant, - TabsProps, -} from "./components/Tabs"; - // usePrefersReducedMotion export { usePrefersReducedMotion } from "./hooks"; diff --git a/src/migrationMap.test.ts b/src/migrationMap.test.ts new file mode 100644 index 0000000..46bc186 --- /dev/null +++ b/src/migrationMap.test.ts @@ -0,0 +1,109 @@ +/** + * `migration/0.1-to-0.2.json` is read by `ui-common upgrade`. It has to agree + * with the package it describes: every removed component is gone, every + * replacement import resolves to a runtime export, and every kept component's + * class rename matches the classes its stylesheet declares. + */ +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +const ROOT = join(__dirname, ".."); + +interface RemovedEntry { + name: string; + replacement: { specifier: string; names: Record }; +} + +interface KeptEntry { + name: string; + classRenames: Record; +} + +const map = JSON.parse( + readFileSync(join(ROOT, "migration", "0.1-to-0.2.json"), "utf8"), +) as { removedComponents: RemovedEntry[]; keptComponents: KeptEntry[] }; + +const pkg = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8")) as { + exports: Record; +}; + +/** `@lablup/ui-common/Badge` → the Astryx module it mirrors. */ +function astryxSpecifier(specifier: string): string { + const sub = specifier.replace(/^@lablup\/ui-common\/?/, ""); + if (sub === "lab") return "@astryxdesign/lab"; + return sub === "" ? "@astryxdesign/core" : `@astryxdesign/core/${sub}`; +} + +describe("migration/0.1-to-0.2.json", () => { + it("lists the twelve removed customs", () => { + expect(map.removedComponents.map((e) => e.name).sort()).toEqual([ + "Badge", + "BaseCard", + "Button", + "DataTable", + "Drawer", + "EmptyState", + "ProgressBar", + "Select", + "Skeleton", + "StatusTag", + "Tabs", + "Tooltip", + ]); + }); + + it.each(map.removedComponents.filter((e) => e.name !== "Skeleton"))( + "$name is gone from src/components", + ({ name }) => { + expect(existsSync(join(ROOT, "src", "components", name))).toBe(false); + }, + ); + + it.each(map.removedComponents)( + "$name's replacement is an exported subpath with the runtime export", + async ({ replacement }) => { + const subpath = `./${replacement.specifier.replace(/^@lablup\/ui-common\/?/, "")}`; + expect(Object.keys(pkg.exports)).toContain(subpath); + const mod = (await import(astryxSpecifier(replacement.specifier))) as Record< + string, + unknown + >; + const component = Object.values(replacement.names).find( + (n) => !n.endsWith("Props"), + ); + expect(component && mod[component]).toBeTruthy(); + }, + ); + + it.each(map.keptComponents)( + "$name's renamed classes are the ones its stylesheet declares", + ({ classRenames }) => { + const css = [ + "PageHeader", + "PageLayout", + "StatCard", + "ErrorState", + "SmoothHeight", + "DigitPopIn", + "Skeleton/SkeletonCard", + "Skeleton/SkeletonText", + "Skeleton/SkeletonChart", + "Skeleton/SkeletonRow", + ] + .flatMap((p) => { + const [dir, file] = p.includes("/") ? p.split("/") : [p, p]; + return [".css", ".tsx"].map((ext) => + readFileSync( + join(ROOT, "src", "components", dir!, `${file}${ext}`), + "utf8", + ), + ); + }) + .join("\n"); + for (const to of Object.values(classRenames)) { + expect(css).toMatch(new RegExp(`[."\` ]${to}\\b`)); + } + }, + ); +}); diff --git a/src/styles/focusIndicator.test.ts b/src/styles/focusIndicator.test.ts index db657cd..cde178b 100644 --- a/src/styles/focusIndicator.test.ts +++ b/src/styles/focusIndicator.test.ts @@ -6,10 +6,11 @@ * complaint. That is how `--token-focusRingColor: #ff7a00` shipped. These * tests are the missing gate: they resolve the token contract the same way a * browser does, composite alpha over an opaque page, and assert the WCAG 2.2 - * SC 1.4.11 floor of 3:1 for every sheet this package ships. + * SC 1.4.11 floor of 3:1 for every deprecated 0.1 sheet this package still + * ships (`styles/base.css`, `styles/themes/*.css`, removed in 0.3). * - * They also pin the two shapes a focus indicator may take, because the defect - * had two spellings and only one of them contained the word `outline`. + * Component stylesheets no longer draw focus: Astryx primitives own it, and + * `src/components/componentStyles.test.ts` keeps focus rules out of them. */ import { readFileSync, readdirSync } from "node:fs"; import { join } from "node:path"; @@ -17,7 +18,6 @@ import { describe, expect, it } from "vitest"; const STYLES_DIR = __dirname; const THEMES_DIR = join(STYLES_DIR, "themes"); -const COMPONENTS_DIR = join(__dirname, "..", "components"); /** The 3:1 non-text contrast floor of WCAG 2.2 SC 1.4.11. */ const MIN_RATIO = 3; @@ -162,43 +162,6 @@ function sheet(theme: string | null): Map { return merged; } -function cssFiles(dir: string): string[] { - const out: string[] = []; - for (const entry of readdirSync(dir, { withFileTypes: true })) { - const path = join(dir, entry.name); - if (entry.isDirectory()) out.push(...cssFiles(path)); - else if (entry.name.endsWith(".css")) out.push(path); - } - return out; -} - -interface RuleBlock { - selector: string; - body: string; -} - -/** Flat rule blocks, so a selector can be read together with its declarations. */ -function ruleBlocks(source: string): RuleBlock[] { - const text = source.replace(/\/\*[\s\S]*?\*\//g, " "); - const out: RuleBlock[] = []; - const stack: { selector: string; start: number }[] = []; - let segment = 0; - for (let i = 0; i < text.length; i++) { - const ch = text.charAt(i); - if (ch === "{") { - stack.push({ selector: text.slice(segment, i).trim(), start: i + 1 }); - segment = i + 1; - } else if (ch === "}") { - const open = stack.pop(); - if (open !== undefined && !text.slice(open.start, i).includes("{")) { - out.push({ selector: open.selector, body: text.slice(open.start, i) }); - } - segment = i + 1; - } - } - return out; -} - const THEME_NAMES = readdirSync(THEMES_DIR) .filter((f) => f.endsWith(".css")) .map((f) => f.replace(/\.css$/, "")); @@ -235,82 +198,3 @@ describe("--token-focusRingColor contrast", () => { } }); }); - -describe("focus indicator shape", () => { - const files = cssFiles(COMPONENTS_DIR); - - it("finds the component stylesheets", () => { - expect(files.length).toBeGreaterThan(10); - }); - - it("never paints an outline from the bare accent", () => { - const offenders: string[] = []; - for (const file of files) { - for (const m of readFileSync(file, "utf8").matchAll(/outline:\s*([^;}]+)/g)) { - const value = m[1] ?? ""; - if (!value.includes("--token-colorPrimary")) continue; - if (!value.includes("--token-focusRingColor")) { - offenders.push(`${file}: outline: ${value.trim()}`); - } - } - } - expect(offenders, "an outline must resolve through --token-focusRingColor").toEqual( - [], - ); - }); - - it("never paints a focus border from the bare accent", () => { - const offenders: string[] = []; - for (const file of files) { - for (const { selector, body } of ruleBlocks(readFileSync(file, "utf8"))) { - if (!selector.includes(":focus")) continue; - for (const declaration of body.split(";")) { - const colon = declaration.indexOf(":"); - if (colon === -1) continue; - const property = declaration.slice(0, colon).trim().toLowerCase(); - const value = declaration.slice(colon + 1); - const paintsBorderColor = - property === "border" || - property === "border-color" || - (property.startsWith("border-") && property.endsWith("-color")); - if (!paintsBorderColor) continue; - if ( - /var\(\s*--token-colorPrimary\s*[,)]/.test(value) && - !value.includes("color-mix") - ) { - offenders.push(`${file}: ${selector} { ${property}:${value.trim()} }`); - } - } - } - } - expect( - offenders, - "a border-drawn focus ring must use the contrast-corrected accent, " + - "not the bare --token-colorPrimary", - ).toEqual([]); - }); - - it("never freezes a colour literal into a focus indicator", () => { - const offenders: string[] = []; - for (const file of files) { - const source = readFileSync(file, "utf8"); - for (const m of source.matchAll(/--token-focusRingColor\s*,([\s\S]*?)\)\s*;/g)) { - if (/#[0-9a-f]{3,8}|rgba?\(/i.test(m[1] ?? "")) - offenders.push(`${file}: ${m[0].trim()}`); - } - for (const { selector, body } of ruleBlocks(source)) { - if (!selector.includes(":focus")) continue; - for (const m of body.matchAll(/color-mix\(([^;]*)\)/g)) { - if (/#[0-9a-f]{3,8}/i.test(m[1] ?? "")) { - offenders.push(`${file}: ${selector} { ${m[0].trim()} }`); - } - } - } - } - expect( - offenders, - "a fixed literal cannot clear 3:1 against a surface it cannot know, " + - "so a focus indicator carries no colour literal (see CONTRIBUTING.md)", - ).toEqual([]); - }); -}); diff --git a/src/styles/scrollbar.test.ts b/src/styles/scrollbar.test.ts index 1219f3a..71b8414 100644 --- a/src/styles/scrollbar.test.ts +++ b/src/styles/scrollbar.test.ts @@ -11,7 +11,7 @@ * rail where the bar took 8.5% of the width. * * The rule these tests enforce is not "no width in a scrollbar rule". Hiding a - * bar is legitimate (`Tabs.css` does it with `display: none`), and so is + * bar is legitimate, and so is * scoping the shared size to one surface. What is not legitimate is a second * source of truth for the size, so a `width` or `height` here has to resolve * through `--token-scrollbarSize`. @@ -22,7 +22,6 @@ import { describe, expect, it } from "vitest"; const STYLES_DIR = __dirname; const THEMES_DIR = join(STYLES_DIR, "themes"); -const COMPONENTS_DIR = join(STYLES_DIR, "..", "components"); const BASE_CSS = readFileSync(join(STYLES_DIR, "base.css"), "utf8"); /** Comments blanked out, so prose about a `width` is not read as one. */ @@ -55,16 +54,6 @@ function unsharedSizes(block: string): string[] { return out; } -function cssFiles(dir: string): string[] { - const out: string[] = []; - for (const entry of readdirSync(dir, { withFileTypes: true })) { - const path = join(dir, entry.name); - if (entry.isDirectory()) out.push(...cssFiles(path)); - else if (entry.name.endsWith(".css")) out.push(path); - } - return out; -} - const THEME_FILES = readdirSync(THEMES_DIR) .filter((name) => name.endsWith(".css")) .sort(); @@ -104,20 +93,4 @@ describe("scrollbar sizing", () => { `var(--token-scrollbarSize) or drop the declaration`, ).toEqual([]); }); - - it("no component stylesheet resizes the scrollbar", () => { - const offenders: string[] = []; - for (const file of cssFiles(COMPONENTS_DIR)) { - for (const block of scrollbarBoxRules(readFileSync(file, "utf8"))) { - for (const declaration of unsharedSizes(block)) { - offenders.push(`${file}: ${declaration}`); - } - } - } - expect( - offenders, - "a component may hide its bar or scope the shared size, but a second " + - "literal size is a second source of truth", - ).toEqual([]); - }); }); diff --git a/src/test/setup.ts b/src/test/setup.ts index 01e4af9..fd8fc08 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -32,8 +32,9 @@ if (!globalThis.ResizeObserver) { // overrides globals it already knows about when they collide with an // existing Node global, and `localStorage` was added to Node after that // allow-list was written, so jsdom's real (working) implementation never -// gets a chance to take its place. DataTable's column-persistence tests -// need a working Storage, so install a minimal in-memory polyfill whenever +// gets a chance to take its place. Nothing here persists to Storage today, +// but a component that does would fail for a reason that has nothing to do +// with it, so install a minimal in-memory polyfill whenever // the environment's own `localStorage` is unusable. function isUsableStorage(storage: unknown): storage is Storage { if (!storage || typeof storage !== "object") return false; From d21faf4f12407e50c1f9437aa6f3149a4424af32 Mon Sep 17 00:00:00 2001 From: Jongeun Date: Fri, 25 Sep 2026 16:36:12 +0000 Subject: [PATCH 11/87] feat: add Modal, ui-common's dialog in place of Astryx Dialog Modal takes every Dialog prop, so a Dialog call site moves by changing the specifier to @lablup/ui-common/Modal and Dialog/DialogProps to Modal/ModalProps. It carries what backend.ai-ui's BAIDialog and BAIModal do that is product-neutral: - a document.body portal instead of the top layer, so layers above the modal band (a notification stack) stay visible and clickable; no aria-modal - a shared level stack: a nested modal paints above its parent, only the topmost traps focus, covered roots go inert; Escape goes through Astryx's layer-dismissal stack, so a popover inside a modal closes alone - trigger focus restore, title-based accessible name, drag-safe backdrop dismissal by purpose, theme name re-emitted on the portal root - content mounted on first open and kept while closed, unmountOnClose to drop it, afterOpenChange on each edge - with title, onAction or footer: a ModalHeader, the body and a footer with a primary action (pending while onAction's promise runs, via Button clickAction) and Cancel, labelled from uic.Modal.ok and uic.Modal.cancel The z-index band defaults to 1100..10999 and moves with configureModalZIndex; useModalLevel lets another portalled surface join the stack. antd-shaped props (open, onOk, okText) stay out, for BUI's adapter. ModalHeader, ModalPosition, ModalPurpose and ModalVariant are Astryx's Dialog parts under Modal names, and DialogHeader, DialogPosition, DialogPurpose and DialogVariant are re-exported unchanged. The generator now allows exactly that: the replacement named by an exclusion's replacedBy may re-export the excluded subpath's names when they resolve to Astryx's own declaration. It also fails when a replacedBy names neither a custom nor a mirrored subpath. --- exports.customs.json | 1 + package.json | 4 + scripts/gen-exports.d.mts | 2 + scripts/gen-exports.mjs | 76 +++- src/components/Modal/Modal.css | 71 ++++ src/components/Modal/Modal.messages.ts | 12 + src/components/Modal/Modal.test.tsx | 503 ++++++++++++++++++++++++ src/components/Modal/Modal.tsx | 495 +++++++++++++++++++++++ src/components/Modal/index.ts | 31 ++ src/components/Modal/modalStack.test.ts | 88 +++++ src/components/Modal/modalStack.ts | 156 ++++++++ src/exports.test.ts | 36 ++ src/i18n/catalog.ts | 4 + src/index.ts | 28 ++ 14 files changed, 1505 insertions(+), 2 deletions(-) create mode 100644 src/components/Modal/Modal.css create mode 100644 src/components/Modal/Modal.messages.ts create mode 100644 src/components/Modal/Modal.test.tsx create mode 100644 src/components/Modal/Modal.tsx create mode 100644 src/components/Modal/index.ts create mode 100644 src/components/Modal/modalStack.test.ts create mode 100644 src/components/Modal/modalStack.ts diff --git a/exports.customs.json b/exports.customs.json index 841f7d0..21a65b1 100644 --- a/exports.customs.json +++ b/exports.customs.json @@ -1,6 +1,7 @@ [ { "name": "DigitPopIn", "source": "components/DigitPopIn/index.ts" }, { "name": "ErrorState", "source": "components/ErrorState/index.ts" }, + { "name": "Modal", "source": "components/Modal/index.ts", "subpath": "Modal" }, { "name": "PageHeader", "source": "components/PageHeader/index.ts" }, { "name": "PageLayout", "source": "components/PageLayout/index.ts" }, { "name": "Skeleton composites", "source": "components/Skeleton/index.ts" }, diff --git a/package.json b/package.json index 2268d7a..c71808c 100644 --- a/package.json +++ b/package.json @@ -552,6 +552,10 @@ "import": "./dist/astryx/theme/neutral/built.js" }, "./theme/neutral/theme.css": "./dist/astryx/theme/neutral/theme.css", + "./Modal": { + "types": "./dist/components/Modal/index.d.ts", + "import": "./dist/components/Modal/index.js" + }, "./package.json": "./package.json" }, "scripts": { diff --git a/scripts/gen-exports.d.mts b/scripts/gen-exports.d.mts index 85333a8..3faf3a9 100644 --- a/scripts/gen-exports.d.mts +++ b/scripts/gen-exports.d.mts @@ -18,6 +18,8 @@ export interface GenerateResult { droppedFromRoot: string[]; /** Deprecated custom names left out because Astryx owns the name. */ legacyCollisions: string[]; + /** Excluded Astryx names a replacement custom re-exports unchanged. */ + reinstated: string[]; }; } diff --git a/scripts/gen-exports.mjs b/scripts/gen-exports.mjs index 851aaf0..cc5b894 100644 --- a/scripts/gen-exports.mjs +++ b/scripts/gen-exports.mjs @@ -203,6 +203,19 @@ function moduleExports(checker, sourceFile) { return { values: values.sort(), types: types.sort() }; } +/** The declaration symbol a module's export `name` finally points at. */ +function exportTarget(checker, sourceFile, name) { + const moduleSymbol = checker.getSymbolAtLocation(sourceFile); + if (!moduleSymbol) return undefined; + const symbol = checker + .getExportsOfModule(moduleSymbol) + .find((s) => s.getName() === name); + if (!symbol) return undefined; + return symbol.flags & ts.SymbolFlags.Alias + ? checker.getAliasedSymbol(symbol) + : symbol; +} + function hasDefaultExport(checker, sourceFile) { const moduleSymbol = checker.getSymbolAtLocation(sourceFile); if (!moduleSymbol) return false; @@ -388,21 +401,60 @@ export async function generate() { } const droppedFromRoot = new Set(); + // name -> the exclusion that dropped it, and the declaration it names. + const droppedBy = new Map(); for (const s of excluded.filter( (e) => e.kind === "js" && e.package === "@astryxdesign/core", )) { const types = typesPath(s); if (!types) continue; + const exclusion = exclusions.find((e) => e.name === s.subpath); const names = moduleExports(checker, sourceOf(types)); - for (const n of [...names.values, ...names.types]) droppedFromRoot.add(n); + for (const n of [...names.values, ...names.types]) { + droppedFromRoot.add(n); + droppedBy.set(n, { + exclusion, + target: exportTarget(checker, sourceOf(types), n), + }); + } + } + + // A replacement has to exist: a custom, or a subpath that is still mirrored. + const customNames = new Set(customs.map((c) => c.name)); + const mirroredSubpaths = new Set(mirrored.map((s) => s.subpath)); + for (const e of exclusions) { + if (e.replacedBy === null) continue; + if (!customNames.has(e.replacedBy) && !mirroredSubpaths.has(e.replacedBy)) { + throw new Error( + `exports.exclude.json: "${e.name}" is replacedBy "${e.replacedBy}", which is ` + + `neither a custom in exports.customs.json nor a mirrored subpath.`, + ); + } } + /** + * The replacement of an excluded subpath may re-export that subpath's own + * names unchanged (Modal re-exports `DialogHeader`), so moving an import to + * it is a rename of the specifier only. Only the identical declaration + * qualifies: a name that means something else still breaks the name rule. + */ + const isReinstated = (custom, n) => { + const dropped = droppedBy.get(n); + if (!dropped || dropped.exclusion?.replacedBy !== custom.name) return false; + const own = exportTarget(checker, sourceOf(join(root, "src", custom.source)), n); + return own !== undefined && own === dropped.target; + }; + const coreValues = coreRoot.values.filter((n) => !droppedFromRoot.has(n)); const coreTypes = coreRoot.types.filter((n) => !droppedFromRoot.has(n)); const coreNames = new Set([...coreRoot.values, ...coreRoot.types]); const labNames = new Set([...labRoot.values, ...labRoot.types]); - const report = { droppedFromRoot: [...droppedFromRoot].sort(), legacyCollisions: [] }; + const report = { + droppedFromRoot: [...droppedFromRoot].sort(), + legacyCollisions: [], + reinstated: [], + }; const customBlocks = []; const rootNames = new Set([...coreValues, ...coreTypes]); @@ -419,6 +471,17 @@ export async function generate() { ["types", names.types], ]) { for (const n of list) { + if (isReinstated(custom, n)) { + if (rootNames.has(n)) { + throw new Error( + `exports.customs.json: "${n}" is exported twice from the root barrel.`, + ); + } + report.reinstated.push(n); + rootNames.add(n); + keep[bucket].push(n); + continue; + } const clashCore = coreNames.has(n); const clashLab = labNames.has(n); if ((clashCore || clashLab) && !deprecated(n)) { @@ -468,6 +531,10 @@ export async function generate() { ` * here; the custom stays at @lablup/ui-common/components/ until 0.3:\n` + wrapComment(report.legacyCollisions) : "") + + (report.reinstated.length > 0 + ? ` *\n * Excluded Astryx names re-exported unchanged by their replacement:\n` + + wrapComment(report.reinstated) + : "") + ` */\n\n` + exportList("export", coreValues, "@astryxdesign/core") + exportList("export type", coreTypes, "@astryxdesign/core") + @@ -552,6 +619,11 @@ async function main() { `Deprecated custom names Astryx owns: ${report.legacyCollisions.join("; ")}`, ); } + if (report.reinstated.length > 0) { + console.log( + `Excluded names re-exported unchanged by their replacement: ${report.reinstated.join(", ")}`, + ); + } } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { diff --git a/src/components/Modal/Modal.css b/src/components/Modal/Modal.css new file mode 100644 index 0000000..40a0bd4 --- /dev/null +++ b/src/components/Modal/Modal.css @@ -0,0 +1,71 @@ +/* + * Modal: the mask, centering and entry motion Astryx `Dialog` gets from the + * top layer and `::backdrop`, restated for a portalled tree. + */ +@layer ui-common { + /* The stack writes `--uic-modal-z` on open (modalStack.ts). The literal is + only the fallback for a root that has not claimed a level yet: the + default band's base. */ + .uic-modal { + position: fixed; + inset: 0; + z-index: var(--uic-modal-z, 1100); + display: flex; + align-items: center; + justify-content: center; + } + + /* A closed modal keeps its children mounted, as a native `` does. */ + .uic-modal--closed { + display: none; + } + + /* Dialog's backdrop wash, without its blur: the tint separates the + surfaces, and the blur is the costliest repaint on screen. */ + .uic-modal__mask { + position: absolute; + inset: 0; + background-color: var(--color-overlay); + } + + /* The animated element, apart from the mask, so only this subtree becomes + a containing block while the keyframe's transform runs. */ + .uic-modal__wrap { + --uic-modal-dir-x: 0px; + --uic-modal-dir-y: var(--spacing-4); + position: relative; + display: flex; + /* The surface can be narrower than the wrap (Dialog caps it), so the + gutter is centred and clicks in it reach the mask. */ + justify-content: center; + pointer-events: none; + animation: uic-modal-enter var(--duration-medium-max) var(--ease-standard) backwards; + } + + .uic-modal__wrap > * { + pointer-events: auto; + } + + /* `position`: statically placed instead of centred. */ + .uic-modal__wrap--positioned { + position: absolute; + } + + @keyframes uic-modal-enter { + from { + opacity: 0; + transform: translate(var(--uic-modal-dir-x), var(--uic-modal-dir-y)) scale(0.95); + } + + to { + opacity: 1; + transform: translate(0, 0) scale(1); + } + } + + @media (prefers-reduced-motion: reduce) { + .uic-modal__wrap { + animation-name: none; + } + } +} diff --git a/src/components/Modal/Modal.messages.ts b/src/components/Modal/Modal.messages.ts new file mode 100644 index 0000000..436e014 --- /dev/null +++ b/src/components/Modal/Modal.messages.ts @@ -0,0 +1,12 @@ +import { defineMessages } from "../../i18n/catalog"; + +export const modalMessages = defineMessages({ + "uic.Modal.ok": { + defaultMessage: "OK", + description: "Default label of the modal's primary action button", + }, + "uic.Modal.cancel": { + defaultMessage: "Cancel", + description: "Default label of the modal's cancel button, which closes it", + }, +}); diff --git a/src/components/Modal/Modal.test.tsx b/src/components/Modal/Modal.test.tsx new file mode 100644 index 0000000..f83f622 --- /dev/null +++ b/src/components/Modal/Modal.test.tsx @@ -0,0 +1,503 @@ +/** + * Modal: the portalled surface (no native ``, nothing outside made + * unavailable), nesting, dismissal by purpose, the content lifecycle, and the + * structured header/footer mode with its catalog defaults. + * + * jsdom treats `inert` as markup only, and there is no layout, so these check + * the attributes and custom properties the browser acts on. + */ +import { useState, type ComponentProps } from "react"; +import { act, fireEvent, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { DialogHeader as CoreDialogHeader } from "@astryxdesign/core/Dialog"; +import { InternationalizationProvider } from "@astryxdesign/core/i18n"; +import { Layout, LayoutContent } from "@astryxdesign/core/Layout"; +import { Theme, defineTheme } from "@astryxdesign/core/theme"; + +import { DialogHeader, Modal, ModalHeader } from "."; +import { MODAL_OPEN_ATTRIBUTE, configureModalZIndex } from "./modalStack"; + +type ModalTestProps = Partial>; + +function renderModal(props: ModalTestProps = {}) { + const onOpenChange = vi.fn(); + const result = render( + + } + content={ + + + + } + /> + , + ); + return { ...result, onOpenChange }; +} + +const rootOf = (name?: string) => + (name === undefined + ? document.querySelector(".uic-modal") + : screen.getByRole("dialog", { name }).closest(".uic-modal")) as HTMLElement; + +const getMask = () => document.querySelector(".uic-modal__mask") as HTMLElement; +const levelOf = (root: HTMLElement) => + Number(root.style.getPropertyValue("--uic-modal-level")); +const zOf = (root: HTMLElement) => Number(root.style.getPropertyValue("--uic-modal-z")); + +afterEach(() => { + configureModalZIndex(); +}); + +describe("Modal surface", () => { + it("portals to document.body without a native ", () => { + const { container } = renderModal(); + const dialog = screen.getByRole("dialog", { name: "Portal title" }); + + expect(dialog.tagName).toBe("DIV"); + expect(document.querySelector("dialog")).toBeNull(); + expect(container.contains(dialog)).toBe(false); + expect(rootOf().parentElement).toBe(document.body); + expect(rootOf()).toHaveAttribute(MODAL_OPEN_ATTRIBUTE); + }); + + it("claims nothing outside the modal is unavailable", () => { + renderModal(); + expect(screen.getByRole("dialog")).not.toHaveAttribute("aria-modal"); + }); + + it("keeps the .astryx-dialog surface themes key off", () => { + renderModal(); + expect(screen.getByRole("dialog").querySelector(".astryx-dialog")).not.toBeNull(); + }); + + it("uses role=alertdialog for a required modal, and lets a passed role win", () => { + const { unmount } = renderModal({ purpose: "required" }); + expect( + screen.getByRole("alertdialog", { name: "Portal title" }), + ).toBeInTheDocument(); + unmount(); + + renderModal({ purpose: "form", role: "alertdialog" }); + expect(screen.getByRole("alertdialog")).toBeInTheDocument(); + }); + + it("re-emits the nearest theme name so theme CSS reaches the portal", () => { + const theme = defineTheme({ name: "uic-modal-test", tokens: {} }); + render( + + + body + + , + ); + expect(rootOf("themed")).toHaveAttribute("data-astryx-theme", "uic-modal-test"); + }); + + it("places a positioned modal with logical inline offsets", () => { + renderModal({ position: { top: 8, end: "2rem" } }); + const wrap = screen.getByRole("dialog"); + expect(wrap).toHaveClass("uic-modal__wrap--positioned"); + expect(wrap.style.top).toBe("8px"); + expect(wrap.style.insetInlineEnd).toBe("2rem"); + expect(wrap.style.bottom).toBe("auto"); + }); + + it("sizes the wrap, so a percentage resolves against the viewport", () => { + renderModal({ width: "80%" }); + expect(screen.getByRole("dialog").style.width).toBe("80%"); + }); + + it("leaves a fullscreen modal to size itself", () => { + renderModal({ variant: "fullscreen", width: 600 }); + expect(screen.getByRole("dialog").style.width).toBe(""); + }); + + it("renders in flow, without a portal, when isInline", () => { + const { container } = render( + + body + , + ); + expect(document.querySelector(".uic-modal")).toBeNull(); + expect(container.textContent).toContain("Preview"); + }); +}); + +describe("Modal dismissal", () => { + it("closes on a backdrop click for an info modal", () => { + const { onOpenChange } = renderModal(); + fireEvent.mouseDown(getMask()); + fireEvent.click(getMask()); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + + it.each(["form", "required"] as const)( + "ignores the backdrop for a %s modal", + (purpose) => { + const { onOpenChange } = renderModal({ purpose }); + fireEvent.mouseDown(getMask()); + fireEvent.click(getMask()); + expect(onOpenChange).not.toHaveBeenCalled(); + }, + ); + + it("keeps a drag that started inside the surface from dismissing it", () => { + const { onOpenChange } = renderModal(); + fireEvent.mouseDown(screen.getByRole("dialog")); + fireEvent.click(getMask()); + expect(onOpenChange).not.toHaveBeenCalled(); + }); + + it.each(["info", "form"] as const)( + "closes on Escape for a %s modal", + async (purpose) => { + const user = userEvent.setup(); + const { onOpenChange } = renderModal({ purpose }); + await user.keyboard("{Escape}"); + expect(onOpenChange).toHaveBeenCalledWith(false); + }, + ); + + it("does not close on Escape for a required modal", async () => { + const user = userEvent.setup(); + const { onOpenChange } = renderModal({ purpose: "required" }); + await user.keyboard("{Escape}"); + expect(onOpenChange).not.toHaveBeenCalled(); + }); + + it("restores focus to the trigger when it closes", () => { + const trigger = document.createElement("button"); + document.body.appendChild(trigger); + trigger.focus(); + + const view = renderModal(); + expect(document.activeElement).not.toBe(trigger); + + view.unmount(); + expect(document.activeElement).toBe(trigger); + trigger.remove(); + }); +}); + +describe("Modal nesting", () => { + function Nested() { + const [isInnerOpen, setIsInnerOpen] = useState(false); + return ( + + + + + + + + + ); + } + + it("stacks from level 0 upward and inerts the covered root", async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole("button", { name: "open inner" })); + + const outer = rootOf("outer"); + const inner = rootOf("inner"); + expect([levelOf(outer), levelOf(inner)]).toEqual([0, 1]); + expect(zOf(inner)).toBeGreaterThan(zOf(outer)); + expect(outer).toHaveAttribute("inert"); + expect(inner).not.toHaveAttribute("inert"); + }); + + it("keeps Tab inside the modal opened on top", async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole("button", { name: "open inner" })); + + screen.getByRole("button", { name: "inner-a" }).focus(); + await user.tab(); + expect(document.activeElement).toBe( + screen.getByRole("button", { name: "inner-b" }), + ); + }); + + it("closes only the top modal on Escape", async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole("button", { name: "open inner" })); + screen.getByRole("button", { name: "inner-a" }).focus(); + + await user.keyboard("{Escape}"); + expect(screen.queryByRole("dialog", { name: "inner" })).toBeNull(); + expect(screen.getByRole("dialog", { name: "outer" })).toBeInTheDocument(); + expect(rootOf("outer")).not.toHaveAttribute("inert"); + }); + + it("honours a zIndex inside the band and ignores one outside it", () => { + const { unmount } = renderModal({ zIndex: 5000 }); + expect(zOf(rootOf())).toBe(5000); + unmount(); + + vi.spyOn(console, "warn").mockImplementation(() => undefined); + renderModal({ zIndex: 10 }); + expect(zOf(rootOf())).toBe(1100); + }); + + it("paints in the band configureModalZIndex sets", () => { + configureModalZIndex({ base: 500, step: 5, max: 900 }); + renderModal(); + expect(zOf(rootOf())).toBe(500); + }); +}); + +describe("Modal content lifecycle", () => { + function Toggle(props: ModalTestProps) { + const [isOpen, setIsOpen] = useState(false); + return ( + <> + + + + + + ); + } + + it("does not render content before the first open", () => { + render(); + expect(screen.queryByLabelText("field")).toBeNull(); + expect(document.querySelector(".uic-modal")).toBeNull(); + }); + + it("fires afterOpenChange on each edge, never on mount", async () => { + const user = userEvent.setup(); + const afterOpenChange = vi.fn(); + render(); + expect(afterOpenChange).not.toHaveBeenCalled(); + + await user.click(screen.getByRole("button", { name: "toggle" })); + expect(afterOpenChange).toHaveBeenLastCalledWith(true); + act(() => { + fireEvent.click(screen.getByRole("button", { name: "toggle", hidden: true })); + }); + expect(afterOpenChange).toHaveBeenLastCalledWith(false); + expect(afterOpenChange).toHaveBeenCalledTimes(2); + }); + + it("keeps content, and its state, mounted while closed", async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole("button", { name: "toggle" })); + await user.type(screen.getByLabelText("field"), "kept"); + act(() => { + fireEvent.click(screen.getByRole("button", { name: "toggle", hidden: true })); + }); + + expect(rootOf()).toHaveClass("uic-modal--closed"); + expect(screen.queryByRole("dialog")).toBeNull(); + expect(screen.getByDisplayValue("kept")).toBeInTheDocument(); + }); + + it("drops content on close with unmountOnClose", async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole("button", { name: "toggle" })); + await user.type(screen.getByLabelText("field"), "dropped"); + act(() => { + fireEvent.click(screen.getByRole("button", { name: "toggle", hidden: true })); + }); + + expect(document.querySelector(".uic-modal")).toBeNull(); + await user.click(screen.getByRole("button", { name: "toggle" })); + expect(screen.getByLabelText("field")).toHaveValue(""); + }); +}); + +describe("Modal structured mode", () => { + it("renders children alone when no structure is asked for", () => { + render( + + only the body + , + ); + expect(screen.getByRole("dialog", { name: "bare" })).toHaveTextContent( + "only the body", + ); + expect(screen.queryByRole("button")).toBeNull(); + }); + + it("names itself from title and closes from the header button", async () => { + const user = userEvent.setup(); + const onOpenChange = vi.fn(); + render( + + body + , + ); + expect(screen.getByRole("dialog", { name: "Rename" })).toHaveTextContent("Folder"); + await user.click(screen.getByRole("button", { name: "Close" })); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + + it("drops the header close button when asked", () => { + render( + + body + , + ); + expect(screen.queryByRole("button", { name: "Close" })).toBeNull(); + }); + + it("generates OK and Cancel with catalog defaults when onAction is set", async () => { + const user = userEvent.setup(); + const onOpenChange = vi.fn(); + const onAction = vi.fn(); + render( + + body + , + ); + + await user.click(screen.getByRole("button", { name: "OK" })); + expect(onAction).toHaveBeenCalledTimes(1); + expect(onOpenChange).not.toHaveBeenCalled(); + + await user.click(screen.getByRole("button", { name: "Cancel" })); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + + it("takes translated defaults from the provider and explicit labels over them", () => { + const messages = { + "ko-KR": { + "uic.Modal.ok": { defaultMessage: "확인" }, + "uic.Modal.cancel": { defaultMessage: "취소" }, + }, + }; + const { unmount } = render( + + + body + + , + ); + expect(screen.getByRole("button", { name: "확인" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "취소" })).toBeInTheDocument(); + unmount(); + + render( + + + body + + , + ); + expect(screen.getByRole("button", { name: "Rename" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Keep" })).toBeInTheDocument(); + }); + + it("holds the action pending until its promise settles", async () => { + const user = userEvent.setup(); + let resolve: () => void = () => undefined; + const onAction = vi.fn( + () => + new Promise((r) => { + resolve = r; + }), + ); + render( + + body + , + ); + const save = screen.getByRole("button", { name: "Save" }); + + await user.click(save); + await user.click(save); + expect(onAction).toHaveBeenCalledTimes(1); + expect(save).toHaveAttribute("aria-busy", "true"); + + await act(async () => { + resolve(); + }); + expect(save).not.toHaveAttribute("aria-busy", "true"); + }); + + it("uses the destructive variant and passes button props through", () => { + render( + + body + , + ); + const button = screen.getByRole("button", { name: "Delete" }); + expect(button).toHaveAttribute("data-variant", "destructive"); + expect(button).toHaveAttribute("type", "submit"); + expect(button).toHaveAttribute("form", "the-form"); + expect(screen.queryByRole("button", { name: "Cancel" })).toBeNull(); + }); + + it("replaces the footer with a node, or removes it with null", () => { + const { unmount } = render( + custom footer} + > + body + , + ); + expect(screen.getByText("custom footer")).toBeInTheDocument(); + unmount(); + + render( + + body + , + ); + expect(screen.queryByRole("button", { name: "OK" })).toBeNull(); + }); + + it("shows a skeleton in place of the body while loading", () => { + render( + + real body + , + ); + expect(screen.queryByText("real body")).toBeNull(); + expect(screen.getByRole("status", { name: "Loading" })).toBeInTheDocument(); + }); +}); + +describe("Modal exports", () => { + it("ModalHeader and DialogHeader are Astryx's DialogHeader", () => { + expect(ModalHeader).toBe(CoreDialogHeader); + expect(DialogHeader).toBe(CoreDialogHeader); + }); +}); diff --git a/src/components/Modal/Modal.tsx b/src/components/Modal/Modal.tsx new file mode 100644 index 0000000..bc098fd --- /dev/null +++ b/src/components/Modal/Modal.tsx @@ -0,0 +1,495 @@ +/** + * Modal + * + * ui-common's dialog. It replaces Astryx `Dialog`, which ui-common does not + * re-export (exports.exclude.json), and takes every `Dialog` prop, so a + * `Dialog` call site moves over by renaming the import. + * + * What it changes about `Dialog`: + * + * - **Portalled, not top layer.** The surface renders into a `document.body` + * portal instead of a native `` promoted with `showModal()`. Nothing + * the app layers above the modal band (a notification stack) is covered or + * made inert, so notices stay visible and clickable over an open modal. + * Covered modal roots are inert instead; see modalStack.ts. + * - **Nesting.** A modal opened from inside another paints above it, and + * only the topmost one traps focus and answers Escape. + * - **Content lifecycle.** Children mount on first open and stay mounted + * while closed, as a native ``'s do. `unmountOnClose` drops them, + * and their state, on close. `afterOpenChange` reports each open and close. + * - **Structure, when asked for.** With `title`, `onAction` or `footer`, the + * modal lays out a header (`ModalHeader`), the body, and a footer with a + * primary action and a Cancel button. Without them it renders `children` + * as they are, exactly like `Dialog`. + * + * @example + * { + * await rename(); + * setIsOpen(false); + * }} + * > + * + * + */ +import { + useEffect, + useEffectEvent, + useId, + useLayoutEffect, + useRef, + useState, + type CSSProperties, + type HTMLAttributes, + type MouseEvent, + type ReactNode, + type Ref, +} from "react"; +import { createPortal } from "react-dom"; +import { + Button, + type ButtonProps, + type ButtonVariant, +} from "@astryxdesign/core/Button"; +import { Dialog, DialogHeader, type DialogProps } from "@astryxdesign/core/Dialog"; +import type { DialogPosition } from "@astryxdesign/core/Dialog"; +import { useFocusTrap, useScrollLock } from "@astryxdesign/core/hooks"; +import { useLayerDismissal } from "@astryxdesign/core/Layer"; +import { Layout, LayoutContent, LayoutFooter } from "@astryxdesign/core/Layout"; +import { dataAttr } from "@astryxdesign/core/naming"; +import { HStack } from "@astryxdesign/core/Stack"; +import { useThemeName } from "@astryxdesign/core/theme"; +import { devWarn, isFocusDetached, mergeRefs } from "@astryxdesign/core/utils"; + +import { useUicTranslator } from "../../i18n/useUicTranslator"; +import { SkeletonText } from "../Skeleton/SkeletonText"; +import { MODAL_OPEN_ATTRIBUTE, useModalLevel } from "./modalStack"; +import "./Modal.css"; + +const HEADING_SELECTOR = '[role="heading"], h1, h2, h3, h4, h5, h6'; +const DIALOG_SELECTOR = 'dialog, [role="dialog"], [role="alertdialog"]'; + +/** Props for the primary action button, beyond what `Modal` sets itself. */ +export type ModalActionButtonProps = Partial< + Omit +>; + +export interface ModalProps extends Omit { + /** Ref to the element carrying `role="dialog"`: a `div`, not a ``. */ + ref?: Ref; + /** The body. Without `title`, `onAction` or `footer`, the whole surface. */ + children?: ReactNode; + /** + * Asks for a z-index inside the modal band. The stack still places a modal + * opened later above this one, and a value outside the band is ignored. + */ + zIndex?: number; + /** + * Called with the new visibility right after `isOpen` changes, never on + * mount. There is no exit animation, so a close edge is the end of the + * close. + */ + afterOpenChange?: (isOpen: boolean) => void; + /** Unmount the content, and drop its state, when the modal closes. */ + unmountOnClose?: boolean; + + /** Header title. Names the dialog. A node is rendered as given. */ + title?: ReactNode; + /** Secondary line under the title. */ + subtitle?: string; + /** Content before the title, e.g. a back button. */ + headerStartContent?: ReactNode; + /** Content after the title, before the close button. */ + headerEndContent?: ReactNode; + /** Whether the header shows a close button. Default: true */ + hasCloseButton?: boolean; + + /** + * Replaces the generated footer. `null` removes the footer. Left + * `undefined`, a footer is generated when `onAction` is set. + */ + footer?: ReactNode | null; + /** Primary action. Returning a promise shows the button pending until it settles. It does not close the modal. */ + onAction?: () => unknown; + /** Primary action label. Default: the catalog's `uic.Modal.ok` ("OK") */ + actionLabel?: string; + /** Primary action variant. Default: "primary" */ + actionVariant?: ButtonVariant; + /** Shows the primary action as pending, for work the modal does not await. */ + isActionLoading?: boolean; + /** Disables the primary action. */ + isActionDisabled?: boolean; + /** More props for the primary action button (`type`, `form`, `icon`, ...). */ + actionButtonProps?: ModalActionButtonProps; + /** Cancel label. Default: the catalog's `uic.Modal.cancel` ("Cancel") */ + cancelLabel?: string; + /** Whether the generated footer has a Cancel button. Default: true */ + hasCancelButton?: boolean; + /** Shows a text skeleton in place of the body. */ + isLoading?: boolean; +} + +/** Restores focus to the opener if focus was lost with the dialog. */ +function restoreTriggerFocus(trigger: HTMLElement | null, root: HTMLElement | null) { + const focusWasLost = + isFocusDetached() || root?.contains(document.activeElement) === true; + if (focusWasLost && trigger?.isConnected) trigger.focus(); +} + +/** The dialog's own title, never a heading of a dialog nested inside it. */ +function findDialogTitle(node: HTMLElement): HTMLElement | null { + for (const heading of node.querySelectorAll(HEADING_SELECTOR)) { + if (heading.closest(DIALOG_SELECTOR) === node) return heading; + } + return null; +} + +/** + * Trigger → viewport-centre direction, for the entry motion. + * SYNC: mirrors `getDialogDirection` in `@astryxdesign/core/Dialog/Dialog`, + * which is not exported; diff it on an Astryx bump. + */ +function getDialogDirection(triggerEl: HTMLElement, distance = 16) { + const rect = triggerEl.getBoundingClientRect(); + const dx = rect.left + rect.width / 2 - window.innerWidth / 2; + const dy = rect.top + rect.height / 2 - window.innerHeight / 2; + const dist = Math.sqrt(dx * dx + dy * dy) || 1; + return { + x: Math.round((dx / dist) * distance), + y: Math.round((dy / dist) * distance), + }; +} + +function toCssLength(value: number | string): string { + return typeof value === "number" ? `${value}px` : value; +} + +/** + * Logical `start`/`end` become `inset-inline-*`; unset offsets are `auto`. + * SYNC: mirrors `resolveDialogPositionOffsets` in `@astryxdesign/core/Dialog`. + */ +function resolvePosition(position: Readonly): CSSProperties { + const { top, bottom, start, end } = position; + return { + top: top !== undefined ? toCssLength(top) : "auto", + bottom: bottom !== undefined ? toCssLength(bottom) : "auto", + insetInlineStart: start !== undefined ? toCssLength(start) : "auto", + insetInlineEnd: end !== undefined ? toCssLength(end) : "auto", + }; +} + +export function Modal({ + isOpen, + onOpenChange, + isInline = false, + width = 400, + maxHeight, + position, + variant = "standard", + purpose = "info", + padding, + zIndex, + afterOpenChange, + unmountOnClose = false, + title, + subtitle, + headerStartContent, + headerEndContent, + hasCloseButton = true, + footer, + onAction, + actionLabel, + actionVariant = "primary", + isActionLoading, + isActionDisabled, + actionButtonProps, + cancelLabel, + hasCancelButton = true, + isLoading = false, + role, + children, + xstyle, + className, + style, + ref, + ...rest +}: ModalProps) { + const t = useUicTranslator(); + + // Content mounts on the first open and then follows `unmountOnClose`. + const [hasOpened, setHasOpened] = useState(isOpen); + if (isOpen && !hasOpened) setHasOpened(true); + const isMounted = isOpen || (hasOpened && !unmountOnClose); + + const wasOpenRef = useRef(isOpen); + const notifyOpenChange = useEffectEvent((open: boolean) => afterOpenChange?.(open)); + useEffect(() => { + if (wasOpenRef.current === isOpen) return; + wasOpenRef.current = isOpen; + notifyOpenChange(isOpen); + // An effect event is not a dependency; this plugin version predates it. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isOpen]); + + // Theme CSS is scoped to `[data-astryx-theme]`, and the portal leaves the + // themed subtree, so the root re-emits the nearest theme's name. + const themeName = useThemeName(); + + const allowEscape = purpose !== "required"; + const allowBackdropClick = purpose === "info"; + const hasConsumerName = rest["aria-label"] != null || rest["aria-labelledby"] != null; + const titleId = useId(); + + const close = () => onOpenChange(false); + + const rootRef = useRef(null); + // Captured before the level stack inerts the covering root, which blurs + // whatever that subtree held. + const triggerRef = useRef(null); + useLayoutEffect(() => { + if (isOpen && !isInline) { + triggerRef.current = document.activeElement as HTMLElement | null; + } + }, [isOpen, isInline]); + + const isActive = isOpen && !isInline; + const isTopmost = useModalLevel(rootRef, isActive, zIndex); + + const { containerRef, focusFirst } = useFocusTrap({ + isActive: isActive && isTopmost, + }); + + // Escape goes through Astryx's shared layer stack, which routes one press + // to the top-most layer: a popover inside the modal closes alone. + useLayerDismissal({ + isActive, + escapeBehavior: allowEscape ? "close" : "block", + onDismiss: close, + getContainer: () => containerRef.current, + }); + + // Passive, not layout: React restores the selection it captured before the + // commit at the end of the mutation phase, undoing an earlier focus move. + useEffect(() => { + if (!isActive) return; + const root = rootRef.current; + return () => { + restoreTriggerFocus(triggerRef.current, root); + triggerRef.current = null; + }; + }, [isActive]); + + useScrollLock(isActive); + + // `Dialog isInline` provides the context `ModalHeader` renders its title id + // from, but nothing points the surface at it. This does. + useEffect(() => { + const node = containerRef.current; + if (!isActive || !node || hasConsumerName) return; + const heading = findDialogTitle(node); + if (!heading) { + node.removeAttribute("aria-labelledby"); + return; + } + if (!heading.id) heading.id = titleId; + node.setAttribute("aria-labelledby", heading.id); + }); + + // Entry motion from the trigger's direction, set before first paint. + useLayoutEffect(() => { + const node = containerRef.current; + if (!isActive || !node) return; + const trigger = triggerRef.current; + const reduced = + typeof window.matchMedia === "function" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches; + if (!reduced && trigger instanceof HTMLElement && trigger !== document.body) { + const { x, y } = getDialogDirection(trigger); + node.style.setProperty("--uic-modal-dir-x", `${x}px`); + node.style.setProperty("--uic-modal-dir-y", `${y}px`); + } + return () => { + node.style.removeProperty("--uic-modal-dir-x"); + node.style.removeProperty("--uic-modal-dir-y"); + }; + }, [isActive, containerRef]); + + // `[data-autofocus]` wins, then the title, then the first focusable. + useEffect(() => { + const node = containerRef.current; + if (!isActive || !node) return; + const target = + node.querySelector("[data-autofocus]") ?? findDialogTitle(node); + target?.focus(); + if (document.activeElement !== target) focusFirst(); + }, [isActive, containerRef, focusFirst]); + + const hasWarnedRef = useRef(false); + useEffect(() => { + const node = containerRef.current; + if (!isActive || hasConsumerName || hasWarnedRef.current || !node) return; + if (findDialogTitle(node) != null) return; + hasWarnedRef.current = true; + devWarn( + "Modal", + "open modal has no accessible name. Pass `title`, render a ModalHeader, " + + "or pass `aria-label`/`aria-labelledby`.", + ); + }, [isActive, hasConsumerName, containerRef]); + + const maskRef = useRef(null); + const pointerDownOnMaskRef = useRef(false); + const handleMouseDown = (event: MouseEvent) => { + pointerDownOnMaskRef.current = event.target === maskRef.current; + }; + const handleClick = (event: MouseEvent) => { + // A drag that started inside the surface must not dismiss it. + const isBackdropClick = + pointerDownOnMaskRef.current && event.target === maskRef.current; + pointerDownOnMaskRef.current = false; + if (isBackdropClick && allowBackdropClick) close(); + }; + + // ------------------------------------------------------------------ body + const isStructured = + title !== undefined || onAction !== undefined || footer !== undefined; + + const generatedFooter = + onAction !== undefined ? ( + + {hasCancelButton && ( + + + + ); +} + +declare function isTouch(): boolean; diff --git a/test/upgrade/fixtures/adapter/expected/src/design-system/common-components.css b/test/upgrade/fixtures/adapter/expected/src/design-system/common-components.css new file mode 100644 index 0000000..fa9fd54 --- /dev/null +++ b/test/upgrade/fixtures/adapter/expected/src/design-system/common-components.css @@ -0,0 +1,8 @@ +.empty-state__title { + font-size: 1.25rem; +} + +:root { + --color-error: #d4380d; + --app-accent: #ff7a00; +} diff --git a/test/upgrade/fixtures/adapter/expected/src/index.scss b/test/upgrade/fixtures/adapter/expected/src/index.scss new file mode 100644 index 0000000..40fea62 --- /dev/null +++ b/test/upgrade/fixtures/adapter/expected/src/index.scss @@ -0,0 +1,14 @@ +@layer reset, theme, base, astryx-base, astryx-theme, ui-common, components, utilities; + +@import "@lablup/ui-common/reset.css"; +@import "@lablup/ui-common/astryx.css"; +@import "@lablup/ui-common/theme/lablup/theme.css"; +@import "@lablup/ui-common/ui-common.css"; +@import "@lablup/ui-common/legacy-tokens.css"; +@import "@lablup/ui-common/lab/lab.css"; + +.app { + .button--primary { + margin: 0; + } +} diff --git a/test/upgrade/fixtures/adapter/expected/src/main.tsx b/test/upgrade/fixtures/adapter/expected/src/main.tsx new file mode 100644 index 0000000..8427de5 --- /dev/null +++ b/test/upgrade/fixtures/adapter/expected/src/main.tsx @@ -0,0 +1,6 @@ +import { createRoot } from "react-dom/client"; +import "./ui-common-entry.css"; +import "./design-system/common-components.css"; +import { App } from "./App"; + +createRoot(document.getElementById("root")!).render(); diff --git a/test/upgrade/fixtures/adapter/expected/src/ui-common-entry.css b/test/upgrade/fixtures/adapter/expected/src/ui-common-entry.css new file mode 100644 index 0000000..0d1791a --- /dev/null +++ b/test/upgrade/fixtures/adapter/expected/src/ui-common-entry.css @@ -0,0 +1,15 @@ +/* + * The @lablup/ui-common stylesheet entry, written by `ui-common upgrade` in + * place of the 0.1 styles/base.css import. Keep it first among your + * stylesheets: the @layer statement fixes the cascade order for everything + * loaded after it. legacy-tokens.css keeps --token-* reads resolving and is + * removed in 0.3. + */ +@layer reset, theme, base, astryx-base, astryx-theme, ui-common, components, utilities; + +@import "@lablup/ui-common/reset.css"; +@import "@lablup/ui-common/astryx.css"; +@import "@lablup/ui-common/theme/lablup/theme.css"; +@import "@lablup/ui-common/ui-common.css"; +@import "@lablup/ui-common/legacy-tokens.css"; +@import "@lablup/ui-common/lab/lab.css"; diff --git a/test/upgrade/fixtures/adapter/expected/ui-common-upgrade-report.md b/test/upgrade/fixtures/adapter/expected/ui-common-upgrade-report.md new file mode 100644 index 0000000..39125fa --- /dev/null +++ b/test/upgrade/fixtures/adapter/expected/ui-common-upgrade-report.md @@ -0,0 +1,88 @@ +# ui-common upgrade report + +`ui-common upgrade` 0.1.0-alpha.19 → 0.2.0-alpha.0 (installed @lablup/ui-common ). + +Scanned 4 files under `src`. + +## Summary + +| | Count | +|---|---:| +| Files changed | 5 | +| package.json changed | yes | +| TODO markers left in code | 6 | +| Manual-review findings | 3 | + +## Steps + +- 0.2.0-alpha.0: 0.1 → 0.2: ui-common on Astryx + +## Changed files + +- `src/design-system/common-adapters.tsx`: +46 −24, components +- `src/design-system/common-components.css`: +0 −2, stylesheet-entry +- `src/index.scss`: +8 −1, stylesheet-entry +- `src/main.tsx`: +1 −1, script-stylesheet-imports +- `src/ui-common-entry.css` (new): +15 −0, stylesheet-entry + +## package.json + +- dependencies["@lablup/ui-common"]: "0.1.0-alpha.19" → "0.2.0-alpha.0". +- added @stylexjs/stylex ^0.19.0 to dependencies. +- added @astryxdesign/lab 0.6.2-canary.c9fb1ad to dependencies: a Drawer moved to @lablup/ui-common/lab, and ui-common pins the lab canary exactly. + +## Manual review + +### TODO markers (6) + +Each is a `TODO(ui-common-upgrade)` comment in the code, above the call it is about. Resolve it, then delete the comment. + +- `src/design-system/common-adapters.tsx:16` Button variant is dynamic; map its values onto Astryx's: primary→primary, secondary→secondary, ghost→ghost, danger→destructive, text→ghost, outline→secondary, success→primary. +- `src/design-system/common-adapters.tsx:17` Button "label" must be a string; it was the element's children. +- `src/design-system/common-adapters.tsx:55` lab Drawer `label` must be a string (it was the title). +- `src/design-system/common-adapters.tsx:56` lab Drawer renders no title, subtitle or footer: put a Heading (and the footer) inside its children, then remove `subtitle` / `footer`. +- `src/design-system/common-adapters.tsx:75` ProgressBar "animated": Astryx ProgressBar always animates its fill. +- `src/design-system/common-adapters.tsx:86` Tooltip "className": Astryx Tooltip has no such prop; it wires the trigger and the ARIA ids itself. Style the trigger, not the tooltip. + +### CSS selectors on 0.1 class names (2) + +Astryx renders none of the 0.1 class names. Restyle through the component's props, the theme, or your `components` layer. Generic names (`.button`, `.select`) may be your own classes: skip those. + +| Where | What | Detail | +|---|---|---| +| `src/design-system/common-components.css:1` | `.empty-state__title` | .empty-state__title (EmptyState) | +| `src/index.scss:11` | `.button--primary` | .button--primary (Button) | + +### DOM hooks on 0.1 class names (0) + +None. + +### Tests querying 0.1 class names (0) + +None. + +### Module mocks of @lablup/ui-common (0) + +None. + +### Custom properties that collide with Astryx tokens (1) + +Astryx declares the same name. Whichever rule wins the cascade now restyles both your CSS and Astryx's components. Rename yours, or set it through a theme (`defineTheme`) on purpose. + +| Where | What | Detail | +|---|---|---| +| `src/design-system/common-components.css:6` | `:root { --color-error: #d4380d }` | --color-error | + +### 0.1 stylesheet paths left in place (0) + +None. + +## Notes + +- Badge: the codemod keeps Badge. Astryx reserves Badge for counts and loud status; a settled value (a tag, a category, a state label) reads better as Token (`@lablup/ui-common/Token`, `label` + `color`). Decide per call site. +- Button: children became `label` (the accessible name, required). `title` became `tooltip`. Sizes collapse onto sm/md/lg (xsmall → sm). +- Select → Selector: `onChange` receives a string value. A 0.1 Select typed over a non-string value needs its own mapping. +- StatusTag → StatusDot: a dot with an accessible label, no visible text. +- Tabs → TabList and DataTable → Table are reshaped only partly: the TODO markers say what is left. +- Drawer → @lablup/ui-common/lab Drawer: `@astryxdesign/lab` is an optional peer of ui-common, pinned to the canary ui-common is built against. The codemod adds it to package.json when it moved a Drawer, and adds `@lablup/ui-common/lab/lab.css` to the stylesheet entry it rewrites; import lab.css yourself if your entry is elsewhere. +- Products' own `--token-*` reads were not rewritten: they belong to your token system. `legacy-tokens.css` keeps library reads resolving until 0.3. diff --git a/test/upgrade/fixtures/adapter/input/package.json b/test/upgrade/fixtures/adapter/input/package.json new file mode 100644 index 0000000..a8c2ed2 --- /dev/null +++ b/test/upgrade/fixtures/adapter/input/package.json @@ -0,0 +1,9 @@ +{ + "name": "adapter-app", + "private": true, + "type": "module", + "dependencies": { + "@lablup/ui-common": "0.1.0-alpha.19", + "react": "^19.2.0" + } +} diff --git a/test/upgrade/fixtures/adapter/input/src/design-system/common-adapters.tsx b/test/upgrade/fixtures/adapter/input/src/design-system/common-adapters.tsx new file mode 100644 index 0000000..7d17701 --- /dev/null +++ b/test/upgrade/fixtures/adapter/input/src/design-system/common-adapters.tsx @@ -0,0 +1,72 @@ +/** + * The only module that imports @lablup/ui-common. Feature code uses the + * product-owned API below. + */ +import type { ReactNode } from "react"; +import { Button as UiButton } from "@lablup/ui-common/components/Button"; +import { EmptyState as UiEmptyState } from "@lablup/ui-common/components/EmptyState"; +import { Select as UiSelect } from "@lablup/ui-common/components/Select"; +import { Drawer as UiDrawer } from "@lablup/ui-common/components/Drawer"; +import { ProgressBar } from "@lablup/ui-common/components/ProgressBar"; +import { Tooltip } from "@lablup/ui-common/components/Tooltip"; +import { Skeleton } from "@lablup/ui-common/components/Skeleton"; + +export function Button({ tone, busy, children, onClick }: { tone: "primary" | "danger"; busy?: boolean; children: ReactNode; onClick?: () => void }) { + return ( + + {children} + + ); +} + +export function Empty({ title, body, onRetry }: { title: string; body: string; onRetry: () => void }) { + return ( + } + showIllustration={false} + primaryAction={{ label: "Retry", onClick: onRetry }} + secondaryAction={{ label: "Docs", href: "/docs" }} + /> + ); +} + +export function RegionSelect({ value, onChange, invalid }: { value: string; onChange: (v: string) => void; invalid: boolean }) { + return ( + + ); +} + +export function Panel({ open, onClose, title, children }: { open: boolean; onClose: () => void; title: string; children: ReactNode }) { + return ( + footer}> + {children} + + ); +} + +export function Loading({ percent }: { percent: number | null }) { + return ( + <> + + + + + + + + + ); +} + +declare function isTouch(): boolean; diff --git a/test/upgrade/fixtures/adapter/input/src/design-system/common-components.css b/test/upgrade/fixtures/adapter/input/src/design-system/common-components.css new file mode 100644 index 0000000..e02b754 --- /dev/null +++ b/test/upgrade/fixtures/adapter/input/src/design-system/common-components.css @@ -0,0 +1,10 @@ +@import "@lablup/ui-common/styles/themes/orange-light.css"; + +.empty-state__title { + font-size: 1.25rem; +} + +:root { + --color-error: #d4380d; + --app-accent: #ff7a00; +} diff --git a/test/upgrade/fixtures/adapter/input/src/index.scss b/test/upgrade/fixtures/adapter/input/src/index.scss new file mode 100644 index 0000000..3f8bb58 --- /dev/null +++ b/test/upgrade/fixtures/adapter/input/src/index.scss @@ -0,0 +1,7 @@ +@import "@lablup/ui-common/styles/base.css"; + +.app { + .button--primary { + margin: 0; + } +} diff --git a/test/upgrade/fixtures/adapter/input/src/main.tsx b/test/upgrade/fixtures/adapter/input/src/main.tsx new file mode 100644 index 0000000..63f469d --- /dev/null +++ b/test/upgrade/fixtures/adapter/input/src/main.tsx @@ -0,0 +1,6 @@ +import { createRoot } from "react-dom/client"; +import "@lablup/ui-common/styles/base.css"; +import "./design-system/common-components.css"; +import { App } from "./App"; + +createRoot(document.getElementById("root")!).render(); diff --git a/test/upgrade/fixtures/css-entry/expected/package.json b/test/upgrade/fixtures/css-entry/expected/package.json new file mode 100644 index 0000000..011ed6a --- /dev/null +++ b/test/upgrade/fixtures/css-entry/expected/package.json @@ -0,0 +1,8 @@ +{ + "name": "css-entry-app", + "private": true, + "dependencies": { + "@lablup/ui-common": "0.2.0-alpha.0", + "@stylexjs/stylex": "^0.19.0" + } +} diff --git a/test/upgrade/fixtures/css-entry/expected/src/index.css b/test/upgrade/fixtures/css-entry/expected/src/index.css new file mode 100644 index 0000000..ab48371 --- /dev/null +++ b/test/upgrade/fixtures/css-entry/expected/src/index.css @@ -0,0 +1,14 @@ +@charset "utf-8"; +@layer reset, theme, base, astryx-base, astryx-theme, ui-common, components, utilities; + +/* App entry stylesheet. */ +@import "@lablup/ui-common/reset.css"; +@import "@lablup/ui-common/astryx.css"; +@import "@lablup/ui-common/theme/lablup/theme.css"; +@import "@lablup/ui-common/ui-common.css"; +@import "@lablup/ui-common/legacy-tokens.css"; +@import "./app.css"; + +body { + margin: 0; +} diff --git a/test/upgrade/fixtures/css-entry/expected/src/print.css b/test/upgrade/fixtures/css-entry/expected/src/print.css new file mode 100644 index 0000000..f271420 --- /dev/null +++ b/test/upgrade/fixtures/css-entry/expected/src/print.css @@ -0,0 +1,2 @@ +/* TODO(ui-common-upgrade): this import of styles/base.css carries "print"; replace it with @lablup/ui-common/reset.css, @lablup/ui-common/astryx.css, @lablup/ui-common/theme/lablup/theme.css, @lablup/ui-common/ui-common.css, @lablup/ui-common/legacy-tokens.css under the same condition, and declare "@layer reset, theme, base, astryx-base, astryx-theme, ui-common, components, utilities;" first. */ +@import url("@lablup/ui-common/styles/base.css") print; diff --git a/test/upgrade/fixtures/css-entry/expected/ui-common-upgrade-report.md b/test/upgrade/fixtures/css-entry/expected/ui-common-upgrade-report.md new file mode 100644 index 0000000..8e36061 --- /dev/null +++ b/test/upgrade/fixtures/css-entry/expected/ui-common-upgrade-report.md @@ -0,0 +1,73 @@ +# ui-common upgrade report + +`ui-common upgrade` 0.1.0-alpha.23 → 0.2.0-alpha.0 (installed @lablup/ui-common ). + +Scanned 2 files under `src`. + +## Summary + +| | Count | +|---|---:| +| Files changed | 2 | +| package.json changed | yes | +| TODO markers left in code | 1 | +| Manual-review findings | 1 | + +## Steps + +- 0.2.0-alpha.0: 0.1 → 0.2: ui-common on Astryx + +## Changed files + +- `src/index.css`: +7 −3, stylesheet-entry +- `src/print.css`: +1 −0, stylesheet-entry + +## package.json + +- dependencies["@lablup/ui-common"]: "0.1.0-alpha.23" → "0.2.0-alpha.0". + +## Manual review + +### TODO markers (1) + +Each is a `TODO(ui-common-upgrade)` comment in the code, above the call it is about. Resolve it, then delete the comment. + +- `src/print.css:1` this import of styles/base.css carries "print"; replace it with @lablup/ui-common/reset.css, @lablup/ui-common/astryx.css, @lablup/ui-common/theme/lablup/theme.css, @lablup/ui-common/ui-common.css, @lablup/ui-common/legacy-tokens.css under the same condition, and declare "@layer reset, theme, base, astryx-base, astryx-theme, ui-common, components, utilities;" first. + +### CSS selectors on 0.1 class names (0) + +None. + +### DOM hooks on 0.1 class names (0) + +None. + +### Tests querying 0.1 class names (0) + +None. + +### Module mocks of @lablup/ui-common (0) + +None. + +### Custom properties that collide with Astryx tokens (0) + +None. + +### 0.1 stylesheet paths left in place (1) + +Scripts, configs or tests that name `@lablup/ui-common/styles/*` directly. base.css and the orange themes are deprecated in 0.2 and removed in 0.3; the Lablup theme replaces them. + +| Where | What | Detail | +|---|---|---| +| `src/print.css:2` | `@import url("@lablup/ui-common/styles/base.css") print;` | 0.1 stylesheet path: deprecated in 0.2, removed in 0.3 | + +## Notes + +- Badge: the codemod keeps Badge. Astryx reserves Badge for counts and loud status; a settled value (a tag, a category, a state label) reads better as Token (`@lablup/ui-common/Token`, `label` + `color`). Decide per call site. +- Button: children became `label` (the accessible name, required). `title` became `tooltip`. Sizes collapse onto sm/md/lg (xsmall → sm). +- Select → Selector: `onChange` receives a string value. A 0.1 Select typed over a non-string value needs its own mapping. +- StatusTag → StatusDot: a dot with an accessible label, no visible text. +- Tabs → TabList and DataTable → Table are reshaped only partly: the TODO markers say what is left. +- Drawer → @lablup/ui-common/lab Drawer: `@astryxdesign/lab` is an optional peer of ui-common, pinned to the canary ui-common is built against. The codemod adds it to package.json when it moved a Drawer, and adds `@lablup/ui-common/lab/lab.css` to the stylesheet entry it rewrites; import lab.css yourself if your entry is elsewhere. +- Products' own `--token-*` reads were not rewritten: they belong to your token system. `legacy-tokens.css` keeps library reads resolving until 0.3. diff --git a/test/upgrade/fixtures/css-entry/input/package.json b/test/upgrade/fixtures/css-entry/input/package.json new file mode 100644 index 0000000..73cb754 --- /dev/null +++ b/test/upgrade/fixtures/css-entry/input/package.json @@ -0,0 +1,8 @@ +{ + "name": "css-entry-app", + "private": true, + "dependencies": { + "@lablup/ui-common": "0.1.0-alpha.23", + "@stylexjs/stylex": "^0.19.0" + } +} diff --git a/test/upgrade/fixtures/css-entry/input/src/index.css b/test/upgrade/fixtures/css-entry/input/src/index.css new file mode 100644 index 0000000..637e34b --- /dev/null +++ b/test/upgrade/fixtures/css-entry/input/src/index.css @@ -0,0 +1,10 @@ +@charset "utf-8"; +/* App entry stylesheet. */ +@import "@lablup/ui-common/styles/base.css"; +@import "@lablup/ui-common/styles/themes/orange-light.css"; +@import "@lablup/ui-common/styles/themes/orange-dark.css"; +@import "./app.css"; + +body { + margin: 0; +} diff --git a/test/upgrade/fixtures/css-entry/input/src/print.css b/test/upgrade/fixtures/css-entry/input/src/print.css new file mode 100644 index 0000000..c72bad2 --- /dev/null +++ b/test/upgrade/fixtures/css-entry/input/src/print.css @@ -0,0 +1 @@ +@import url("@lablup/ui-common/styles/base.css") print; diff --git a/test/upgrade/fixtures/library/expected/package.json b/test/upgrade/fixtures/library/expected/package.json new file mode 100644 index 0000000..6f132d8 --- /dev/null +++ b/test/upgrade/fixtures/library/expected/package.json @@ -0,0 +1,15 @@ +{ + "name": "@example/chat-ui", + "version": "0.3.0", + "type": "module", + "peerDependencies": { + "@lablup/ui-common": "^0.2.0-alpha.0", + "@stylexjs/stylex": "^0.19.0", + "react": "^19.0.0" + }, + "devDependencies": { + "@lablup/ui-common": "0.2.0-alpha.0", + "@stylexjs/stylex": "^0.19.0", + "react": "^19.2.0" + } +} diff --git a/test/upgrade/fixtures/library/expected/src/chat/ReasoningBlock.tsx b/test/upgrade/fixtures/library/expected/src/chat/ReasoningBlock.tsx new file mode 100644 index 0000000..18e5255 --- /dev/null +++ b/test/upgrade/fixtures/library/expected/src/chat/ReasoningBlock.tsx @@ -0,0 +1,20 @@ +// TODO(ui-common-upgrade): namespace import of @lablup/ui-common: in 0.2 Badge, Button, Select, Tabs and the other removed 0.1 components are Astryx's (or gone). Rewrite the UC.X uses by hand. +import * as UC from "@lablup/ui-common"; +import { Button } from "@lablup/ui-common/Button"; +import { SmoothHeight } from "@lablup/ui-common"; + +export function ReasoningBlock({ open, toggle }: { open: boolean; toggle: () => void }) { + return ( +
+ {/* TODO(ui-common-upgrade): Astryx Button has no "text" variant; mapped to "ghost". */} + {/* TODO(ui-common-upgrade): Button "inline": Astryx Button has no "inline" variant. Use variant="ghost" size="sm", or a Link. */} +
+ ); +} + +// TODO(ui-common-upgrade): Button is used as a value here; props passed to it this way are not migrated to Astryx Button. +export const Toggle = Button; diff --git a/test/upgrade/fixtures/library/expected/ui-common-upgrade-report.md b/test/upgrade/fixtures/library/expected/ui-common-upgrade-report.md new file mode 100644 index 0000000..1d03eda --- /dev/null +++ b/test/upgrade/fixtures/library/expected/ui-common-upgrade-report.md @@ -0,0 +1,74 @@ +# ui-common upgrade report + +`ui-common upgrade` 0.1.0-alpha.0 → 0.2.0-alpha.0 (installed @lablup/ui-common ). + +Scanned 1 file under `src`. + +## Summary + +| | Count | +|---|---:| +| Files changed | 1 | +| package.json changed | yes | +| TODO markers left in code | 4 | +| Manual-review findings | 0 | + +## Steps + +- 0.2.0-alpha.0: 0.1 → 0.2: ui-common on Astryx + +## Changed files + +- `src/chat/ReasoningBlock.tsx`: +7 −2, components + +## package.json + +- devDependencies["@lablup/ui-common"]: "0.1.0-alpha.0" → "0.2.0-alpha.0". +- peerDependencies["@lablup/ui-common"]: ">=0.1.0-alpha.0 <0.2.0" → "^0.2.0-alpha.0". +- peerDependencies["@lablup/ui-common"]: the range ">=0.1.0-alpha.0 <0.2.0" was replaced with "^0.2.0-alpha.0"; widen it again if this package must still accept 0.1. +- added @stylexjs/stylex ^0.19.0 to peerDependencies and devDependencies. + +## Manual review + +### TODO markers (4) + +Each is a `TODO(ui-common-upgrade)` comment in the code, above the call it is about. Resolve it, then delete the comment. + +- `src/chat/ReasoningBlock.tsx:1` namespace import of @lablup/ui-common: in 0.2 Badge, Button, Select, Tabs and the other removed 0.1 components are Astryx's (or gone). Rewrite the UC.X uses by hand. +- `src/chat/ReasoningBlock.tsx:9` Astryx Button has no "text" variant; mapped to "ghost". +- `src/chat/ReasoningBlock.tsx:10` Button "inline": Astryx Button has no "inline" variant. Use variant="ghost" size="sm", or a Link. +- `src/chat/ReasoningBlock.tsx:19` Button is used as a value here; props passed to it this way are not migrated to Astryx Button. + +### CSS selectors on 0.1 class names (0) + +None. + +### DOM hooks on 0.1 class names (0) + +None. + +### Tests querying 0.1 class names (0) + +None. + +### Module mocks of @lablup/ui-common (0) + +None. + +### Custom properties that collide with Astryx tokens (0) + +None. + +### 0.1 stylesheet paths left in place (0) + +None. + +## Notes + +- Badge: the codemod keeps Badge. Astryx reserves Badge for counts and loud status; a settled value (a tag, a category, a state label) reads better as Token (`@lablup/ui-common/Token`, `label` + `color`). Decide per call site. +- Button: children became `label` (the accessible name, required). `title` became `tooltip`. Sizes collapse onto sm/md/lg (xsmall → sm). +- Select → Selector: `onChange` receives a string value. A 0.1 Select typed over a non-string value needs its own mapping. +- StatusTag → StatusDot: a dot with an accessible label, no visible text. +- Tabs → TabList and DataTable → Table are reshaped only partly: the TODO markers say what is left. +- Drawer → @lablup/ui-common/lab Drawer: `@astryxdesign/lab` is an optional peer of ui-common, pinned to the canary ui-common is built against. The codemod adds it to package.json when it moved a Drawer, and adds `@lablup/ui-common/lab/lab.css` to the stylesheet entry it rewrites; import lab.css yourself if your entry is elsewhere. +- Products' own `--token-*` reads were not rewritten: they belong to your token system. `legacy-tokens.css` keeps library reads resolving until 0.3. diff --git a/test/upgrade/fixtures/library/input/package.json b/test/upgrade/fixtures/library/input/package.json new file mode 100644 index 0000000..2589d74 --- /dev/null +++ b/test/upgrade/fixtures/library/input/package.json @@ -0,0 +1,13 @@ +{ + "name": "@example/chat-ui", + "version": "0.3.0", + "type": "module", + "peerDependencies": { + "@lablup/ui-common": ">=0.1.0-alpha.0 <0.2.0", + "react": "^19.0.0" + }, + "devDependencies": { + "@lablup/ui-common": "0.1.0-alpha.0", + "react": "^19.2.0" + } +} diff --git a/test/upgrade/fixtures/library/input/src/chat/ReasoningBlock.tsx b/test/upgrade/fixtures/library/input/src/chat/ReasoningBlock.tsx new file mode 100644 index 0000000..28e00ad --- /dev/null +++ b/test/upgrade/fixtures/library/input/src/chat/ReasoningBlock.tsx @@ -0,0 +1,15 @@ +import * as UC from "@lablup/ui-common"; +import { Button, SmoothHeight } from "@lablup/ui-common"; + +export function ReasoningBlock({ open, toggle }: { open: boolean; toggle: () => void }) { + return ( +
+
+ ); +} + +export const Toggle = Button; diff --git a/test/upgrade/fixtures/root-barrel/expected/package.json b/test/upgrade/fixtures/root-barrel/expected/package.json new file mode 100644 index 0000000..b672d4e --- /dev/null +++ b/test/upgrade/fixtures/root-barrel/expected/package.json @@ -0,0 +1,11 @@ +{ + "name": "root-barrel-app", + "private": true, + "type": "module", + "dependencies": { + "@lablup/ui-common": "0.2.0-alpha.0", + "@stylexjs/stylex": "^0.19.0", + "react": "^19.2.0", + "react-dom": "^19.2.0" + } +} diff --git a/test/upgrade/fixtures/root-barrel/expected/src/chat/InputPopup.tsx b/test/upgrade/fixtures/root-barrel/expected/src/chat/InputPopup.tsx new file mode 100644 index 0000000..3440e36 --- /dev/null +++ b/test/upgrade/fixtures/root-barrel/expected/src/chat/InputPopup.tsx @@ -0,0 +1,14 @@ +import { useEffect } from "react"; + +export function useOutsideClose(onClose: () => void) { + useEffect(() => { + const handler = (event: MouseEvent) => { + const target = event.target as HTMLElement; + if (target.closest(".select__dropdown--portal")) return; + onClose(); + }; + document.addEventListener("mousedown", handler); + document.documentElement.style.setProperty("--color-error", "#c82333"); + return () => document.removeEventListener("mousedown", handler); + }, [onClose]); +} diff --git a/test/upgrade/fixtures/root-barrel/expected/src/pages/ModelsPage.test.tsx b/test/upgrade/fixtures/root-barrel/expected/src/pages/ModelsPage.test.tsx new file mode 100644 index 0000000..71671df --- /dev/null +++ b/test/upgrade/fixtures/root-barrel/expected/src/pages/ModelsPage.test.tsx @@ -0,0 +1,14 @@ +import { render } from "@testing-library/react"; +import { vi } from "vitest"; +import { ModelsPage } from "./ModelsPage"; + +vi.mock("@lablup/ui-common", async (importOriginal) => ({ + ...(await importOriginal()), + StatCard: () => null, +})); + +it("marks the primary action", () => { + const { container } = render( {}} />); + expect(container.querySelector(".button--primary")).not.toBeNull(); + expect(container.firstChild).toHaveClass("page-layout"); +}); diff --git a/test/upgrade/fixtures/root-barrel/expected/src/pages/ModelsPage.tsx b/test/upgrade/fixtures/root-barrel/expected/src/pages/ModelsPage.tsx new file mode 100644 index 0000000..e590f00 --- /dev/null +++ b/test/upgrade/fixtures/root-barrel/expected/src/pages/ModelsPage.tsx @@ -0,0 +1,88 @@ +import { useState } from "react"; +import { Badge } from "@lablup/ui-common/Badge"; +import { Card } from "@lablup/ui-common/Card"; +import { Button } from "@lablup/ui-common/Button"; +import { ProgressBar } from "@lablup/ui-common/ProgressBar"; +import { StatusDot } from "@lablup/ui-common/StatusDot"; +import { Tooltip } from "@lablup/ui-common/Tooltip"; +import { PageLayout, StatCard } from "@lablup/ui-common"; +import { ClickableCard } from "@lablup/ui-common/ClickableCard"; +// TODO(ui-common-upgrade): type StatusKind was removed with StatusTag in 0.2 and has no Astryx counterpart. +import type { ButtonProps } from "@lablup/ui-common/Button"; +import { usePrefersReducedMotion } from "@lablup/ui-common"; +import { useTranslation } from "../i18n"; + +interface Model { + id: string; + name: string; + state: StatusKind; + progress: number; +} + +export function ModelsPage({ models, onOpen }: { models: Model[]; onOpen: (id: string) => void }) { + const { t } = useTranslation(); + const reduced = usePrefersReducedMotion(); + const [busy, setBusy] = useState(false); + const extra: Partial = { size: "small" }; + + return ( + + + + {models.map((model) => ( + // TODO(ui-common-upgrade): BaseCard "state": Card has no state; show loading/disabled/warning in its content (Skeleton, Banner). + onOpen(model.id)} + label={model.name} + state="loading"> + + {/* TODO(ui-common-upgrade): Astryx Badge has no "primary" variant; mapped to the "orange" colour variant (tinted, not solid). */} + + {/* TODO(ui-common-upgrade): StatusDot shows no text: `label` is its accessible name only. Put a beside it if the label must stay visible. */} + + {/* TODO(ui-common-upgrade): StatusDot shows no text: `label` is its accessible name only. Put a beside it if the label must stay visible. */} + + {/* TODO(ui-common-upgrade): ProgressBar "size": Astryx ProgressBar has one size. */} + + + copy + + + ))} + +

Summary

+
+
+ ); +} diff --git a/test/upgrade/fixtures/root-barrel/expected/src/themes/violet.css b/test/upgrade/fixtures/root-barrel/expected/src/themes/violet.css new file mode 100644 index 0000000..0cc2926 --- /dev/null +++ b/test/upgrade/fixtures/root-barrel/expected/src/themes/violet.css @@ -0,0 +1,18 @@ +[data-theme="violet-light"] { + --token-colorPrimary: #6b4eff; + --color-border: #e0dcf5; + --color-text-primary: #1b1535; +} + +[data-theme="violet-light"] .button--primary:hover { + box-shadow: 0 0 0 2px var(--token-colorPrimary); +} + +.drawer__content, +.my-panel { + padding: var(--token-paddingMD); +} + +.sidebar { + color: var(--color-text-primary); +} diff --git a/test/upgrade/fixtures/root-barrel/expected/ui-common-upgrade-report.md b/test/upgrade/fixtures/root-barrel/expected/ui-common-upgrade-report.md new file mode 100644 index 0000000..86b5883 --- /dev/null +++ b/test/upgrade/fixtures/root-barrel/expected/ui-common-upgrade-report.md @@ -0,0 +1,103 @@ +# ui-common upgrade report + +`ui-common upgrade` 0.1.0-alpha.7 → 0.2.0-alpha.0 (installed @lablup/ui-common ). + +Scanned 4 files under `src`. + +## Summary + +| | Count | +|---|---:| +| Files changed | 1 | +| package.json changed | yes | +| TODO markers left in code | 10 | +| Manual-review findings | 9 | + +## Steps + +- 0.2.0-alpha.0: 0.1 → 0.2: ui-common on Astryx + +## Changed files + +- `src/pages/ModelsPage.tsx`: +59 −23, components + +## package.json + +- dependencies["@lablup/ui-common"]: "0.1.0-alpha.7" → "0.2.0-alpha.0". +- added @stylexjs/stylex ^0.19.0 to dependencies. + +## Manual review + +### TODO markers (10) + +Each is a `TODO(ui-common-upgrade)` comment in the code, above the call it is about. Resolve it, then delete the comment. + +- `src/pages/ModelsPage.tsx:10` type StatusKind was removed with StatusTag in 0.2 and has no Astryx counterpart. +- `src/pages/ModelsPage.tsx:44` props spread into + + + {models.map((model) => ( + onOpen(model.id)} ariaLabel={model.name} state="loading"> + + {model.name} + + New + + + + + copy + + + ))} + +

Summary

+
+ + ); +} diff --git a/test/upgrade/fixtures/root-barrel/input/src/themes/violet.css b/test/upgrade/fixtures/root-barrel/input/src/themes/violet.css new file mode 100644 index 0000000..0cc2926 --- /dev/null +++ b/test/upgrade/fixtures/root-barrel/input/src/themes/violet.css @@ -0,0 +1,18 @@ +[data-theme="violet-light"] { + --token-colorPrimary: #6b4eff; + --color-border: #e0dcf5; + --color-text-primary: #1b1535; +} + +[data-theme="violet-light"] .button--primary:hover { + box-shadow: 0 0 0 2px var(--token-colorPrimary); +} + +.drawer__content, +.my-panel { + padding: var(--token-paddingMD); +} + +.sidebar { + color: var(--color-text-primary); +} diff --git a/test/upgrade/fixtures/subpath-barrel/expected/package.json b/test/upgrade/fixtures/subpath-barrel/expected/package.json new file mode 100644 index 0000000..73f1af1 --- /dev/null +++ b/test/upgrade/fixtures/subpath-barrel/expected/package.json @@ -0,0 +1,14 @@ +{ + "name": "subpath-barrel-app", + "private": true, + "type": "module", + "dependencies": { + "@astryxdesign/lab": "0.6.2-canary.c9fb1ad", + "@lablup/ui-common": "^0.2.0-alpha.0", + "@stylexjs/stylex": "^0.19.0", + "react": "^19.2.0" + }, + "devDependencies": { + "vitest": "^4.0.0" + } +} diff --git a/test/upgrade/fixtures/subpath-barrel/expected/src/components/common/DataTableWrapper.tsx b/test/upgrade/fixtures/subpath-barrel/expected/src/components/common/DataTableWrapper.tsx new file mode 100644 index 0000000..455e4e4 --- /dev/null +++ b/test/upgrade/fixtures/subpath-barrel/expected/src/components/common/DataTableWrapper.tsx @@ -0,0 +1,78 @@ +import { Table } from "@lablup/ui-common/Table"; +import { Button } from "@lablup/ui-common/Button"; +import type { TableColumn } from "@lablup/ui-common/Table"; +import { EmptyState } from "@lablup/ui-common/EmptyState"; +import { TabList } from "@lablup/ui-common/TabList"; +import { Drawer } from "@lablup/ui-common/lab"; +import { Selector } from "@lablup/ui-common/Selector"; + +export interface Row { + id: string; + name: string; +} + +export function DataTableWrapper({ + rows, + columns, + open, + setOpen, + tab, + setTab, + region, + setRegion, +}: { + rows: Row[]; + columns: TableColumn[]; + open: boolean; + setOpen: (open: boolean) => void; + tab: string; + setTab: (tab: string) => void; + region: string; + setRegion: (region: string) => void; +}) { + return ( + <> + {/* TODO(ui-common-upgrade): TabList renders the tab strip only: turn `tabs` into children, render the active panel yourself (was `content` / `renderPanel`), and drop `groups`, `variant`, `overflowMode`, `fillContainer` (see `ui-common component TabList`; `segmented` is SegmentedControl). */} + + + {/* TODO(ui-common-upgrade): Table columns are {key, header, width, align, renderCell}: rename id→key and render→renderCell, and widths use pixel()/proportional() from @lablup/ui-common/Table. */} + {/* TODO(ui-common-upgrade): Table has no emptyState, onRowClick: rebuild them with Table plugins (useTableSortable, useTableColumnResize, useTableColumnSettings) or around the table. */} + row.id} + aria-label="Sessions" + emptyState={ + setOpen(true)} />} + /> + } + onRowClick={(row) => setOpen(Boolean(row))} + /> + {/* TODO(ui-common-upgrade): lab Drawer renders no title, subtitle or footer: put a Heading (and the footer) inside its children, then remove `subtitle` / `footer`. */} + {/* TODO(ui-common-upgrade): Drawer "preventDismiss": lab Drawer always dismisses on Escape and scrim click; guard in onOpenChange instead. */} + { + if (!isOpen) { + setOpen(false); + } + }} label="Session" width={900} preventDismiss> +

Details

+
+ + ); +} diff --git a/test/upgrade/fixtures/subpath-barrel/expected/src/components/common/Select.test.tsx b/test/upgrade/fixtures/subpath-barrel/expected/src/components/common/Select.test.tsx new file mode 100644 index 0000000..2442573 --- /dev/null +++ b/test/upgrade/fixtures/subpath-barrel/expected/src/components/common/Select.test.tsx @@ -0,0 +1,8 @@ +import { render } from "@testing-library/react"; +import { Select } from "./index"; + +it("opens", () => { + const { container } = render( + row.id} + ariaLabel="Sessions" + emptyState={ + setOpen(true) }} + /> + } + onRowClick={(row) => setOpen(Boolean(row))} + /> + setOpen(false)} title="Session" width="wide" preventDismiss> +

Details

+
+ + ); +} diff --git a/test/upgrade/fixtures/subpath-barrel/input/src/components/common/Select.test.tsx b/test/upgrade/fixtures/subpath-barrel/input/src/components/common/Select.test.tsx new file mode 100644 index 0000000..2442573 --- /dev/null +++ b/test/upgrade/fixtures/subpath-barrel/input/src/components/common/Select.test.tsx @@ -0,0 +1,8 @@ +import { render } from "@testing-library/react"; +import { Select } from "./index"; + +it("opens", () => { + const { container } = render(
setOpen(Boolean(row))} /> - {/* TODO(ui-common-upgrade): lab Drawer renders no title, subtitle or footer: put a Heading (and the footer) inside its children, then remove `subtitle` / `footer`. */} - {/* TODO(ui-common-upgrade): Drawer "preventDismiss": lab Drawer always dismisses on Escape and scrim click; guard in onOpenChange instead. */} + {/* TODO(ui-common-upgrade): lab Drawer renders no header: render the title, subtitle and footer inside children. */} + {/* TODO(ui-common-upgrade): preventDismiss and onDismissAttempt: decline the close in onOpenChange. */} { if (!isOpen) { setOpen(false); diff --git a/test/upgrade/fixtures/subpath-barrel/expected/src/styles/families.css b/test/upgrade/fixtures/subpath-barrel/expected/src/styles/families.css index fcc2ea3..134e1d9 100644 --- a/test/upgrade/fixtures/subpath-barrel/expected/src/styles/families.css +++ b/test/upgrade/fixtures/subpath-barrel/expected/src/styles/families.css @@ -5,3 +5,8 @@ :root { --color-text-secondary: #6b7280; } + +.page-header__title, +.error-state__action-btn--primary { + font-weight: 600; +} diff --git a/test/upgrade/fixtures/subpath-barrel/expected/ui-common-upgrade-report.md b/test/upgrade/fixtures/subpath-barrel/expected/ui-common-upgrade-report.md index 5f1970e..f11880c 100644 --- a/test/upgrade/fixtures/subpath-barrel/expected/ui-common-upgrade-report.md +++ b/test/upgrade/fixtures/subpath-barrel/expected/ui-common-upgrade-report.md @@ -10,8 +10,8 @@ Scanned 6 files under `src`. |---|---:| | Files changed | 4 | | package.json changed | yes | -| TODO markers left in code | 9 | -| Manual-review findings | 4 | +| TODO markers left in code | 11 | +| Manual-review findings | 5 | ## Steps @@ -19,7 +19,7 @@ Scanned 6 files under `src`. ## Changed files -- `src/components/common/DataTableWrapper.tsx`: +33 −23, components +- `src/components/common/DataTableWrapper.tsx`: +35 −23, components - `src/components/common/index.ts`: +13 −6, components - `src/styles/theme.ts`: +1 −2, script-stylesheet-imports - `src/styles/ui-common-entry.css` (new): +15 −0, stylesheet-entry @@ -32,27 +32,30 @@ Scanned 6 files under `src`. ## Manual review -### TODO markers (9) +### TODO markers (11) Each is a `TODO(ui-common-upgrade)` comment in the code, above the call it is about. Resolve it, then delete the comment. -- `src/components/common/DataTableWrapper.tsx:35` TabList renders the tab strip only: turn `tabs` into children, render the active panel yourself (was `content` / `renderPanel`), and drop `groups`, `variant`, `overflowMode`, `fillContainer` (see `ui-common component TabList`; `segmented` is SegmentedControl). -- `src/components/common/DataTableWrapper.tsx:51` Table columns are {key, header, width, align, renderCell}: rename id→key and render→renderCell, and widths use pixel()/proportional() from @lablup/ui-common/Table. -- `src/components/common/DataTableWrapper.tsx:52` Table has no emptyState, onRowClick: rebuild them with Table plugins (useTableSortable, useTableColumnResize, useTableColumnSettings) or around the table. -- `src/components/common/DataTableWrapper.tsx:67` lab Drawer renders no title, subtitle or footer: put a Heading (and the footer) inside its children, then remove `subtitle` / `footer`. -- `src/components/common/DataTableWrapper.tsx:68` Drawer "preventDismiss": lab Drawer always dismisses on Escape and scrim click; guard in onOpenChange instead. +- `src/components/common/DataTableWrapper.tsx:35` TabList renders the strip only. `tabs` (id, label, content) becomes children, and the active panel is rendered by the caller. +- `src/components/common/DataTableWrapper.tsx:51` Table idKey: idKey takes (item) or a property name; the index argument is gone. +- `src/components/common/DataTableWrapper.tsx:52` Table columns: rename id→key, render→renderCell, initialWidth→width; renderCell takes the row item, and width is pixel()/proportional() from @lablup/ui-common/Table. +- `src/components/common/DataTableWrapper.tsx:53` loading, loadingState and emptyState: render them around the Table. +- `src/components/common/DataTableWrapper.tsx:54` onRowClick, isRowClickable and rowClassName: use the row-interaction plugin or children mode. +- `src/components/common/DataTableWrapper.tsx:69` lab Drawer renders no header: render the title, subtitle and footer inside children. +- `src/components/common/DataTableWrapper.tsx:70` preventDismiss and onDismissAttempt: decline the close in onOpenChange. - `src/components/common/index.ts:2` re-exported under the 0.1 name, but the component is Astryx's now; modules importing it from here still pass 0.1 props and need the same migration. - `src/components/common/index.ts:4` re-exported under the 0.1 name, but the component is Astryx's now; modules importing it from here still pass 0.1 props and need the same migration. - `src/components/common/index.ts:8` re-exported under the 0.1 name, but the component is Astryx's now; modules importing it from here still pass 0.1 props and need the same migration. - `src/components/common/index.ts:13` re-exported under the 0.1 name, but the component is Astryx's now; modules importing it from here still pass 0.1 props and need the same migration. -### CSS selectors on 0.1 class names (1) +### CSS selectors on 0.1 class names (2) -Astryx renders none of the 0.1 class names. Restyle through the component's props, the theme, or your `components` layer. Generic names (`.button`, `.select`) may be your own classes: skip those. +A removed component's classes are gone: Astryx renders its own. Restyle through the component's props, the theme, or your `components` layer. A kept component's classes were renamed to `uic-` names (shown as →), but its markup was rebuilt on Astryx, so check the selector still means what it did. Generic names (`.button`, `.select`) may be your own classes: skip those. | Where | What | Detail | |---|---|---| -| `src/styles/families.css:1` | `[data-theme="orange-light"] .tabs__tab--active` | .tabs__tab--active (Tabs) | +| `src/styles/families.css:1` | `[data-theme="orange-light"] .tabs__tab--active` | .tabs__tab--active (Tabs): gone | +| `src/styles/families.css:9` | `.page-header__title, .error-state__action-btn--primary` | .page-header__title → .uic-page-header__title (PageHeader), .error-state__action-btn--primary → .uic-error-state__action--primary | ### DOM hooks on 0.1 class names (1) @@ -60,7 +63,7 @@ Scripts that find 0.1 markup by class stop matching. Use a ref, a data-testid, o | Where | What | Detail | |---|---|---| -| `src/reports/ReportPreviewFrame.tsx:2` | `return node.closest(".drawer") !== null;` | .drawer (Drawer) | +| `src/reports/ReportPreviewFrame.tsx:2` | `return node.closest(".drawer") !== null;` | .drawer (Drawer): gone | ### Tests querying 0.1 class names (1) @@ -68,7 +71,7 @@ Query by role, label or data-testid instead. | Where | What | Detail | |---|---|---| -| `src/components/common/Select.test.tsx:6` | `const trigger = container.querySelector(".select__trigger");` | .select__trigger (Select) | +| `src/components/common/Select.test.tsx:6` | `const trigger = container.querySelector(".select__trigger");` | .select__trigger (Select): gone | ### Module mocks of @lablup/ui-common (0) @@ -88,11 +91,11 @@ None. ## Notes -- Badge: the codemod keeps Badge. Astryx reserves Badge for counts and loud status; a settled value (a tag, a category, a state label) reads better as Token (`@lablup/ui-common/Token`, `label` + `color`). Decide per call site. -- Button: children became `label` (the accessible name, required). `title` became `tooltip`. Sizes collapse onto sm/md/lg (xsmall → sm). -- Select → Selector: `onChange` receives a string value. A 0.1 Select typed over a non-string value needs its own mapping. -- StatusTag → StatusDot: a dot with an accessible label, no visible text. -- Tabs → TabList and DataTable → Table are reshaped only partly: the TODO markers say what is left. -- Drawer → @lablup/ui-common/lab Drawer: `@astryxdesign/lab` is an optional peer of ui-common, pinned to the canary ui-common is built against. The codemod adds it to package.json when it moved a Drawer, and adds `@lablup/ui-common/lab/lab.css` to the stylesheet entry it rewrites; import lab.css yourself if your entry is elsewhere. -- Products' own `--token-*` reads were not rewritten: they belong to your token system. `legacy-tokens.css` keeps library reads resolving until 0.3. +- DataTable → Table (@lablup/ui-common/Table). Sorting, column resizing, column visibility and persisted column state are Table plugins (`plugins`), not props. loading, loadingState and emptyState: render them around the Table. onRowClick, isRowClickable and rowClassName: use the row-interaction plugin or children mode. The .data-table classes are gone. +- Drawer → Drawer (@lablup/ui-common/lab). lab Drawer renders no header: render the title, subtitle and footer inside children. closeLabel, ariaLabelledBy and ariaDescribedBy have no counterpart. preventDismiss and onDismissAttempt: decline the close in onOpenChange. The .drawer classes are gone. +- EmptyState → EmptyState (@lablup/ui-common/EmptyState). primaryAction and secondaryAction become `actions`, a node: + ) : ( + + {iconNode} + + )} + + ); +} + +IconWithTooltip.displayName = "IconWithTooltip"; diff --git a/src/components/IconWithTooltip/accessibleLabel.ts b/src/components/IconWithTooltip/accessibleLabel.ts new file mode 100644 index 0000000..356bd03 --- /dev/null +++ b/src/components/IconWithTooltip/accessibleLabel.ts @@ -0,0 +1,38 @@ +/** + * Flattens a `ReactNode` into plain text for an accessible name: string and + * number leaves, through fragments, arrays and elements' children, joined by + * a space. A node with no text (an icon alone) yields `""`. Text carried in + * props rather than children is invisible to the walk. + * + * Internal: not exported from the package. + */ +import { isValidElement, type ReactNode } from "react"; + +const MAX_DEPTH = 6; + +function walk(node: ReactNode, depth: number, out: string[]): void { + if (node === null || node === undefined || typeof node === "boolean") return; + if (typeof node === "string") { + if (node.trim() !== "") out.push(node); + return; + } + if (typeof node === "number") { + out.push(String(node)); + return; + } + if (depth >= MAX_DEPTH) return; + if (Array.isArray(node)) { + for (const child of node) walk(child as ReactNode, depth + 1, out); + return; + } + if (isValidElement(node)) { + const props = node.props as { children?: ReactNode } | undefined; + walk(props?.children, depth + 1, out); + } +} + +export function nodeToAccessibleLabel(node: ReactNode): string { + const out: string[] = []; + walk(node, 0, out); + return out.join(" ").replace(/\s+/g, " ").trim(); +} diff --git a/src/components/IconWithTooltip/index.ts b/src/components/IconWithTooltip/index.ts new file mode 100644 index 0000000..565f21b --- /dev/null +++ b/src/components/IconWithTooltip/index.ts @@ -0,0 +1,2 @@ +export { IconWithTooltip } from "./IconWithTooltip"; +export type { IconWithTooltipProps } from "./IconWithTooltip"; diff --git a/src/components/ImageWithFallback/ImageWithFallback.test.tsx b/src/components/ImageWithFallback/ImageWithFallback.test.tsx new file mode 100644 index 0000000..d97a549 --- /dev/null +++ b/src/components/ImageWithFallback/ImageWithFallback.test.tsx @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; + +import { ImageWithFallback } from "./ImageWithFallback"; + +const fallback = icon; + +describe("ImageWithFallback", () => { + it("renders the image with its alt text and the other img props", () => { + render( + , + ); + const img = screen.getByRole("img", { name: "Vendor" }); + expect(img).toHaveAttribute("src", "/a.png"); + expect(img).toHaveAttribute("width", "16"); + expect(screen.queryByTestId("fallback")).not.toBeInTheDocument(); + }); + + it("swaps in the fallback once the image fails to load", () => { + render(); + fireEvent.error(screen.getByRole("img")); + expect(screen.queryByRole("img")).not.toBeInTheDocument(); + expect(screen.getByTestId("fallback")).toBeInTheDocument(); + }); + + it("tries again when the src changes", () => { + const { rerender } = render( + , + ); + fireEvent.error(screen.getByRole("img")); + rerender(); + expect(screen.getByRole("img")).toHaveAttribute("src", "/b.png"); + expect(screen.queryByTestId("fallback")).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/ImageWithFallback/ImageWithFallback.tsx b/src/components/ImageWithFallback/ImageWithFallback.tsx new file mode 100644 index 0000000..45c8185 --- /dev/null +++ b/src/components/ImageWithFallback/ImageWithFallback.tsx @@ -0,0 +1,39 @@ +/** + * ImageWithFallback + * + * An `` that renders `fallbackIcon` in its place once the image fails to + * load. The failure is remembered per `src`, so a new `src` gets a fresh try. + * Astryx's `Avatar` and `Thumbnail` fall back to another image; this falls + * back to any node, typically an icon. + * + * @example + * } width={16} /> + */ +import { useState, type ImgHTMLAttributes, type ReactNode } from "react"; + +export interface ImageWithFallbackProps extends Omit< + ImgHTMLAttributes, + "onError" +> { + src: string; + /** Rendered instead of the image once it fails to load. */ + fallbackIcon: ReactNode; + alt: string; +} + +export function ImageWithFallback({ + src, + fallbackIcon, + alt, + ...props +}: ImageWithFallbackProps) { + const [errorSrc, setErrorSrc] = useState(null); + + if (errorSrc === src) { + return <>{fallbackIcon}; + } + + return {alt} setErrorSrc(src)} />; +} + +ImageWithFallback.displayName = "ImageWithFallback"; diff --git a/src/components/ImageWithFallback/index.ts b/src/components/ImageWithFallback/index.ts new file mode 100644 index 0000000..2fa507b --- /dev/null +++ b/src/components/ImageWithFallback/index.ts @@ -0,0 +1,2 @@ +export { ImageWithFallback } from "./ImageWithFallback"; +export type { ImageWithFallbackProps } from "./ImageWithFallback"; diff --git a/src/components/NotificationStack/NotificationStack.css b/src/components/NotificationStack/NotificationStack.css new file mode 100644 index 0000000..e4e3ecb --- /dev/null +++ b/src/components/NotificationStack/NotificationStack.css @@ -0,0 +1,91 @@ +/* + * NotificationStack: a fixed column of notices in the bottom-end corner, + * newest nearest the corner, with enter and exit slides. + */ +@layer ui-common { + .uic-notification-stack { + position: fixed; + /* The inset is padding, not `inset`: `overflow` clips to the padding box, + and both the slide and the notices' shadow need room inside it. + `content-box` keeps `width` and `max-height` the visible figures. */ + inset-block-end: 0; + inset-inline-end: 0; + padding: var(--spacing-6); + box-sizing: content-box; + display: flex; + flex-direction: column; + gap: var(--spacing-3); + /* A notice's readable width. Not a spacing step, and no token names it. */ + width: 384px; + max-width: calc(100vw - var(--spacing-6) * 2); + /* Stops below the reserved top inset (an app header), so what is there + stays reachable however many notices are open. */ + max-height: calc( + 100dvh - var(--uic-notification-stack-inset-top, 0px) - var(--spacing-6) * 2 + ); + overflow: hidden auto; + overscroll-behavior: contain; + /* One above the top of Modal's default z-index band (1100..10999), so a + notice stays visible and clickable over an open modal. */ + z-index: var(--uic-notification-stack-z, 11000); + pointer-events: none; + } + + .uic-notification-stack > * { + pointer-events: auto; + } + + @keyframes uic-notification-stack-enter { + from { + opacity: 0; + transform: translateX(var(--spacing-6)); + } + to { + opacity: 1; + transform: translateX(0); + } + } + + @keyframes uic-notification-stack-exit { + from { + opacity: 1; + transform: translateX(0); + max-height: 40vh; + } + to { + opacity: 0; + transform: translateX(var(--spacing-6)); + max-height: 0; + } + } + + .uic-notification-stack__item { + animation-name: uic-notification-stack-enter; + animation-duration: var(--duration-fast); + animation-timing-function: var(--ease-standard); + animation-fill-mode: both; + } + + .uic-notification-stack__item[data-exiting="true"] { + animation-name: uic-notification-stack-exit; + overflow: hidden; + } + + /* A long text scrolls inside the notice instead of pushing its header, and + the dismiss button in it, off screen. Not on the item: the exit animation + drives the item's own `max-height`. */ + .uic-notification-stack__body { + max-height: var(--uic-notification-body-max-height, 30vh); + overflow-y: auto; + overscroll-behavior: contain; + } + + @media (prefers-reduced-motion: reduce) { + .uic-notification-stack__item, + .uic-notification-stack__item[data-exiting="true"] { + animation-name: none; + opacity: 1; + transform: none; + } + } +} diff --git a/src/components/NotificationStack/NotificationStack.messages.ts b/src/components/NotificationStack/NotificationStack.messages.ts new file mode 100644 index 0000000..e02e4ad --- /dev/null +++ b/src/components/NotificationStack/NotificationStack.messages.ts @@ -0,0 +1,17 @@ +import { defineMessages } from "../../i18n/catalog"; + +export const notificationStackMessages = defineMessages({ + "uic.NotificationStack.cancel": { + defaultMessage: "Cancel", + description: "Default label of the button that cancels a notice's background task", + }, + "uic.NotificationStack.retry": { + defaultMessage: "Retry", + description: "Default label of the button that retries a notice's failed task", + }, + "uic.NotificationStack.progress": { + defaultMessage: "Task progress", + description: + "Accessible name of a notice's progress bar when the notice title is not plain text", + }, +}); diff --git a/src/components/NotificationStack/NotificationStack.test.tsx b/src/components/NotificationStack/NotificationStack.test.tsx new file mode 100644 index 0000000..40c9d15 --- /dev/null +++ b/src/components/NotificationStack/NotificationStack.test.tsx @@ -0,0 +1,220 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { act, fireEvent, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { InternationalizationProvider } from "@astryxdesign/core/i18n"; + +import { uiCommonMessages } from "../../i18n/messages"; +import { NotificationStack, type NotificationStackItem } from "./NotificationStack"; + +const items = (count: number): Array => + Array.from({ length: count }, (_, i) => ({ key: `n${i}`, title: `Notice ${i}` })); + +const renderedKeys = () => + Array.from( + screen.getByTestId("stack").querySelectorAll("[data-notification-key]"), + ).map((el) => el.getAttribute("data-notification-key")); + +describe("NotificationStack", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("renders nothing for an empty list", () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it("renders every notice, oldest first, when maxVisible is unset", () => { + render(); + expect(renderedKeys()).toEqual(["n0", "n1", "n2", "n3", "n4"]); + }); + + it("keeps only the newest notices when maxVisible is set", () => { + render( + , + ); + expect(renderedKeys()).toEqual(["n2", "n3", "n4"]); + }); + + it("puts className on the stack and hooks on each notice", () => { + render( + , + ); + const stack = screen.getByTestId("stack"); + expect(stack).toHaveClass("uic-notification-stack", "host-hook"); + const notice = stack.querySelector("[data-notification-key='a']"); + expect(notice).toHaveAttribute("data-status", "success"); + expect(notice).toHaveAttribute("data-paused", "false"); + expect(screen.getByTestId("notification-title")).toHaveTextContent("Saved"); + }); + + it("wraps a description in the scrollable body, never the notice itself", () => { + render( + , + ); + const description = screen.getByTestId("notification-description"); + expect(description.closest(".uic-notification-stack__body")).not.toBeNull(); + expect(document.querySelector(".uic-notification-stack__item")).not.toHaveClass( + "uic-notification-stack__body", + ); + }); + + it("renders no scrollable body when there is nothing to scroll", () => { + render(); + expect(document.querySelector(".uic-notification-stack__body")).toBeNull(); + }); + + it("does not surface a hidden notice that is closed", () => { + const five = items(5); + const { rerender } = render( + , + ); + rerender( + n.key !== "n0")} + maxVisible={3} + />, + ); + expect(renderedKeys()).toEqual(["n2", "n3", "n4"]); + }); + + it("keeps a removed notice mounted while it slides out", () => { + vi.useFakeTimers(); + const { rerender } = render( + , + ); + rerender(); + const leaving = document.querySelector("[data-notification-key='n1']"); + expect(leaving).toHaveAttribute("data-exiting", "true"); + act(() => { + vi.advanceTimersByTime(250); + }); + expect(renderedKeys()).toEqual(["n0"]); + }); + + it("closes through the dismiss button", async () => { + const onClose = vi.fn(); + render( + , + ); + await userEvent.click(screen.getByRole("button", { name: /dismiss/i })); + expect(onClose).toHaveBeenCalledWith("e"); + }); + + it("closes itself after its duration, pausing while hovered", () => { + vi.useFakeTimers(); + const onClose = vi.fn(); + render( + , + ); + const notice = document.querySelector("[data-notification-key='t']") as HTMLElement; + act(() => { + vi.advanceTimersByTime(1000); + }); + fireEvent.mouseEnter(notice); + expect(notice).toHaveAttribute("data-paused", "true"); + act(() => { + vi.advanceTimersByTime(5000); + }); + expect(onClose).not.toHaveBeenCalled(); + fireEvent.mouseLeave(notice); + act(() => { + vi.advanceTimersByTime(1100); + }); + expect(onClose).toHaveBeenCalledWith("t"); + }); + + it("keeps a notice with duration 0 open", () => { + vi.useFakeTimers(); + const onClose = vi.fn(); + render( + , + ); + act(() => { + vi.advanceTimersByTime(60_000); + }); + expect(onClose).not.toHaveBeenCalled(); + }); + + it("renders task progress and the task actions", async () => { + const onCancel = vi.fn(); + const onRetry = vi.fn(); + const onAction = vi.fn(); + render( + , + ); + expect(screen.getByRole("progressbar", { name: "Uploading" })).toHaveAttribute( + "aria-valuenow", + "40", + ); + await userEvent.click(screen.getByRole("button", { name: "Cancel" })); + await userEvent.click(screen.getByRole("button", { name: "Retry" })); + await userEvent.click(screen.getByRole("button", { name: "Open folder" })); + expect(onCancel).toHaveBeenCalledOnce(); + expect(onRetry).toHaveBeenCalledOnce(); + expect(onAction).toHaveBeenCalledOnce(); + }); + + it("replaces the header with its own content", () => { + render( + Custom body }, + ]} + />, + ); + expect(screen.getByText("Custom body")).toBeInTheDocument(); + expect(screen.queryByTestId("notification-title")).not.toBeInTheDocument(); + }); + + it("takes its default labels from the shipped translations", () => { + render( + + Upload, + percent: 10, + onCancel: () => undefined, + onRetry: () => undefined, + }, + ]} + /> + , + ); + expect(screen.getByRole("button", { name: "취소" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "다시 시도" })).toBeInTheDocument(); + expect( + screen.getByRole("progressbar", { name: "작업 진행률" }), + ).toBeInTheDocument(); + }); +}); diff --git a/src/components/NotificationStack/NotificationStack.tsx b/src/components/NotificationStack/NotificationStack.tsx new file mode 100644 index 0000000..5d19bf8 --- /dev/null +++ b/src/components/NotificationStack/NotificationStack.tsx @@ -0,0 +1,358 @@ +/** + * NotificationStack + * + * Floating notices stacked in the bottom-end corner, newest nearest the + * corner. Each notice is an Astryx `Banner` that can carry what a toast + * cannot: background-task progress (determinate or not), Cancel / Retry and a + * navigation action, a collapsible detail, and a body of the caller's own. + * + * It is presentational. The caller owns the list: it adds notices, removes + * them in `onClose`, and updates a notice in place under the same `key`. + * + * - `duration` closes a notice after that many seconds; the countdown pauses + * while the notice is hovered or holds focus. `null` or `0` keeps it open. + * - An error notice opens its detail (`children`) up front; the reader's own + * toggle wins after that. + * - `maxVisible` caps how many render; the newest win, and the rest render as + * room frees up. + * - Removed notices slide out before they unmount, unless the reader asked + * for reduced motion. + * - A long description scrolls inside its notice, so the dismiss button stays + * on screen; the stack scrolls once it reaches the top inset. + * + * Layout hooks, set on the stack or an ancestor: + * `--uic-notification-stack-z` (stacking order, default 11000, one above + * `Modal`'s default band), `--uic-notification-stack-inset-top` (space kept + * free above the stack, such as an app header, default 0) and + * `--uic-notification-body-max-height` (one notice's scroll cap, default 30vh). + * + * e2e hooks: each notice carries `data-notification-key`, `data-status` and + * `data-paused`; its title and description carry `data-testid` + * `notification-title` and `notification-description`. + * + * @example + * remove(key)} + * /> + */ +import { + useEffect, + useEffectEvent, + useRef, + useState, + type Key, + type ReactNode, +} from "react"; +import { Banner, type BannerStatus } from "@astryxdesign/core/Banner"; +import { Button } from "@astryxdesign/core/Button"; +import { ProgressBar } from "@astryxdesign/core/ProgressBar"; +import { HStack, VStack } from "@astryxdesign/core/Stack"; +import { Text } from "@astryxdesign/core/Text"; + +import { useUicTranslator } from "../../i18n/useUicTranslator"; +import "./NotificationStack.css"; + +/** Matches the exit animation's budget in NotificationStack.css. */ +const EXIT_ANIMATION_MS = 200; + +export interface NotificationStackItem { + key: Key; + /** Headline. */ + title: ReactNode; + description?: ReactNode; + /** Drives the Banner's icon and colour. @default 'info' */ + status?: BannerStatus; + /** Background-task progress, 0-100. Omit for a notice with no task. */ + percent?: number; + /** A task that is running with no measurable progress yet. */ + isProgressIndeterminate?: boolean; + /** + * Accessible name of the progress bar. + * @default the title when it is a string, else the catalog's uic.NotificationStack.progress + */ + progressLabel?: string; + /** Label of the navigation action ("View folder"). Shown with `onAction`. */ + actionText?: string; + onAction?: () => void; + /** @default the catalog's uic.NotificationStack.retry ("Retry") */ + retryText?: string; + onRetry?: () => void; + /** @default the catalog's uic.NotificationStack.cancel ("Cancel") */ + cancelText?: string; + onCancel?: () => void; + /** + * Seconds until the notice closes itself; `null` or `0` keeps it open until + * dismissed. The countdown pauses while the notice is hovered or focused. + */ + duration?: number | null; + /** @default true */ + isClosable?: boolean; + /** Overrides the status icon. */ + icon?: ReactNode; + /** A complete notice body that replaces the title, description and progress. */ + content?: ReactNode; + /** Collapsible detail below the header. */ + children?: ReactNode; +} + +export interface NotificationStackProps { + /** Oldest first; the last entry renders nearest the corner. */ + notifications: Array; + /** Fired by the close button and by the auto-close timer. */ + onClose?: (key: Key) => void; + /** Cap on simultaneously visible notices; the newest win. Unlimited when unset. */ + maxVisible?: number; + /** Extra class names on the stack. */ + className?: string; + "data-testid"?: string; +} + +function NotificationStackItemView({ + item, + isExiting, + onClose, +}: { + item: NotificationStackItem; + isExiting: boolean; + onClose?: (key: Key) => void; +}) { + const t = useUicTranslator(); + const { key, duration } = item; + + // The timer must not restart when the parent re-creates `onClose`, but + // firing must still see the latest one. + const fireClose = useEffectEvent(() => onClose?.(key)); + + // A notice that closes under the pointer the reader moved there to read it + // is lost; focus counts too, so keyboard users get the same reprieve. + const [isPaused, setIsPaused] = useState(false); + + // Derived, not a default: a task is updated in place under the same key + // (pending, then failed), so the item never remounts. `null` means the + // reader has not touched the disclosure, so it follows the status. + const isError = (item.status ?? "info") === "error"; + const [detailOpenByUser, setDetailOpenByUser] = useState(null); + const isDetailOpen = detailOpenByUser ?? isError; + + // `0` means "stay open", not "close immediately". + const autoCloseMs = + typeof duration === "number" && duration > 0 ? duration * 1000 : null; + // What is left of the countdown, banked by the timer effect's cleanup so a + // pause and resume continues rather than restarts. + const remainingMsRef = useRef(autoCloseMs); + + // A new duration is a new budget. Cleanups run before effects, so this + // lands after the timer's cleanup banked the old value and before the timer + // below reads it. + useEffect(() => { + remainingMsRef.current = autoCloseMs; + }, [autoCloseMs]); + + useEffect(() => { + if (autoCloseMs === null || isExiting || isPaused) return; + const budget = remainingMsRef.current ?? autoCloseMs; + const startedAt = Date.now(); + const timer = window.setTimeout(() => fireClose(), budget); + return () => { + window.clearTimeout(timer); + remainingMsRef.current = Math.max(0, budget - (Date.now() - startedAt)); + }; + // An effect event is not a dependency; this plugin version predates it. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [autoCloseMs, isExiting, isPaused]); + + const hasProgress = + item.percent !== undefined || item.isProgressIndeterminate === true; + const hasActions = !!(item.onCancel || item.onRetry || item.onAction); + const hasOwnContent = item.content != null; + + // `wrap` lets the buttons stack inside the end area of a narrow notice + // instead of pushing the header wider. + const actions = ( + + {item.onCancel ? ( + + + + ); +} + +NumberStepper.displayName = "NumberStepper"; + +/** + * The index of the next rung of `steps` from `current`. A value already on a + * rung moves one rung (↑ from 4 lands on 8); a value between rungs moves to + * the nearer rung in that direction. The result can fall outside `steps` + * (-1, or `steps.length`); the caller decides what that means: clamp, or + * carry into another unit. + */ +export function getNextStepIndex( + steps: readonly number[], + current: number, + direction: StepDirection, +): number { + const sorted = [...steps].sort((a, b) => a - b); + let index = 0; + while (index < sorted.length && (sorted[index] ?? 0) < current) index += 1; + if (direction === "up") return current === sorted[index] ? index + 1 : index; + return index - 1; +} diff --git a/src/components/StepNumberInput/StepNumberInput.test.tsx b/src/components/StepNumberInput/StepNumberInput.test.tsx new file mode 100644 index 0000000..15b0093 --- /dev/null +++ b/src/components/StepNumberInput/StepNumberInput.test.tsx @@ -0,0 +1,189 @@ +import { useState } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { InputGroup } from "@astryxdesign/core/InputGroup"; +import { InternationalizationProvider } from "@astryxdesign/core/i18n"; + +import { uiCommonMessages } from "../../i18n/messages"; +import { NumberStepper, getNextStepIndex } from "./NumberStepper"; +import { StepNumberInput } from "./StepNumberInput"; + +const STEPS = [0, 0.5, 1, 2, 4, 8, 16]; + +const renderStepper = (onStep: (d: "up" | "down") => void = () => {}) => + render( + + + , + ); + +describe("NumberStepper", () => { + // The weld is CSS, which jsdom does not apply; these pin the DOM the + // stylesheet is written against. + it("is an InputGroupText slot whose two buttons are its direct children", () => { + const { container } = renderStepper(); + const slot = container.querySelector(".uic-number-stepper") as HTMLElement; + expect(slot).toHaveClass("astryx-input-group-text"); + expect(slot.children).toHaveLength(2); + for (const el of slot.children) + expect(el).toHaveClass("uic-number-stepper__button"); + expect(screen.getByLabelText("Increase")).toHaveClass( + "uic-number-stepper__button--increase", + ); + expect(screen.getByLabelText("Decrease")).toHaveClass( + "uic-number-stepper__button--decrease", + ); + }); + + it("keeps both halves off the tab order", () => { + const { container } = renderStepper(); + for (const el of container.querySelectorAll(".uic-number-stepper__button")) { + expect(el).toHaveAttribute("tabindex", "-1"); + expect(el).toHaveAttribute("type", "button"); + } + }); + + it("reports each half's direction", async () => { + const steps: string[] = []; + renderStepper((d) => steps.push(d)); + await userEvent.click(screen.getByLabelText("Increase")); + await userEvent.click(screen.getByLabelText("Decrease")); + expect(steps).toEqual(["up", "down"]); + }); + + it("disables both halves together", () => { + render( + + {}} isDisabled /> + , + ); + expect(screen.getByLabelText("Increase")).toBeDisabled(); + expect(screen.getByLabelText("Decrease")).toBeDisabled(); + }); + + it("names its halves through the catalog, or as given", () => { + const { unmount } = render( + + + {}} /> + + , + ); + expect(screen.getByLabelText("증가")).toBeInTheDocument(); + expect(screen.getByLabelText("감소")).toBeInTheDocument(); + unmount(); + + render( + + {}} increaseLabel="More" decreaseLabel="Less" /> + , + ); + expect(screen.getByLabelText("More")).toBeInTheDocument(); + expect(screen.getByLabelText("Less")).toBeInTheDocument(); + }); +}); + +describe("getNextStepIndex", () => { + it("moves one rung from a value on a rung", () => { + const rungs = [1, 2, 4, 8, 16]; + expect(getNextStepIndex(rungs, 4, "up")).toBe(3); + expect(getNextStepIndex(rungs, 4, "down")).toBe(1); + }); + + it("moves to the nearer rung from between rungs", () => { + const rungs = [1, 2, 4, 8, 16]; + expect(getNextStepIndex(rungs, 3, "up")).toBe(2); + expect(getNextStepIndex(rungs, 3, "down")).toBe(1); + }); + + it("can leave the list, for the caller to decide", () => { + const rungs = [1, 2, 4]; + expect(getNextStepIndex(rungs, 4, "up")).toBe(3); + expect(getNextStepIndex(rungs, 1, "down")).toBe(-1); + }); +}); + +function Controlled(props: { initial: number; min?: number; max?: number }) { + const [value, setValue] = useState(props.initial); + return ( + + ); +} + +const field = () => screen.getByRole("spinbutton", { name: /Size/ }); + +describe("StepNumberInput", () => { + it("steps along the list from the buttons", async () => { + render(); + await userEvent.click(screen.getByLabelText("Increase")); + expect(field()).toHaveValue("4"); + await userEvent.click(screen.getByLabelText("Decrease")); + await userEvent.click(screen.getByLabelText("Decrease")); + expect(field()).toHaveValue("1"); + }); + + it("steps along the list, not linearly, on ArrowUp and ArrowDown", async () => { + const user = userEvent.setup(); + render(); + await user.click(field()); + await user.keyboard("{ArrowUp}"); + expect(field()).toHaveValue("8"); + await user.keyboard("{ArrowDown}{ArrowDown}"); + expect(field()).toHaveValue("2"); + }); + + it("stays put at either end of the list", async () => { + const onChange = vi.fn(); + render( + , + ); + await userEvent.click(screen.getByLabelText("Increase")); + expect(onChange).not.toHaveBeenCalled(); + }); + + it("clamps a step to min and max", async () => { + render(); + await userEvent.click(screen.getByLabelText("Increase")); + expect(field()).toHaveValue("6"); + }); + + it("works uncontrolled from defaultValue, or the first step", async () => { + const onChange = vi.fn(); + render(); + expect(field()).toHaveValue("1"); + await userEvent.click(screen.getByLabelText("Increase")); + expect(onChange).toHaveBeenLastCalledWith(2); + expect(field()).toHaveValue("2"); + }); + + it("does not step while disabled", async () => { + const onChange = vi.fn(); + render( + , + ); + expect(screen.getByLabelText("Increase")).toBeDisabled(); + field().focus(); + await userEvent.keyboard("{ArrowUp}"); + expect(onChange).not.toHaveBeenCalled(); + }); + + it("labels the group and hides the label on request", () => { + render(); + expect(field()).toBeInTheDocument(); + expect(screen.getByRole("group", { name: "Size" })).toBeInTheDocument(); + }); +}); diff --git a/src/components/StepNumberInput/StepNumberInput.tsx b/src/components/StepNumberInput/StepNumberInput.tsx new file mode 100644 index 0000000..3d5982d --- /dev/null +++ b/src/components/StepNumberInput/StepNumberInput.tsx @@ -0,0 +1,130 @@ +/** + * StepNumberInput + * + * A number field that steps along a list of values instead of by a fixed + * step: `[0.25, 0.5, 1, 2, 4, 8]` goes 2 → 4 → 8 on the stepper and on + * ArrowUp/ArrowDown, and a typed 3 steps up to 4. A typed value is kept as + * typed. `min` and `max` clamp a step. + * + * `NumberInput`'s own stepping is linear, so the field cancels its + * ArrowUp/ArrowDown and the column beside it is a `NumberStepper`. + * + * @example + * + */ +import { useState, type CSSProperties, type KeyboardEvent } from "react"; +import { InputGroup } from "@astryxdesign/core/InputGroup"; +import { NumberInput } from "@astryxdesign/core/NumberInput"; + +import { NumberStepper, getNextStepIndex, type StepDirection } from "./NumberStepper"; + +export interface StepNumberInputProps { + /** The values the stepper moves between. */ + steps: readonly number[]; + /** The value, when controlled. */ + value?: number; + /** The first value when uncontrolled. Default: `steps[0]` */ + defaultValue?: number; + /** Called with the new value. A cleared field reports 0. */ + onChange?: (value: number) => void; + /** Lower bound for typing and stepping. */ + min?: number; + /** Upper bound for typing and stepping. */ + max?: number; + /** Unit shown in the field ("GiB"). */ + units?: string; + /** Accessible name. */ + label: string; + /** Hides the label visually; it stays the accessible name. Default: false */ + isLabelHidden?: boolean; + placeholder?: string; + isDisabled?: boolean; + /** Accessible name of the step-up button. Default: NumberStepper's */ + increaseLabel?: string; + /** Accessible name of the step-down button. Default: NumberStepper's */ + decreaseLabel?: string; + className?: string; + style?: CSSProperties; +} + +export function StepNumberInput({ + steps, + value: valueProp, + defaultValue, + onChange, + min, + max, + units, + label, + isLabelHidden = false, + placeholder, + isDisabled, + increaseLabel, + decreaseLabel, + className, + style, +}: StepNumberInputProps) { + const [innerValue, setInnerValue] = useState(defaultValue ?? steps[0] ?? 0); + const isControlled = valueProp !== undefined; + const value = isControlled ? valueProp : innerValue; + + const setValue = (next: number) => { + if (!isControlled) setInnerValue(next); + onChange?.(next); + }; + + const step = (direction: StepDirection) => { + const index = getNextStepIndex(steps, value, direction); + if (index < 0 || index >= steps.length) return; + let next = [...steps].sort((a, b) => a - b)[index] ?? value; + if (min !== undefined && next < min) next = min; + else if (max !== undefined && next > max) next = max; + setValue(next); + }; + + // ArrowUp/ArrowDown would step linearly by `step`; step the list instead. + const handleKeyDown = (event: KeyboardEvent) => { + if (isDisabled) return; + if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return; + event.preventDefault(); + step(event.key === "ArrowUp" ? "up" : "down"); + }; + + return ( + + setValue(next ?? 0)} + onKeyDown={handleKeyDown} + min={min} + max={max} + units={units} + placeholder={placeholder} + isDisabled={isDisabled} + width="100%" + /> + + + ); +} + +StepNumberInput.displayName = "StepNumberInput"; diff --git a/src/components/StepNumberInput/index.ts b/src/components/StepNumberInput/index.ts new file mode 100644 index 0000000..af327b9 --- /dev/null +++ b/src/components/StepNumberInput/index.ts @@ -0,0 +1,4 @@ +export { StepNumberInput } from "./StepNumberInput"; +export type { StepNumberInputProps } from "./StepNumberInput"; +export { NumberStepper, getNextStepIndex } from "./NumberStepper"; +export type { NumberStepperProps, StepDirection } from "./NumberStepper"; diff --git a/src/i18n/catalog.ts b/src/i18n/catalog.ts index 9ad68e3..ea4f82c 100644 --- a/src/i18n/catalog.ts +++ b/src/i18n/catalog.ts @@ -25,10 +25,12 @@ import type { Catalog, MessageEntry } from "@astryxdesign/core/i18n"; // These modules import `defineMessages` back from here. The cycle is safe: // it is a function declaration, so it is bound before either module runs. import { booleanTokenMessages } from "../components/BooleanToken/BooleanToken.messages"; +import { deleteConfirmModalMessages } from "../components/DeleteConfirmModal/DeleteConfirmModal.messages"; import { notificationStackMessages } from "../components/NotificationStack/NotificationStack.messages"; import { pageHeaderMessages } from "../components/PageHeader/PageHeader.messages"; import { selectionLabelMessages } from "../components/SelectionLabel/SelectionLabel.messages"; import { skeletonMessages } from "../components/Skeleton/Skeleton.messages"; +import { numberStepperMessages } from "../components/StepNumberInput/NumberStepper.messages"; import { uncontrolledInputMessages } from "../components/UncontrolledInput/UncontrolledInput.messages"; import { commonMessages } from "./common.messages"; @@ -51,7 +53,9 @@ export function defineMessages, +): ReactNode { + const nodes: ReactNode[] = []; + const formatted: Record = {}; + for (const [name, value] of Object.entries(values)) { + if (typeof value === "string" || typeof value === "number") { + formatted[name] = value; + } else { + formatted[name] = `${MARK}${nodes.length}${MARK}`; + nodes.push(value); + } + } + const text = t(key, formatted); + if (nodes.length === 0) return text; + // Split on the marker: even parts are text, odd parts are node indexes. + return text + .split(MARK) + .map((part, index) => + index % 2 === 1 ? {nodes[Number(part)]} : part, + ); +} diff --git a/src/index.ts b/src/index.ts index 3346d08..d748712 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1312,6 +1312,10 @@ export type { WordBreak, } from "@astryxdesign/core"; +// AlertModal +export { AlertModal } from "./components/AlertModal"; +export type { AlertModalProps } from "./components/AlertModal"; + // BooleanToken export { BooleanToken } from "./components/BooleanToken"; export type { BooleanTokenProps } from "./components/BooleanToken"; @@ -1324,6 +1328,13 @@ export type { ConfirmPopoverProps } from "./components/ConfirmPopover"; export { CountBadge } from "./components/CountBadge"; export type { CountBadgeProps } from "./components/CountBadge"; +// DeleteConfirmModal +export { DeleteConfirmModal } from "./components/DeleteConfirmModal"; +export type { + DeleteConfirmModalItem, + DeleteConfirmModalProps, +} from "./components/DeleteConfirmModal"; + // DigitPopIn export { DigitPopIn } from "./components/DigitPopIn"; export type { DigitPopInProps } from "./components/DigitPopIn"; @@ -1423,6 +1434,18 @@ export type { StatCardTrendDirection, } from "./components/StatCard"; +// StepNumberInput +export { + NumberStepper, + StepNumberInput, + getNextStepIndex, +} from "./components/StepNumberInput"; +export type { + NumberStepperProps, + StepDirection, + StepNumberInputProps, +} from "./components/StepNumberInput"; + // UncontrolledInput export { UncontrolledInput } from "./components/UncontrolledInput"; export type { UncontrolledInputProps } from "./components/UncontrolledInput"; From 56bff6a717dfd42c6e6e3fa98001cd16d575a02a Mon Sep 17 00:00:00 2001 From: Jongeun Date: Fri, 25 Sep 2026 19:27:25 +0000 Subject: [PATCH 31/87] docs: document AlertModal, DeleteConfirmModal and StepNumberInput README, CONTRIBUTING's component table and the CLI topic list the three components moved from backend.ai-ui. --- CONTRIBUTING.md | 43 +++++++++++++++++---------------- README.md | 45 +++++++++++++++++++---------------- astryx/docs/ui-common.doc.mjs | 3 +++ 3 files changed, 50 insertions(+), 41 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 10c1995..7979bcd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -75,26 +75,29 @@ Pick a different name, or use the Astryx component. ## Components -| Component | Source | Built on | -| -------------------------------------------------------------- | ----------------------------------- | -------------------------------------------------- | -| `Modal` | `src/components/Modal/` | `Dialog` (inline), `DialogHeader`, `Layout` | -| `PageLayout` | `src/components/PageLayout/` | plain CSS | -| `PageHeader` | `src/components/PageHeader/` | `Heading`, `Text`, `Button`, `IconButton` | -| `StatCard` | `src/components/StatCard/` | `Card`, `ClickableCard`, `Text`, `Skeleton` | -| `ErrorState` | `src/components/ErrorState/` | `Icon`, `Heading`, `Text`, `Button` | -| `SkeletonCard`, `SkeletonText`, `SkeletonChart`, `SkeletonRow` | `src/components/Skeleton/` | `Skeleton` | -| `SmoothHeight` | `src/components/SmoothHeight/` | plain CSS | -| `DigitPopIn` | `src/components/DigitPopIn/` | plain CSS | -| `CountBadge` | `src/components/CountBadge/` | `Badge` | -| `DoubleBadge` | `src/components/DoubleBadge/` | `Badge`, `HStack` | -| `BooleanToken` | `src/components/BooleanToken/` | `Token` | -| `IconWithTooltip` | `src/components/IconWithTooltip/` | `Tooltip`, `Text` | -| `ImageWithFallback` | `src/components/ImageWithFallback/` | plain `` | -| `NotificationStack` | `src/components/NotificationStack/` | `Banner`, `Button`, `ProgressBar`, `Stack`, `Text` | -| `OverlayScrollbar` | `src/components/OverlayScrollbar/` | plain CSS | -| `ConfirmPopover` | `src/components/ConfirmPopover/` | `Popover`, `Button`, `Stack`, `Text` | -| `SelectionLabel` | `src/components/SelectionLabel/` | `Text`, `IconButton`, `HStack` | -| `UncontrolledInput` | `src/components/UncontrolledInput/` | `TextInput`, `NumberInput` | +| Component | Source | Built on | +| -------------------------------------------------------------- | ------------------------------------ | -------------------------------------------------- | +| `Modal` | `src/components/Modal/` | `Dialog` (inline), `DialogHeader`, `Layout` | +| `PageLayout` | `src/components/PageLayout/` | plain CSS | +| `PageHeader` | `src/components/PageHeader/` | `Heading`, `Text`, `Button`, `IconButton` | +| `StatCard` | `src/components/StatCard/` | `Card`, `ClickableCard`, `Text`, `Skeleton` | +| `ErrorState` | `src/components/ErrorState/` | `Icon`, `Heading`, `Text`, `Button` | +| `SkeletonCard`, `SkeletonText`, `SkeletonChart`, `SkeletonRow` | `src/components/Skeleton/` | `Skeleton` | +| `SmoothHeight` | `src/components/SmoothHeight/` | plain CSS | +| `DigitPopIn` | `src/components/DigitPopIn/` | plain CSS | +| `CountBadge` | `src/components/CountBadge/` | `Badge` | +| `DoubleBadge` | `src/components/DoubleBadge/` | `Badge`, `HStack` | +| `BooleanToken` | `src/components/BooleanToken/` | `Token` | +| `IconWithTooltip` | `src/components/IconWithTooltip/` | `Tooltip`, `Text` | +| `ImageWithFallback` | `src/components/ImageWithFallback/` | plain `` | +| `NotificationStack` | `src/components/NotificationStack/` | `Banner`, `Button`, `ProgressBar`, `Stack`, `Text` | +| `OverlayScrollbar` | `src/components/OverlayScrollbar/` | plain CSS | +| `ConfirmPopover` | `src/components/ConfirmPopover/` | `Popover`, `Button`, `Stack`, `Text` | +| `SelectionLabel` | `src/components/SelectionLabel/` | `Text`, `IconButton`, `HStack` | +| `UncontrolledInput` | `src/components/UncontrolledInput/` | `TextInput`, `NumberInput` | +| `AlertModal` | `src/components/AlertModal/` | `Modal`, `Heading`, `Text`, `Button`, `Layout` | +| `DeleteConfirmModal` | `src/components/DeleteConfirmModal/` | `Modal`, `TextInput`, `Token`, `Banner`, `Text` | +| `StepNumberInput`, `NumberStepper` | `src/components/StepNumberInput/` | `InputGroup`, `NumberInput`, `Icon` | Each has tests beside it. `src/components/componentStyles.test.ts` holds every stylesheet to the styling rules below. diff --git a/README.md b/README.md index 07fba5a..52045a5 100644 --- a/README.md +++ b/README.md @@ -141,27 +141,30 @@ import { PageHeader, PageLayout, StatCard } from "@lablup/ui-common"; import { Modal } from "@lablup/ui-common/Modal"; ``` -| Component | What it is | Subpath | -| -------------------------------------------------------------- | ----------------------------------------------------------- | ------------------------------- | -| `Modal` | The dialog, in place of Astryx `Dialog`. See below. | `/Modal` | -| `PageLayout` | A page's width clamp (`standard`, `wide`, `full`) | `/components/PageLayout` | -| `PageHeader` | A page's title, description, actions and error banner | `/components/PageHeader` | -| `StatCard` | A dashboard metric, on Astryx `Card` | `/components/StatCard` | -| `ErrorState` | A full-area error with recovery actions | `/components/ErrorState` | -| `SkeletonCard`, `SkeletonText`, `SkeletonChart`, `SkeletonRow` | Loading placeholders drawn with Astryx `Skeleton` | `/components/Skeleton` | -| `SmoothHeight` | Animates a container toward its content's height | `/components/SmoothHeight` | -| `DigitPopIn` | A number whose characters pop in, one after another | `/components/DigitPopIn` | -| `CountBadge` | A count or dot overlaid on its child's corner | `/components/CountBadge` | -| `DoubleBadge` | A run of Badges welded into one chip | `/components/DoubleBadge` | -| `BooleanToken` | An on/off value as a Token | `/components/BooleanToken` | -| `IconWithTooltip` | A focusable glyph that explains itself in a Tooltip | `/components/IconWithTooltip` | -| `ImageWithFallback` | An image that renders a fallback node when it fails to load | `/components/ImageWithFallback` | -| `NotificationStack` | Floating notices with task progress and actions | `/components/NotificationStack` | -| `OverlayScrollbar` | A persistent scroll thumb drawn over a scroll container | `/components/OverlayScrollbar` | -| `ConfirmPopover` | A one-click confirmation anchored to its trigger | `/components/ConfirmPopover` | -| `SelectionLabel` | "3 selected", with a button that clears the selection | `/components/SelectionLabel` | -| `UncontrolledInput` | A field that reports its value on Enter or blur | `/components/UncontrolledInput` | -| `usePrefersReducedMotion` | The `prefers-reduced-motion` media query, as a hook | root only | +| Component | What it is | Subpath | +| -------------------------------------------------------------- | ----------------------------------------------------------- | -------------------------------- | +| `Modal` | The dialog, in place of Astryx `Dialog`. See below. | `/Modal` | +| `PageLayout` | A page's width clamp (`standard`, `wide`, `full`) | `/components/PageLayout` | +| `PageHeader` | A page's title, description, actions and error banner | `/components/PageHeader` | +| `StatCard` | A dashboard metric, on Astryx `Card` | `/components/StatCard` | +| `ErrorState` | A full-area error with recovery actions | `/components/ErrorState` | +| `SkeletonCard`, `SkeletonText`, `SkeletonChart`, `SkeletonRow` | Loading placeholders drawn with Astryx `Skeleton` | `/components/Skeleton` | +| `SmoothHeight` | Animates a container toward its content's height | `/components/SmoothHeight` | +| `DigitPopIn` | A number whose characters pop in, one after another | `/components/DigitPopIn` | +| `CountBadge` | A count or dot overlaid on its child's corner | `/components/CountBadge` | +| `DoubleBadge` | A run of Badges welded into one chip | `/components/DoubleBadge` | +| `BooleanToken` | An on/off value as a Token | `/components/BooleanToken` | +| `IconWithTooltip` | A focusable glyph that explains itself in a Tooltip | `/components/IconWithTooltip` | +| `ImageWithFallback` | An image that renders a fallback node when it fails to load | `/components/ImageWithFallback` | +| `NotificationStack` | Floating notices with task progress and actions | `/components/NotificationStack` | +| `OverlayScrollbar` | A persistent scroll thumb drawn over a scroll container | `/components/OverlayScrollbar` | +| `ConfirmPopover` | A one-click confirmation anchored to its trigger | `/components/ConfirmPopover` | +| `SelectionLabel` | "3 selected", with a button that clears the selection | `/components/SelectionLabel` | +| `UncontrolledInput` | A field that reports its value on Enter or blur | `/components/UncontrolledInput` | +| `AlertModal` | An alert dialog on `Modal`'s surface, in its level stack | `/components/AlertModal` | +| `DeleteConfirmModal` | Confirms a deletion, with typed confirmation when needed | `/components/DeleteConfirmModal` | +| `StepNumberInput`, `NumberStepper` | A number field that steps along a list of values | `/components/StepNumberInput` | +| `usePrefersReducedMotion` | The `prefers-reduced-motion` media query, as a hook | root only | Their styles live in `@layer ui-common`, under `uic-` class names. diff --git a/astryx/docs/ui-common.doc.mjs b/astryx/docs/ui-common.doc.mjs index 8352de7..c266520 100644 --- a/astryx/docs/ui-common.doc.mjs +++ b/astryx/docs/ui-common.doc.mjs @@ -61,6 +61,9 @@ export const docs = { "`ConfirmPopover`: a one-click confirmation on Popover, for reversible actions.", '`SelectionLabel`: "3 selected" with a button that clears the selection.', "`UncontrolledInput`: a text or number field that reports its value on Enter or blur.", + "`AlertModal`: the alert-dialog pattern on Modal, in its level stack. Use it instead of AlertDialog.", + "`DeleteConfirmModal`: confirms a deletion on Modal; typed confirmation for irreversible ones.", + "`StepNumberInput`: a number field that steps along a list of values (1, 2, 4, 8); `NumberStepper` is its stepper column.", ], }, { From e81b15001aa1e183d6fd64292303e958522590a0 Mon Sep 17 00:00:00 2001 From: Jongeun Date: Fri, 25 Sep 2026 20:00:01 +0000 Subject: [PATCH 32/87] feat(Modal): add headerClassName and footerClassName A product that keeps its own header and footer geometry (a fixed row height) can reach the header and footer Modal generates, without restyling Astryx classes. DeleteConfirmModal passes them through. --- astryx/components/Modal.doc.mjs | 11 +++++++++++ src/components/Modal/Modal.test.tsx | 19 +++++++++++++++++++ src/components/Modal/Modal.tsx | 11 ++++++++++- 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/astryx/components/Modal.doc.mjs b/astryx/components/Modal.doc.mjs index ad0c5ab..69e974a 100644 --- a/astryx/components/Modal.doc.mjs +++ b/astryx/components/Modal.doc.mjs @@ -98,6 +98,17 @@ export default { description: "Whether the header shows a close button.", default: "true", }, + { + name: "headerClassName", + type: "string", + description: + "Class name on the generated header, for a product's own header geometry.", + }, + { + name: "footerClassName", + type: "string", + description: "Class name on the generated footer.", + }, { name: "headerStartContent", type: "ReactNode", diff --git a/src/components/Modal/Modal.test.tsx b/src/components/Modal/Modal.test.tsx index 274ce7d..03095ba 100644 --- a/src/components/Modal/Modal.test.tsx +++ b/src/components/Modal/Modal.test.tsx @@ -319,6 +319,25 @@ describe("Modal content lifecycle", () => { }); describe("Modal structured mode", () => { + it("puts headerClassName and footerClassName on the generated chrome", () => { + render( + + body + , + ); + const header = document.querySelector(".my-header"); + const footer = document.querySelector(".my-footer"); + expect(header).toHaveTextContent("Rename"); + expect(footer?.querySelectorAll("button")).toHaveLength(2); + }); + it("renders children alone when no structure is asked for", () => { render( diff --git a/src/components/Modal/Modal.tsx b/src/components/Modal/Modal.tsx index d3f7613..e48e953 100644 --- a/src/components/Modal/Modal.tsx +++ b/src/components/Modal/Modal.tsx @@ -107,6 +107,8 @@ export interface ModalProps extends Omit { headerEndContent?: ReactNode; /** Whether the header shows a close button. Default: true */ hasCloseButton?: boolean; + /** Class name on the generated header, for a product's own header geometry. */ + headerClassName?: string; /** * Replaces the generated footer. `null` removes the footer. Left @@ -129,6 +131,8 @@ export interface ModalProps extends Omit { cancelLabel?: string; /** Whether the generated footer has a Cancel button. Default: true */ hasCancelButton?: boolean; + /** Class name on the generated footer. */ + footerClassName?: string; /** Shows a text skeleton in place of the body. */ isLoading?: boolean; } @@ -200,6 +204,7 @@ export function Modal({ headerStartContent, headerEndContent, hasCloseButton = true, + headerClassName, footer, onAction, actionLabel, @@ -209,6 +214,7 @@ export function Modal({ actionButtonProps, cancelLabel, hasCancelButton = true, + footerClassName, isLoading = false, role, children, @@ -397,13 +403,16 @@ export function Modal({ startContent={headerStartContent} endContent={headerEndContent} onOpenChange={hasCloseButton ? (next) => !next && close() : undefined} + className={headerClassName} /> ) : undefined } content={{body}} footer={ resolvedFooter ? ( - {resolvedFooter} + + {resolvedFooter} + ) : undefined } /> From da4e27548354350afa967012b3686ad7f31cdf61 Mon Sep 17 00:00:00 2001 From: Jongeun Date: Fri, 25 Sep 2026 20:00:11 +0000 Subject: [PATCH 33/87] chore: prepare 0.2.0-alpha.4 --- CHANGELOG.md | 33 +++++++++++++++++++++++++++++++++ package.json | 2 +- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 79e2c0e..b61c143 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,39 @@ Versioning follows the policy in [CONTRIBUTING.md](CONTRIBUTING.md#versioning). ## [Unreleased] +## [0.2.0-alpha.4] + +Three more components move in from backend.ai-ui: the rest of its dialog +family and its list-stepped number field. + +### Added + +- **Components moved from backend.ai-ui**, with Astryx-shaped props and + their tests, exported from the root and from + `@lablup/ui-common/components/`: + - `AlertModal`: the WAI-ARIA alert-dialog pattern on `Modal`'s portalled + surface and level stack (`title`, `description`, `actionLabel`, + `onAction`, `actionVariant`, `isActionLoading`, `isActionDisabled`, + `cancelLabel`, `isCancelDisabled`, plus `Modal`'s own props). Cancel + takes focus first; Escape cancels, the backdrop does not. Use it instead + of `AlertDialog` beside `Modal`. + - `DeleteConfirmModal`: confirms a deletion on `Modal` (`items`, `target`, + `description`, `title`, `titleIcon`, `onAction`, `actionLabel`), with a + typed confirmation (`isConfirmInputRequired`, `confirmText`, + `inputLabel`, `inputPlaceholder`, `isInputDisabled`) for irreversible + deletions and `isReversible` for undoable ones. `inputLabel` takes a node + or a function that places the confirm-text token. + - `StepNumberInput`: a number field that steps along `steps` on its + stepper and on ArrowUp/ArrowDown. `NumberStepper` (the stepper column for + an `InputGroup`) and `getNextStepIndex` are exported with it. +- Catalog keys `uic.common.delete`, `uic.DeleteConfirmModal.{title, +titleMany,description,targetDescription,typeToConfirm,confirmText, +cannotBeUndone}` and `uic.NumberStepper.{increase,decrease}`, translated + in every shipped locale from backend.ai-ui's locale files. + `uic.DeleteConfirmModal.titleMany` is an ICU plural. +- `Modal`: `headerClassName` and `footerClassName`, class names on the + header and footer it generates. + ## [0.2.0-alpha.3] Three more components move in from backend.ai-ui, and ui-common's strings diff --git a/package.json b/package.json index aef6aaf..673541d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@lablup/ui-common", - "version": "0.2.0-alpha.3", + "version": "0.2.0-alpha.4", "description": "Lablup's UI layer on Astryx: the Astryx surface re-exported 1:1, the Lablup theme, and shared components", "license": "Apache-2.0", "author": "Lablup Inc.", From 6dae0ffdbab795df9e143b790d89647996fd5e7e Mon Sep 17 00:00:00 2001 From: Jongeun Date: Fri, 25 Sep 2026 20:33:21 +0000 Subject: [PATCH 34/87] feat!: hide AlertDialog behind AlertModal Astryx AlertDialog renders in the browser's top layer, outside Modal's level stack, so a raw AlertDialog opened next to Modal surfaces does not nest with them and covers the notification layer Modal leaves reachable. exports.exclude.json now hides it with replacedBy AlertModal, the same treatment as Dialog and Modal: the @lablup/ui-common/AlertDialog subpath is gone, and AlertDialog, AlertDialogProps, useImperativeAlertDialog and ImperativeAlertDialogReturn leave the root barrel. AlertModal gets the top-level subpath @lablup/ui-common/AlertModal, so the CLI's exclusion note and the agents block point at an import that exists. BREAKING CHANGE: import AlertModal instead of AlertDialog. --- CHANGELOG.md | 10 ++++++++++ README.md | 5 +++-- astryx/components/AlertModal.doc.mjs | 2 +- astryx/docs/ui-common.doc.mjs | 2 +- docs/astryx.md | 3 ++- exports.customs.json | 6 +++++- exports.exclude.json | 5 +++++ package.json | 8 ++++---- src/astryx/AlertDialog.ts | 2 -- src/exports.test.ts | 21 +++++++++++++++++++++ src/index.ts | 11 ++++------- test/cli/cli.test.ts | 7 ++++++- 12 files changed, 62 insertions(+), 20 deletions(-) delete mode 100644 src/astryx/AlertDialog.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b61c143..83056ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ Versioning follows the policy in [CONTRIBUTING.md](CONTRIBUTING.md#versioning). ## [Unreleased] +### Removed + +- **Breaking:** `AlertDialog` is no longer mirrored. The + `@lablup/ui-common/AlertDialog` subpath is gone, and `AlertDialog`, + `AlertDialogProps`, `useImperativeAlertDialog` and + `ImperativeAlertDialogReturn` leave the root barrel. Dialog-based surfaces + go through `Modal`'s level stack; a raw `AlertDialog` bypasses it. Use + `AlertModal`, which now also has the top-level subpath + `@lablup/ui-common/AlertModal`, the way `Modal` stands in for `Dialog`. + ## [0.2.0-alpha.4] Three more components move in from backend.ai-ui: the rest of its dialog diff --git a/README.md b/README.md index 52045a5..8d054e7 100644 --- a/README.md +++ b/README.md @@ -161,7 +161,7 @@ import { Modal } from "@lablup/ui-common/Modal"; | `ConfirmPopover` | A one-click confirmation anchored to its trigger | `/components/ConfirmPopover` | | `SelectionLabel` | "3 selected", with a button that clears the selection | `/components/SelectionLabel` | | `UncontrolledInput` | A field that reports its value on Enter or blur | `/components/UncontrolledInput` | -| `AlertModal` | An alert dialog on `Modal`'s surface, in its level stack | `/components/AlertModal` | +| `AlertModal` | An alert dialog, in place of Astryx `AlertDialog` | `/AlertModal` | | `DeleteConfirmModal` | Confirms a deletion, with typed confirmation when needed | `/components/DeleteConfirmModal` | | `StepNumberInput`, `NumberStepper` | A number field that steps along a list of values | `/components/StepNumberInput` | | `usePrefersReducedMotion` | The `prefers-reduced-motion` media query, as a hook | root only | @@ -213,7 +213,8 @@ What it adds: A few Astryx subpaths are deliberately not re-exported. They are listed, with the reason and the replacement, in [`exports.exclude.json`](exports.exclude.json). -Today that is `Dialog` (use `Modal`) and two Astryx CLI data files. +Today that is `Dialog` (use `Modal`), `AlertDialog` (use `AlertModal`) and two +Astryx CLI data files. ### Name rule diff --git a/astryx/components/AlertModal.doc.mjs b/astryx/components/AlertModal.doc.mjs index 347929e..179572b 100644 --- a/astryx/components/AlertModal.doc.mjs +++ b/astryx/components/AlertModal.doc.mjs @@ -11,7 +11,7 @@ export default { category: "Overlay", keywords: ["alert dialog", "confirm", "alertdialog", "destructive", "are you sure"], description: - "The WAI-ARIA alert dialog on Modal's portalled surface: role=alertdialog named by its title and described by its description, Cancel focused first, Escape cancels, the backdrop does not. Use it instead of AlertDialog next to Modal: it joins Modal's level stack and leaves the notification layer reachable.", + "The WAI-ARIA alert dialog on Modal's portalled surface: role=alertdialog named by its title and described by its description, Cancel focused first, Escape cancels, the backdrop does not. ui-common hides Astryx AlertDialog in its favour: it joins Modal's level stack and leaves the notification layer reachable.", props: [ { name: "isOpen", diff --git a/astryx/docs/ui-common.doc.mjs b/astryx/docs/ui-common.doc.mjs index c266520..c0b11c8 100644 --- a/astryx/docs/ui-common.doc.mjs +++ b/astryx/docs/ui-common.doc.mjs @@ -61,7 +61,7 @@ export const docs = { "`ConfirmPopover`: a one-click confirmation on Popover, for reversible actions.", '`SelectionLabel`: "3 selected" with a button that clears the selection.', "`UncontrolledInput`: a text or number field that reports its value on Enter or blur.", - "`AlertModal`: the alert-dialog pattern on Modal, in its level stack. Use it instead of AlertDialog.", + "`AlertModal`: the alert-dialog pattern on Modal, in its level stack. ui-common hides AlertDialog; use this.", "`DeleteConfirmModal`: confirms a deletion on Modal; typed confirmation for irreversible ones.", "`StepNumberInput`: a number field that steps along a list of values (1, 2, 4, 8); `NumberStepper` is its stepper column.", ], diff --git a/docs/astryx.md b/docs/astryx.md index 46aea32..6fb4d42 100644 --- a/docs/astryx.md +++ b/docs/astryx.md @@ -82,7 +82,8 @@ Product palettes stay in the products, as their own `defineTheme` over Astryx primitives and tokens and never share a name with Astryx. Each is in the root barrel and at `@lablup/ui-common/components/`; `Modal` also has a top-level subpath, `@lablup/ui-common/Modal`, because it stands where - the hidden `@lablup/ui-common/Dialog` would. The excluded subpath's own + the hidden `@lablup/ui-common/Dialog` would, and so has `AlertModal` in + place of the hidden `AlertDialog`. The excluded subpath's own names that `Modal` re-exports unchanged (`DialogHeader` and the Dialog types) are the one sanctioned overlap with Astryx: they are Astryx's. - **`ui-common.css`**, the global sheet. It holds rules with no component to diff --git a/exports.customs.json b/exports.customs.json index 1781e76..26a6deb 100644 --- a/exports.customs.json +++ b/exports.customs.json @@ -1,5 +1,9 @@ [ - { "name": "AlertModal", "source": "components/AlertModal/index.ts" }, + { + "name": "AlertModal", + "source": "components/AlertModal/index.ts", + "subpath": "AlertModal" + }, { "name": "BooleanToken", "source": "components/BooleanToken/index.ts" }, { "name": "ConfirmPopover", "source": "components/ConfirmPopover/index.ts" }, { "name": "CountBadge", "source": "components/CountBadge/index.ts" }, diff --git a/exports.exclude.json b/exports.exclude.json index 5b6103b..d8c735b 100644 --- a/exports.exclude.json +++ b/exports.exclude.json @@ -1,4 +1,9 @@ [ + { + "name": "AlertDialog", + "replacedBy": "AlertModal", + "reason": "Dialog-based surfaces go through Modal's level stack; a raw AlertDialog bypasses it." + }, { "name": "Dialog", "replacedBy": "Modal", diff --git a/package.json b/package.json index 673541d..0d8bad4 100644 --- a/package.json +++ b/package.json @@ -91,10 +91,6 @@ "import": "./dist/astryx/theme/syntax.js" }, "./locales/*.json": "./dist/locales/*.json", - "./AlertDialog": { - "types": "./dist/astryx/AlertDialog.d.ts", - "import": "./dist/astryx/AlertDialog.js" - }, "./AppShell": { "types": "./dist/astryx/AppShell.d.ts", "import": "./dist/astryx/AppShell.js" @@ -561,6 +557,10 @@ "import": "./dist/astryx/theme/neutral/built.js" }, "./theme/neutral/theme.css": "./dist/astryx/theme/neutral/theme.css", + "./AlertModal": { + "types": "./dist/components/AlertModal/index.d.ts", + "import": "./dist/components/AlertModal/index.js" + }, "./Modal": { "types": "./dist/components/Modal/index.d.ts", "import": "./dist/components/Modal/index.js" diff --git a/src/astryx/AlertDialog.ts b/src/astryx/AlertDialog.ts deleted file mode 100644 index 4b77447..0000000 --- a/src/astryx/AlertDialog.ts +++ /dev/null @@ -1,2 +0,0 @@ -// @generated by scripts/gen-exports.mjs. Do not edit. -export * from "@astryxdesign/core/AlertDialog"; diff --git a/src/exports.test.ts b/src/exports.test.ts index 8254ea5..8bc3846 100644 --- a/src/exports.test.ts +++ b/src/exports.test.ts @@ -148,5 +148,26 @@ describe("export surface rules", () => { const dialog = exclusions.find((e) => e.name === "Dialog"); expect(dialog?.replacedBy).toBe("Modal"); expect(customs.map((c) => c.name)).toContain("Modal"); + const alertDialog = exclusions.find((e) => e.name === "AlertDialog"); + expect(alertDialog?.replacedBy).toBe("AlertModal"); + expect(customs.map((c) => c.name)).toContain("AlertModal"); + }); + + it("hides AlertDialog behind AlertModal, which has its own subpath", () => { + expect(Object.keys(result.exports)).not.toContain("./AlertDialog"); + expect(result.exports["./AlertModal"]).toEqual({ + types: "./dist/components/AlertModal/index.d.ts", + import: "./dist/components/AlertModal/index.js", + }); + const barrel = result.files.get("src/index.ts") ?? ""; + for (const name of [ + "AlertDialog", + "AlertDialogProps", + "useImperativeAlertDialog", + "ImperativeAlertDialogReturn", + ]) { + expect(barrel).not.toMatch(new RegExp(`^\\s+${name},$`, "m")); + expect(result.report.droppedFromRoot).toContain(name); + } }); }); diff --git a/src/index.ts b/src/index.ts index d748712..1300651 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,9 +6,10 @@ * exports.customs.json. Edit those files and run `pnpm run gen:exports`. * * Left out because their subpath is excluded: - * Dialog, DialogHeader, DialogHeaderProps, DialogPosition, DialogProps, - * DialogPurpose, DialogVariant, DialogVariantMap, ImperativeDialogReturn, - * useImperativeDialog. + * AlertDialog, AlertDialogProps, Dialog, DialogHeader, DialogHeaderProps, + * DialogPosition, DialogProps, DialogPurpose, DialogVariant, DialogVariantMap, + * ImperativeAlertDialogReturn, ImperativeDialogReturn, + * useImperativeAlertDialog, useImperativeDialog. * * Excluded Astryx names re-exported unchanged by their replacement: * DialogHeader, DialogHeaderProps, DialogPosition, DialogPurpose, @@ -16,7 +17,6 @@ */ export { - AlertDialog, AppShell, AppShellMobileContext, AspectRatio, @@ -442,7 +442,6 @@ export { useHoverCard, useIcon, useImageMode, - useImperativeAlertDialog, useIndicator, useIndicatorFocusRing, useInputContainer, @@ -511,7 +510,6 @@ export { warnOnce, } from "@astryxdesign/core"; export type { - AlertDialogProps, AnnounceFn, AnnouncePoliteness, AppShellBreakpoint, @@ -805,7 +803,6 @@ export type { IconSize, IconType, ImageSampleRegion, - ImperativeAlertDialogReturn, IncrementalParseState, IndicatorComponent, IndicatorFamily, diff --git a/test/cli/cli.test.ts b/test/cli/cli.test.ts index 1e52233..8d2b5a3 100644 --- a/test/cli/cli.test.ts +++ b/test/cli/cli.test.ts @@ -101,7 +101,12 @@ describe("output rewriting", { timeout: 60_000 }, () => { expect(exclusionNotes("see @lablup/ui-common/Dialog")[0]).toMatch( /Use Modal .*not Dialog/, ); - expect(exclusionNotes("AlertDialog and DialogHeader")).toEqual([]); + // Dialog is matched as a whole word: AlertDialog is its own exclusion, + // and DialogHeader is re-exported by Modal. + const notes = exclusionNotes("AlertDialog and DialogHeader"); + expect(notes).toHaveLength(1); + expect(notes[0]).toMatch(/Use AlertModal .*not AlertDialog/); + expect(exclusionNotes("DialogHeader")).toEqual([]); expect(exclusionNotes("nothing", ["component", "Dialog"])).toHaveLength(1); }); From 6e151f3b1be21ab244b5cf211733eb14f8acea19 Mon Sep 17 00:00:00 2001 From: Jongeun Date: Fri, 25 Sep 2026 20:54:25 +0000 Subject: [PATCH 35/87] feat: add BoardItemTitle, Statistic, DividedRow, TokenList, TokenRow and NotificationItem from backend.ai-ui Six product-neutral components that backend.ai-ui ships today move here. In the origin they laid themselves out with BAIFlex and read the antd-parity theme shim; here the layout is Astryx HStack/VStack plus @layer ui-common CSS, and every value the shim resolved is the Astryx token it probed (--spacing-*, --color-background-surface, --color-border, --color-text-secondary): - BoardItemTitle: a dashboard panel's sticky title row, with a help tooltip on IconWithTooltip (tooltipIcon, the theme's info icon by default) and endContent. --uic-board-item-title-z sets its z-index. - Statistic: a caption, a 32px value and a notched usage bar with role=progressbar and a value / total tooltip. label, value, progressMode hidden/placeholder/visible and color replace the origin's title, current, ghost/normal and style.color. - DividedRow: a wrapping row with a divider between neighbours on the same line only, measured after layout. Default gaps are var(--spacing-8) and var(--spacing-12). - TokenList: values inline and the rest behind +N, on a HoverCard or a latching Popover. - TokenRow: tokens cut off with "and N more"; moreLabel overrides it. - NotificationItem: the title, description, actions and footer of a notice. The two strings are catalog keys, uic.Statistic.unlimited and uic.TokenRow.more (ICU {count}), with the translations for all 20 locales carried over from backend.ai-ui. DividedRow and NotificationItem had no tests in the origin and get them here. --- CHANGELOG.md | 26 +++ CONTRIBUTING.md | 6 + README.md | 6 + astryx/components/BoardItemTitle.doc.mjs | 51 ++++++ astryx/components/BoardItemTitle.tsx | 3 + astryx/components/DividedRow.doc.mjs | 85 +++++++++ astryx/components/DividedRow.tsx | 3 + astryx/components/NotificationItem.doc.mjs | 52 ++++++ astryx/components/NotificationItem.tsx | 3 + astryx/components/Statistic.doc.mjs | 84 +++++++++ astryx/components/Statistic.tsx | 3 + astryx/components/TokenList.doc.mjs | 58 ++++++ astryx/components/TokenList.tsx | 3 + astryx/components/TokenRow.doc.mjs | 62 +++++++ astryx/components/TokenRow.tsx | 3 + astryx/docs/ui-common.doc.mjs | 6 +- exports.customs.json | 6 + .../BoardItemTitle/BoardItemTitle.css | 26 +++ .../BoardItemTitle/BoardItemTitle.test.tsx | 64 +++++++ .../BoardItemTitle/BoardItemTitle.tsx | 81 ++++++++ src/components/BoardItemTitle/index.ts | 2 + src/components/DividedRow/DividedRow.css | 24 +++ src/components/DividedRow/DividedRow.test.tsx | 121 ++++++++++++ src/components/DividedRow/DividedRow.tsx | 157 ++++++++++++++++ src/components/DividedRow/index.ts | 2 + .../NotificationItem/NotificationItem.css | 23 +++ .../NotificationItem.test.tsx | 56 ++++++ .../NotificationItem/NotificationItem.tsx | 72 ++++++++ src/components/NotificationItem/index.ts | 2 + src/components/Statistic/Statistic.css | 48 +++++ .../Statistic/Statistic.messages.ts | 8 + src/components/Statistic/Statistic.test.tsx | 139 ++++++++++++++ src/components/Statistic/Statistic.tsx | 173 ++++++++++++++++++ src/components/Statistic/index.ts | 2 + src/components/TokenList/TokenList.css | 28 +++ src/components/TokenList/TokenList.test.tsx | 77 ++++++++ src/components/TokenList/TokenList.tsx | 129 +++++++++++++ src/components/TokenList/index.ts | 2 + src/components/TokenRow/TokenRow.css | 18 ++ src/components/TokenRow/TokenRow.messages.ts | 9 + src/components/TokenRow/TokenRow.test.tsx | 73 ++++++++ src/components/TokenRow/TokenRow.tsx | 88 +++++++++ src/components/TokenRow/index.ts | 2 + src/i18n/catalog.ts | 4 + src/i18n/locales/de-DE.json | 8 + src/i18n/locales/el-GR.json | 8 + src/i18n/locales/es-ES.json | 8 + src/i18n/locales/fi-FI.json | 8 + src/i18n/locales/fr-FR.json | 8 + src/i18n/locales/id-ID.json | 8 + src/i18n/locales/it-IT.json | 8 + src/i18n/locales/ja-JP.json | 8 + src/i18n/locales/ko-KR.json | 8 + src/i18n/locales/mn-MN.json | 8 + src/i18n/locales/ms-MY.json | 8 + src/i18n/locales/pl-PL.json | 8 + src/i18n/locales/pt-BR.json | 8 + src/i18n/locales/pt-PT.json | 8 + src/i18n/locales/ru-RU.json | 8 + src/i18n/locales/th-TH.json | 8 + src/i18n/locales/tr-TR.json | 8 + src/i18n/locales/vi-VN.json | 8 + src/i18n/locales/zh-CN.json | 8 + src/i18n/locales/zh-TW.json | 8 + src/index.ts | 24 +++ 65 files changed, 2073 insertions(+), 1 deletion(-) create mode 100644 astryx/components/BoardItemTitle.doc.mjs create mode 100644 astryx/components/BoardItemTitle.tsx create mode 100644 astryx/components/DividedRow.doc.mjs create mode 100644 astryx/components/DividedRow.tsx create mode 100644 astryx/components/NotificationItem.doc.mjs create mode 100644 astryx/components/NotificationItem.tsx create mode 100644 astryx/components/Statistic.doc.mjs create mode 100644 astryx/components/Statistic.tsx create mode 100644 astryx/components/TokenList.doc.mjs create mode 100644 astryx/components/TokenList.tsx create mode 100644 astryx/components/TokenRow.doc.mjs create mode 100644 astryx/components/TokenRow.tsx create mode 100644 src/components/BoardItemTitle/BoardItemTitle.css create mode 100644 src/components/BoardItemTitle/BoardItemTitle.test.tsx create mode 100644 src/components/BoardItemTitle/BoardItemTitle.tsx create mode 100644 src/components/BoardItemTitle/index.ts create mode 100644 src/components/DividedRow/DividedRow.css create mode 100644 src/components/DividedRow/DividedRow.test.tsx create mode 100644 src/components/DividedRow/DividedRow.tsx create mode 100644 src/components/DividedRow/index.ts create mode 100644 src/components/NotificationItem/NotificationItem.css create mode 100644 src/components/NotificationItem/NotificationItem.test.tsx create mode 100644 src/components/NotificationItem/NotificationItem.tsx create mode 100644 src/components/NotificationItem/index.ts create mode 100644 src/components/Statistic/Statistic.css create mode 100644 src/components/Statistic/Statistic.messages.ts create mode 100644 src/components/Statistic/Statistic.test.tsx create mode 100644 src/components/Statistic/Statistic.tsx create mode 100644 src/components/Statistic/index.ts create mode 100644 src/components/TokenList/TokenList.css create mode 100644 src/components/TokenList/TokenList.test.tsx create mode 100644 src/components/TokenList/TokenList.tsx create mode 100644 src/components/TokenList/index.ts create mode 100644 src/components/TokenRow/TokenRow.css create mode 100644 src/components/TokenRow/TokenRow.messages.ts create mode 100644 src/components/TokenRow/TokenRow.test.tsx create mode 100644 src/components/TokenRow/TokenRow.tsx create mode 100644 src/components/TokenRow/index.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 83056ea..bda6644 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,32 @@ Versioning follows the policy in [CONTRIBUTING.md](CONTRIBUTING.md#versioning). ## [Unreleased] +### Added + +- **Components moved from backend.ai-ui**, with Astryx-shaped props and + their tests, exported from the root and from + `@lablup/ui-common/components/`. Their layout is Astryx + `Stack`/`HStack`/`VStack` and `@layer ui-common` CSS on Astryx tokens: + - `BoardItemTitle`: a dashboard panel's sticky title row (`title`, + `tooltip`, `tooltipIcon`, `endContent`); `--uic-board-item-title-z` + sets its z-index (default 50). + - `Statistic`: a metric with a caption, a large value and a notched usage + bar (`label`, `value`, `total`, `unit`, `precision`, `progressMode` + `hidden`/`placeholder`/`visible`, `progressSteps`, `color`, + `unlimitedLabel`, `infinityLabel`). + - `DividedRow`: a wrapping row that draws a divider between neighbours on + the same line only (`wrap`, `rowGap`, `columnGap`, `dividerWidth`, + `dividerColor`, `dividerInset`, `itemStyle`). + - `TokenList`: values inline, the rest behind `+N` on hover or click + (`items`, `maxInline`, `emptyText`, `variant`, `trigger`). + - `TokenRow`: tokens cut off with "and N more" (`items`, `maxCount`, + `totalCount`, `color`, `emptyText`, `moreLabel`). + - `NotificationItem`: the title, description, actions and footer of one + notice. +- Catalog keys `uic.Statistic.unlimited` and `uic.TokenRow.more` (ICU + `{count}`), translated in every shipped locale from backend.ai-ui's locale + files. + ### Removed - **Breaking:** `AlertDialog` is no longer mirrored. The diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7979bcd..6924264 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -98,6 +98,12 @@ Pick a different name, or use the Astryx component. | `AlertModal` | `src/components/AlertModal/` | `Modal`, `Heading`, `Text`, `Button`, `Layout` | | `DeleteConfirmModal` | `src/components/DeleteConfirmModal/` | `Modal`, `TextInput`, `Token`, `Banner`, `Text` | | `StepNumberInput`, `NumberStepper` | `src/components/StepNumberInput/` | `InputGroup`, `NumberInput`, `Icon` | +| `BoardItemTitle` | `src/components/BoardItemTitle/` | `HStack`, `Heading`, `Icon`, `IconWithTooltip` | +| `Statistic` | `src/components/Statistic/` | `Stack`, `Text`, `Tooltip` | +| `DividedRow` | `src/components/DividedRow/` | plain CSS | +| `TokenList` | `src/components/TokenList/` | `Token`, `Badge`, `Link`, `HoverCard`, `Popover` | +| `TokenRow` | `src/components/TokenRow/` | `Token`, `HStack` | +| `NotificationItem` | `src/components/NotificationItem/` | `Stack`, `Text` | Each has tests beside it. `src/components/componentStyles.test.ts` holds every stylesheet to the styling rules below. diff --git a/README.md b/README.md index 8d054e7..fba8955 100644 --- a/README.md +++ b/README.md @@ -164,6 +164,12 @@ import { Modal } from "@lablup/ui-common/Modal"; | `AlertModal` | An alert dialog, in place of Astryx `AlertDialog` | `/AlertModal` | | `DeleteConfirmModal` | Confirms a deletion, with typed confirmation when needed | `/components/DeleteConfirmModal` | | `StepNumberInput`, `NumberStepper` | A number field that steps along a list of values | `/components/StepNumberInput` | +| `BoardItemTitle` | A dashboard panel's sticky title row | `/components/BoardItemTitle` | +| `Statistic` | A metric with a caption, a large value and a notched bar | `/components/Statistic` | +| `DividedRow` | A wrapping row with dividers between neighbours on a line | `/components/DividedRow` | +| `TokenList` | Values inline, the rest behind `+N` on hover | `/components/TokenList` | +| `TokenRow` | Tokens cut off with "and N more" | `/components/TokenRow` | +| `NotificationItem` | The title, description, actions and footer of one notice | `/components/NotificationItem` | | `usePrefersReducedMotion` | The `prefers-reduced-motion` media query, as a hook | root only | Their styles live in `@layer ui-common`, under `uic-` class names. diff --git a/astryx/components/BoardItemTitle.doc.mjs b/astryx/components/BoardItemTitle.doc.mjs new file mode 100644 index 0000000..22a5852 --- /dev/null +++ b/astryx/components/BoardItemTitle.doc.mjs @@ -0,0 +1,51 @@ +/** + * `astryx component BoardItemTitle` (and `ui-common component BoardItemTitle`). + * + * @type {import('@astryxdesign/cli/authoring').ComponentDoc} + */ +export default { + type: "component", + name: "BoardItemTitle", + displayName: "BoardItemTitle", + import: "@lablup/ui-common", + category: "Layout", + keywords: ["title", "panel", "dashboard", "board", "sticky", "header"], + description: + "The title row of a dashboard panel: a heading with an optional help tooltip, and actions at the end. It sticks to the top of the panel's scroll area on the surface colour, and its two groups wrap onto separate lines when the panel is narrow.", + props: [ + { + name: "title", + type: "ReactNode", + description: + "The title. A string renders as a level-5 heading; a node renders as is.", + required: true, + }, + { + name: "tooltip", + type: "ReactNode", + description: + "Help text in a tooltip beside the title. Without it, no help glyph.", + }, + { + name: "tooltipIcon", + type: "ReactNode", + description: "Glyph of the help tooltip.", + default: "the theme's info icon", + }, + { + name: "endContent", + type: "ReactNode", + description: "Actions at the end of the row.", + }, + ], + usage: { + description: + "At the top of a dashboard panel or a board item. Other div attributes (className, style, data-*) reach the row. The --uic-board-item-title-z property sets its z-index (default 50).", + }, + examples: [ + { + label: "Panel title with help and an action", + code: '}\n/>', + }, + ], +}; diff --git a/astryx/components/BoardItemTitle.tsx b/astryx/components/BoardItemTitle.tsx new file mode 100644 index 0000000..4a8dea3 --- /dev/null +++ b/astryx/components/BoardItemTitle.tsx @@ -0,0 +1,3 @@ +// The Astryx CLI pairs each component doc with a same-stem source file. +// ui-common ships no source, so this names the export instead. +export { BoardItemTitle } from "@lablup/ui-common"; diff --git a/astryx/components/DividedRow.doc.mjs b/astryx/components/DividedRow.doc.mjs new file mode 100644 index 0000000..6e5ba3a --- /dev/null +++ b/astryx/components/DividedRow.doc.mjs @@ -0,0 +1,85 @@ +/** + * `astryx component DividedRow` (and `ui-common component DividedRow`). + * + * @type {import('@astryxdesign/cli/authoring').ComponentDoc} + */ +export default { + type: "component", + name: "DividedRow", + displayName: "DividedRow", + import: "@lablup/ui-common", + category: "Layout", + keywords: ["divider", "separator", "wrap", "row", "stat row"], + description: + "Lays its children out in a wrapping row and draws a vertical divider between neighbours on the same line only, centred in the column gap. An item that ends a line has no divider after it; line ends are measured after layout and on every resize.", + props: [ + { + name: "children", + type: "ReactNode", + description: "The items. Empty children are skipped.", + }, + { + name: "wrap", + type: "'wrap' | 'nowrap'", + description: "Whether items flow onto more lines.", + default: "'wrap'", + }, + { + name: "rowGap", + type: "number | string", + description: "Gap between lines. A number is pixels.", + default: "var(--spacing-8)", + }, + { + name: "columnGap", + type: "number | string", + description: + "Gap between items on a line; the divider sits in its middle. A number is pixels.", + default: "var(--spacing-12)", + }, + { + name: "dividerWidth", + type: "number", + description: "Divider thickness in pixels.", + default: "1", + }, + { + name: "dividerColor", + type: "string", + description: "Divider colour.", + default: "var(--color-border)", + }, + { + name: "dividerInset", + type: "number", + description: + "How far the divider stops short of the item's top and bottom, in pixels.", + default: "0", + }, + { + name: "itemStyle", + type: "CSSProperties", + description: "Inline styles on each item's wrapper.", + }, + { + name: "className", + type: "string", + description: "Class names on the row.", + }, + { + name: "style", + type: "CSSProperties", + description: "Inline styles on the row.", + }, + ], + usage: { + description: + "A row of metrics or summaries that should read as separate columns and still wrap on a narrow screen.", + }, + examples: [ + { + label: "Metrics separated by dividers", + code: '\n \n \n', + }, + ], +}; diff --git a/astryx/components/DividedRow.tsx b/astryx/components/DividedRow.tsx new file mode 100644 index 0000000..c9b71cc --- /dev/null +++ b/astryx/components/DividedRow.tsx @@ -0,0 +1,3 @@ +// The Astryx CLI pairs each component doc with a same-stem source file. +// ui-common ships no source, so this names the export instead. +export { DividedRow } from "@lablup/ui-common"; diff --git a/astryx/components/NotificationItem.doc.mjs b/astryx/components/NotificationItem.doc.mjs new file mode 100644 index 0000000..c8ef1a5 --- /dev/null +++ b/astryx/components/NotificationItem.doc.mjs @@ -0,0 +1,52 @@ +/** + * `astryx component NotificationItem` (and `ui-common component NotificationItem`). + * + * @type {import('@astryxdesign/cli/authoring').ComponentDoc} + */ +export default { + type: "component", + name: "NotificationItem", + displayName: "NotificationItem", + import: "@lablup/ui-common", + category: "Feedback", + keywords: ["notification", "notice", "toast body", "footer", "timestamp"], + description: + "The body of one notice: a title, a description, actions and a footer, stacked, with the actions and the footer at the end. A string or number in a slot renders as body Text; a node renders as is. The title leaves room for the notice's close button.", + props: [ + { + name: "title", + type: "ReactNode", + description: "The title.", + }, + { + name: "description", + type: "ReactNode", + description: "The description.", + }, + { + name: "action", + type: "ReactNode", + description: "Actions, at the end of their own row.", + }, + { + name: "footer", + type: "ReactNode", + description: "A quiet note at the end, such as when the notice arrived.", + }, + { + name: "className", + type: "string", + description: "Class names on the root.", + }, + ], + usage: { + description: + "As the description of a NotificationStack notice whose body has more structure than a line of text.", + }, + examples: [ + { + label: "A finished upload", + code: '}\n footer="2 minutes ago"\n/>', + }, + ], +}; diff --git a/astryx/components/NotificationItem.tsx b/astryx/components/NotificationItem.tsx new file mode 100644 index 0000000..2922d0f --- /dev/null +++ b/astryx/components/NotificationItem.tsx @@ -0,0 +1,3 @@ +// The Astryx CLI pairs each component doc with a same-stem source file. +// ui-common ships no source, so this names the export instead. +export { NotificationItem } from "@lablup/ui-common"; diff --git a/astryx/components/Statistic.doc.mjs b/astryx/components/Statistic.doc.mjs new file mode 100644 index 0000000..b69c8e7 --- /dev/null +++ b/astryx/components/Statistic.doc.mjs @@ -0,0 +1,84 @@ +/** + * `astryx component Statistic` (and `ui-common component Statistic`). + * + * @type {import('@astryxdesign/cli/authoring').ComponentDoc} + */ +export default { + type: "component", + name: "Statistic", + displayName: "Statistic", + import: "@lablup/ui-common", + category: "Data Display", + keywords: ["metric", "statistic", "usage", "quota", "progress", "steps"], + description: + "A dashboard metric: a caption, a large value with its unit, and an optional segmented usage bar of value against total. The bar is a strip of notches with role=progressbar and a value / total tooltip. A value that is not finite shows the Unlimited label.", + props: [ + { + name: "label", + type: "ReactNode", + description: "The caption.", + required: true, + }, + { + name: "value", + type: "number", + description: "The value. Not finite renders unlimitedLabel.", + }, + { + name: "total", + type: "number", + description: "What the value is measured against; the bar needs it.", + }, + { + name: "unit", + type: "string", + description: "Unit after the value.", + }, + { + name: "precision", + type: "number", + description: "Decimal places, trailing zeros dropped.", + default: "2", + }, + { + name: "infinityLabel", + type: "string", + description: "A non-finite value or total in the bar's tooltip.", + default: "'∞'", + }, + { + name: "progressMode", + type: "'hidden' | 'placeholder' | 'visible'", + description: + "visible draws the bar (with a total), placeholder keeps its space without painting it, hidden leaves it out.", + default: "'hidden'", + }, + { + name: "progressSteps", + type: "number", + description: "Notches in the bar.", + default: "20", + }, + { + name: "color", + type: "string", + description: "Colour of the value and of the bar's filled notches.", + }, + { + name: "unlimitedLabel", + type: "string", + description: "Shown for a non-finite value.", + default: 'the catalog\'s uic.Statistic.unlimited ("Unlimited")', + }, + ], + usage: { + description: + "A resource or quota figure on a dashboard, where the notch bar tells 3 of 20 from 4 of 20 at a glance. Other div attributes reach the root.", + }, + examples: [ + { + label: "Memory against its quota", + code: '', + }, + ], +}; diff --git a/astryx/components/Statistic.tsx b/astryx/components/Statistic.tsx new file mode 100644 index 0000000..34186ea --- /dev/null +++ b/astryx/components/Statistic.tsx @@ -0,0 +1,3 @@ +// The Astryx CLI pairs each component doc with a same-stem source file. +// ui-common ships no source, so this names the export instead. +export { Statistic } from "@lablup/ui-common"; diff --git a/astryx/components/TokenList.doc.mjs b/astryx/components/TokenList.doc.mjs new file mode 100644 index 0000000..650b92e --- /dev/null +++ b/astryx/components/TokenList.doc.mjs @@ -0,0 +1,58 @@ +/** + * `astryx component TokenList` (and `ui-common component TokenList`). + * + * @type {import('@astryxdesign/cli/authoring').ComponentDoc} + */ +export default { + type: "component", + name: "TokenList", + displayName: "TokenList", + import: "@lablup/ui-common", + category: "Data Display", + keywords: ["tokens", "tags", "overflow", "more", "list", "emails"], + description: + "A bounded list of settled values: the first maxInline items inline, then +N that shows the rest on hover and focus in a HoverCard (a Popover with trigger=click). variant=token draws Tokens and a +N link; variant=text draws plain text and a +N badge for dense table cells.", + props: [ + { + name: "items", + type: "ReadonlyArray", + description: "The values.", + required: true, + }, + { + name: "maxInline", + type: "number", + description: "How many items show inline before +N.", + default: "3", + }, + { + name: "emptyText", + type: "ReactNode", + description: "Rendered instead of the list when items is empty.", + default: "'-'", + }, + { + name: "variant", + type: "'token' | 'text'", + description: + "token for Tokens and a +N link, text for plain text and a +N badge.", + default: "'token'", + }, + { + name: "trigger", + type: "'hover' | 'click'", + description: "How the overflow opens.", + default: "'hover'", + }, + ], + usage: { + description: + "A cell or field that holds a list of values of unknown length, such as a user's groups.", + }, + examples: [ + { + label: "In a table cell", + code: '', + }, + ], +}; diff --git a/astryx/components/TokenList.tsx b/astryx/components/TokenList.tsx new file mode 100644 index 0000000..2613e12 --- /dev/null +++ b/astryx/components/TokenList.tsx @@ -0,0 +1,3 @@ +// The Astryx CLI pairs each component doc with a same-stem source file. +// ui-common ships no source, so this names the export instead. +export { TokenList } from "@lablup/ui-common"; diff --git a/astryx/components/TokenRow.doc.mjs b/astryx/components/TokenRow.doc.mjs new file mode 100644 index 0000000..7b5c0d1 --- /dev/null +++ b/astryx/components/TokenRow.doc.mjs @@ -0,0 +1,62 @@ +/** + * `astryx component TokenRow` (and `ui-common component TokenRow`). + * + * @type {import('@astryxdesign/cli/authoring').ComponentDoc} + */ +export default { + type: "component", + name: "TokenRow", + displayName: "TokenRow", + import: "@lablup/ui-common", + category: "Data Display", + keywords: ["tokens", "tags", "row", "and more", "overflow", "count"], + description: + 'A row of read-only tokens that stops at maxCount and ends with "and N more". With totalCount, the count reports the whole collection when items is only a fetched page.', + props: [ + { + name: "items", + type: "ReadonlyArray<{ key?: Key; label: string }>", + description: "The tokens, in order.", + required: true, + }, + { + name: "maxCount", + type: "number", + description: "How many tokens render before the count.", + default: "3", + }, + { + name: "totalCount", + type: "number", + description: "Size of the whole collection when items is a page of it.", + default: "items.length", + }, + { + name: "color", + type: "TokenColor", + description: "Colour of every token.", + }, + { + name: "emptyText", + type: "ReactNode", + description: "Rendered instead of the row when there is nothing to show.", + default: "'-'", + }, + { + name: "moreLabel", + type: "(count: number) => string", + description: "The count after the tokens, given how many were left out.", + default: 'the catalog\'s uic.TokenRow.more ("and {count} more")', + }, + ], + usage: { + description: + "A table cell listing a record's aliases or members, where one long record must not stretch the row. Other div attributes reach the row.", + }, + examples: [ + { + label: "Aliases, two shown", + code: " ({ key: a, label: a }))} maxCount={2} />", + }, + ], +}; diff --git a/astryx/components/TokenRow.tsx b/astryx/components/TokenRow.tsx new file mode 100644 index 0000000..c4c68ba --- /dev/null +++ b/astryx/components/TokenRow.tsx @@ -0,0 +1,3 @@ +// The Astryx CLI pairs each component doc with a same-stem source file. +// ui-common ships no source, so this names the export instead. +export { TokenRow } from "@lablup/ui-common"; diff --git a/astryx/docs/ui-common.doc.mjs b/astryx/docs/ui-common.doc.mjs index c0b11c8..f0a9152 100644 --- a/astryx/docs/ui-common.doc.mjs +++ b/astryx/docs/ui-common.doc.mjs @@ -42,7 +42,7 @@ export const docs = { content: [ { type: "prose", - text: "ui-common's own components are built on Astryx primitives and never share a name with an Astryx export. Each is exported from the package root; Modal also has its own subpath.", + text: "ui-common's own components are built on Astryx primitives and never share a name with an Astryx export. Each is exported from the package root; Modal and AlertModal also have their own subpaths.", }, { type: "list", @@ -64,6 +64,10 @@ export const docs = { "`AlertModal`: the alert-dialog pattern on Modal, in its level stack. ui-common hides AlertDialog; use this.", "`DeleteConfirmModal`: confirms a deletion on Modal; typed confirmation for irreversible ones.", "`StepNumberInput`: a number field that steps along a list of values (1, 2, 4, 8); `NumberStepper` is its stepper column.", + "`BoardItemTitle`: a dashboard panel's sticky title row, with a help tooltip and actions at the end.", + "`Statistic`: a metric with a caption, a large value and a notched usage bar. `DividedRow`: a wrapping row with dividers between neighbours on a line.", + '`TokenList`: values inline, the rest behind +N on hover. `TokenRow`: tokens cut off with "and N more".', + "`NotificationItem`: the title, description, actions and footer of one notice.", ], }, { diff --git a/exports.customs.json b/exports.customs.json index 26a6deb..9e1da58 100644 --- a/exports.customs.json +++ b/exports.customs.json @@ -4,16 +4,19 @@ "source": "components/AlertModal/index.ts", "subpath": "AlertModal" }, + { "name": "BoardItemTitle", "source": "components/BoardItemTitle/index.ts" }, { "name": "BooleanToken", "source": "components/BooleanToken/index.ts" }, { "name": "ConfirmPopover", "source": "components/ConfirmPopover/index.ts" }, { "name": "CountBadge", "source": "components/CountBadge/index.ts" }, { "name": "DeleteConfirmModal", "source": "components/DeleteConfirmModal/index.ts" }, { "name": "DigitPopIn", "source": "components/DigitPopIn/index.ts" }, + { "name": "DividedRow", "source": "components/DividedRow/index.ts" }, { "name": "DoubleBadge", "source": "components/DoubleBadge/index.ts" }, { "name": "ErrorState", "source": "components/ErrorState/index.ts" }, { "name": "IconWithTooltip", "source": "components/IconWithTooltip/index.ts" }, { "name": "ImageWithFallback", "source": "components/ImageWithFallback/index.ts" }, { "name": "Modal", "source": "components/Modal/index.ts", "subpath": "Modal" }, + { "name": "NotificationItem", "source": "components/NotificationItem/index.ts" }, { "name": "NotificationStack", "source": "components/NotificationStack/index.ts" }, { "name": "OverlayScrollbar", "source": "components/OverlayScrollbar/index.ts" }, { "name": "PageHeader", "source": "components/PageHeader/index.ts" }, @@ -22,7 +25,10 @@ { "name": "Skeleton composites", "source": "components/Skeleton/index.ts" }, { "name": "SmoothHeight", "source": "components/SmoothHeight/index.ts" }, { "name": "StatCard", "source": "components/StatCard/index.ts" }, + { "name": "Statistic", "source": "components/Statistic/index.ts" }, { "name": "StepNumberInput", "source": "components/StepNumberInput/index.ts" }, + { "name": "TokenList", "source": "components/TokenList/index.ts" }, + { "name": "TokenRow", "source": "components/TokenRow/index.ts" }, { "name": "UncontrolledInput", "source": "components/UncontrolledInput/index.ts" }, { "name": "usePrefersReducedMotion", "source": "hooks/index.ts" } ] diff --git a/src/components/BoardItemTitle/BoardItemTitle.css b/src/components/BoardItemTitle/BoardItemTitle.css new file mode 100644 index 0000000..9b664d8 --- /dev/null +++ b/src/components/BoardItemTitle/BoardItemTitle.css @@ -0,0 +1,26 @@ +/* + * BoardItemTitle: the sticky title row. --uic-board-item-title-z lifts it + * over the panel's own content (sticky table headers and fixed columns); + * a product with a z-index ladder sets it from there. + */ +@layer ui-common { + .uic-board-item-title { + position: sticky; + top: 0; + z-index: var(--uic-board-item-title-z, 50); + flex-shrink: 0; + min-width: 0; + padding-block: var(--spacing-5); + background-color: var(--color-background-surface); + } + + .uic-board-item-title__group { + position: relative; + flex-shrink: 0; + min-width: 0; + } + + .uic-board-item-title__end { + margin-inline-start: auto; + } +} diff --git a/src/components/BoardItemTitle/BoardItemTitle.test.tsx b/src/components/BoardItemTitle/BoardItemTitle.test.tsx new file mode 100644 index 0000000..2231739 --- /dev/null +++ b/src/components/BoardItemTitle/BoardItemTitle.test.tsx @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; + +import { BoardItemTitle } from "./BoardItemTitle"; + +describe("BoardItemTitle", () => { + it("renders a string title as a level-5 heading", () => { + render(); + expect( + screen.getByRole("heading", { level: 5, name: "Active sessions" }), + ).toBeInTheDocument(); + }); + + it("renders a node title as is", () => { + render(Custom} />); + expect(screen.getByTestId("custom")).toBeInTheDocument(); + expect(screen.queryByRole("heading")).not.toBeInTheDocument(); + }); + + it("names a help button after its tooltip only when there is one", () => { + const { rerender } = render(); + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + rerender( + } + />, + ); + expect( + screen.getByRole("button", { name: "Counts running sessions" }), + ).toContainElement(screen.getByTestId("glyph")); + }); + + it("puts the end content in its own group after the title", () => { + const { container } = render( + Refresh} + />, + ); + const root = container.firstElementChild as HTMLElement; + expect(root).toHaveClass("uic-board-item-title"); + const [start, end] = Array.from(root.children); + expect(start).toHaveTextContent("Usage"); + expect(end).toHaveClass("uic-board-item-title__end"); + expect(end).toContainElement(screen.getByRole("button", { name: "Refresh" })); + }); + + it("passes class names, inline styles and attributes to the row", () => { + const { container } = render( + , + ); + const root = container.firstElementChild as HTMLElement; + expect(root).toHaveClass("uic-board-item-title", "extra"); + expect(root.style.paddingBlock).toBe("0px"); + expect(root).toHaveAttribute("data-testid", "row"); + }); +}); diff --git a/src/components/BoardItemTitle/BoardItemTitle.tsx b/src/components/BoardItemTitle/BoardItemTitle.tsx new file mode 100644 index 0000000..3b94b6f --- /dev/null +++ b/src/components/BoardItemTitle/BoardItemTitle.tsx @@ -0,0 +1,81 @@ +/** + * BoardItemTitle + * + * The title row of a dashboard panel: a heading with an optional help + * tooltip, and actions at the end. It sticks to the top of the panel's + * scroll area on the surface colour, so a long panel keeps its title in view. + * The two groups wrap onto separate lines when the panel is narrow. + * + * @example + * } + * /> + */ +import type { HTMLAttributes, ReactNode } from "react"; +import { Heading } from "@astryxdesign/core/Heading"; +import { Icon } from "@astryxdesign/core/Icon"; +import { HStack } from "@astryxdesign/core/Stack"; + +import { IconWithTooltip } from "../IconWithTooltip"; +import "./BoardItemTitle.css"; + +export interface BoardItemTitleProps extends Omit< + HTMLAttributes, + "title" | "children" +> { + /** The title. A string renders as a level-5 heading; a node renders as is. */ + title: ReactNode; + /** Help text in a tooltip beside the title. Without it, no help glyph. */ + tooltip?: ReactNode; + /** Glyph of the help tooltip. @default the theme's `info` icon */ + tooltipIcon?: ReactNode; + /** Actions at the end of the row. */ + endContent?: ReactNode; +} + +export function BoardItemTitle({ + title, + tooltip, + tooltipIcon, + endContent, + className, + ...divProps +}: BoardItemTitleProps) { + return ( + + + {typeof title === "string" ? {title} : title} + {tooltip ? ( + } + content={tooltip} + /> + ) : null} + + + {endContent} + + + ); +} + +BoardItemTitle.displayName = "BoardItemTitle"; diff --git a/src/components/BoardItemTitle/index.ts b/src/components/BoardItemTitle/index.ts new file mode 100644 index 0000000..8419d29 --- /dev/null +++ b/src/components/BoardItemTitle/index.ts @@ -0,0 +1,2 @@ +export { BoardItemTitle } from "./BoardItemTitle"; +export type { BoardItemTitleProps } from "./BoardItemTitle"; diff --git a/src/components/DividedRow/DividedRow.css b/src/components/DividedRow/DividedRow.css new file mode 100644 index 0000000..30bb05d --- /dev/null +++ b/src/components/DividedRow/DividedRow.css @@ -0,0 +1,24 @@ +/* + * DividedRow: the divider overlays the column gap, shifted half a gap past + * its item's end edge, so items keep their width and the line sits centred + * between neighbours. + */ +@layer ui-common { + .uic-divided-row { + display: flex; + align-items: stretch; + } + + .uic-divided-row__item { + position: relative; + display: flex; + align-items: stretch; + } + + .uic-divided-row__divider { + position: absolute; + right: calc(-0.5 * var(--uic-divided-row-column-gap, 0px)); + background: var(--color-border); + pointer-events: none; + } +} diff --git a/src/components/DividedRow/DividedRow.test.tsx b/src/components/DividedRow/DividedRow.test.tsx new file mode 100644 index 0000000..f3cc2d9 --- /dev/null +++ b/src/components/DividedRow/DividedRow.test.tsx @@ -0,0 +1,121 @@ +import { describe, expect, it } from "vitest"; +import { render } from "@testing-library/react"; + +import { DividedRow } from "./DividedRow"; + +const dividers = (container: HTMLElement) => + Array.from(container.querySelectorAll(".uic-divided-row__divider")); + +describe("DividedRow", () => { + it("wraps each child and hides the divider after the last one", () => { + const { container } = render( + + A + B + C + , + ); + const items = container.querySelectorAll(".uic-divided-row__item"); + expect(items).toHaveLength(3); + expect(dividers(container).map((d) => d.style.visibility)).toEqual([ + "visible", + "visible", + "hidden", + ]); + for (const divider of dividers(container)) { + expect(divider).toHaveAttribute("aria-hidden"); + } + }); + + it("hides the divider after an item that ends a line", () => { + const tops = [0, 0, 40]; + const original = Object.getOwnPropertyDescriptor( + HTMLElement.prototype, + "offsetTop", + ); + Object.defineProperty(HTMLElement.prototype, "offsetTop", { + configurable: true, + get(this: HTMLElement) { + const items = Array.from( + this.parentElement?.querySelectorAll(":scope > .uic-divided-row__item") ?? [], + ); + return tops[items.indexOf(this)] ?? 0; + }, + }); + try { + const { container } = render( + + A + B + C + , + ); + expect(dividers(container).map((d) => d.style.visibility)).toEqual([ + "visible", + "hidden", + "hidden", + ]); + } finally { + if (original) Object.defineProperty(HTMLElement.prototype, "offsetTop", original); + } + }); + + it("skips empty children", () => { + const { container } = render( + + A + {null} + {false} + B + , + ); + expect(container.querySelectorAll(".uic-divided-row__item")).toHaveLength(2); + }); + + it("defaults its gaps to spacing tokens and exposes the column gap", () => { + const { container } = render( + + A + , + ); + const row = container.firstElementChild as HTMLElement; + expect(row.style.rowGap).toBe("var(--spacing-8)"); + expect(row.style.columnGap).toBe("var(--spacing-12)"); + expect(row.style.getPropertyValue("--uic-divided-row-column-gap")).toBe( + "var(--spacing-12)", + ); + expect(row.style.flexWrap).toBe("wrap"); + }); + + it("takes gaps in pixels, divider geometry and pass-through styles", () => { + const { container } = render( + + A + B + , + ); + const row = container.firstElementChild as HTMLElement; + expect(row).toHaveClass("uic-divided-row", "extra"); + expect(row.style.flexWrap).toBe("nowrap"); + expect(row.style.rowGap).toBe("8px"); + expect(row.style.columnGap).toBe("24px"); + expect(row.style.paddingBlock).toBe("16px"); + const [first] = dividers(container); + expect(first!.style.width).toBe("2px"); + expect(first!.style.top).toBe("4px"); + expect(first!.style.bottom).toBe("4px"); + expect(first!.style.background).toBe("red"); + const item = container.querySelector(".uic-divided-row__item"); + expect(item!.style.flex).toMatch(/^1/); + }); +}); diff --git a/src/components/DividedRow/DividedRow.tsx b/src/components/DividedRow/DividedRow.tsx new file mode 100644 index 0000000..be56877 --- /dev/null +++ b/src/components/DividedRow/DividedRow.tsx @@ -0,0 +1,157 @@ +/** + * DividedRow + * + * Lays its children out in a wrapping row and draws a vertical divider + * between neighbours on the same line only: an item that ends a line has no + * divider after it, so a wrapped row never starts or ends with a stray rule. + * The divider sits in the middle of the column gap and takes no space. + * + * Which items end a line is measured after layout, and again whenever the row + * or an item resizes. + * + * @example + * + * + * + * + */ +import { + Children, + isValidElement, + useLayoutEffect, + useRef, + useState, + type CSSProperties, + type ReactNode, +} from "react"; + +import "./DividedRow.css"; + +export interface DividedRowProps { + children?: ReactNode; + /** Whether items flow onto more lines. @default 'wrap' */ + wrap?: "wrap" | "nowrap"; + /** Gap between lines. A number is pixels. @default var(--spacing-8) (32px) */ + rowGap?: number | string; + /** + * Gap between items on a line; the divider sits in its middle. A number is + * pixels. @default var(--spacing-12) (48px) + */ + columnGap?: number | string; + /** Divider thickness in pixels. @default 1 */ + dividerWidth?: number; + /** Divider colour. @default var(--color-border) */ + dividerColor?: string; + /** How far the divider stops short of the item's top and bottom, in pixels. @default 0 */ + dividerInset?: number; + /** Inline styles on each item's wrapper. */ + itemStyle?: CSSProperties; + className?: string; + style?: CSSProperties; +} + +const toLength = (value: number | string) => + typeof value === "number" ? `${value}px` : value; + +export function DividedRow({ + children, + wrap = "wrap", + rowGap = "var(--spacing-8)", + columnGap = "var(--spacing-12)", + dividerWidth = 1, + dividerColor, + dividerInset = 0, + itemStyle, + className, + style, +}: DividedRowProps) { + const items = Children.toArray(children).filter(Boolean); + const containerRef = useRef(null); + const itemRefs = useRef>([]); + const [lineEnds, setLineEnds] = useState([]); + + const measureRef = useRef(() => {}); + measureRef.current = () => { + const nodes = itemRefs.current + .slice(0, items.length) + .filter((node): node is HTMLDivElement => node !== null); + const next = nodes.map( + (node, i) => i === nodes.length - 1 || nodes[i + 1]!.offsetTop > node.offsetTop, + ); + setLineEnds((prev) => + prev.length === next.length && prev.every((v, i) => v === next[i]) ? prev : next, + ); + }; + + useLayoutEffect(() => { + itemRefs.current.length = items.length; + measureRef.current(); + }, [items.length]); + + useLayoutEffect(() => { + const root = containerRef.current; + if (!root) return; + const measure = () => measureRef.current(); + const rowObserver = new ResizeObserver(measure); + rowObserver.observe(root); + const itemObserver = new ResizeObserver(measure); + itemRefs.current.forEach((node) => node && itemObserver.observe(node)); + const mutationObserver = new MutationObserver(measure); + mutationObserver.observe(root, { + childList: true, + subtree: true, + attributes: true, + }); + window.addEventListener("resize", measure); + measure(); + return () => { + rowObserver.disconnect(); + itemObserver.disconnect(); + mutationObserver.disconnect(); + window.removeEventListener("resize", measure); + }; + }, []); + + return ( +
+ {items.map((child, i) => ( +
{ + itemRefs.current[i] = node; + }} + className="uic-divided-row__item" + style={itemStyle} + > + {child} + +
+ ))} +
+ ); +} + +DividedRow.displayName = "DividedRow"; diff --git a/src/components/DividedRow/index.ts b/src/components/DividedRow/index.ts new file mode 100644 index 0000000..197c0d9 --- /dev/null +++ b/src/components/DividedRow/index.ts @@ -0,0 +1,2 @@ +export { DividedRow } from "./DividedRow"; +export type { DividedRowProps } from "./DividedRow"; diff --git a/src/components/NotificationItem/NotificationItem.css b/src/components/NotificationItem/NotificationItem.css new file mode 100644 index 0000000..31f756e --- /dev/null +++ b/src/components/NotificationItem/NotificationItem.css @@ -0,0 +1,23 @@ +/* + * NotificationItem: the title keeps 22px clear at its end, the width of the + * notice's close button. + */ +@layer ui-common { + .uic-notification-item__stack, + .uic-notification-item__action { + position: relative; + flex-shrink: 0; + min-width: 0; + } + + .uic-notification-item__title { + margin-inline-end: 22px; + margin-bottom: var(--spacing-3); + font-weight: 500; + } + + .uic-notification-item__footer { + align-self: flex-end; + color: var(--color-text-secondary); + } +} diff --git a/src/components/NotificationItem/NotificationItem.test.tsx b/src/components/NotificationItem/NotificationItem.test.tsx new file mode 100644 index 0000000..f455c67 --- /dev/null +++ b/src/components/NotificationItem/NotificationItem.test.tsx @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; + +import { NotificationItem } from "./NotificationItem"; + +describe("NotificationItem", () => { + it("renders every slot in order", () => { + const { container } = render( + Open} + footer="2 minutes ago" + />, + ); + const text = container.textContent ?? ""; + expect(text.indexOf("Upload finished")).toBeLessThan(text.indexOf("report.csv")); + expect(text.indexOf("report.csv")).toBeLessThan(text.indexOf("Open")); + expect(text.indexOf("Open")).toBeLessThan(text.indexOf("2 minutes ago")); + expect( + screen.getByText("Upload finished").closest(".uic-notification-item__title"), + ).not.toBeNull(); + expect( + screen + .getByRole("button", { name: "Open" }) + .closest(".uic-notification-item__action"), + ).not.toBeNull(); + expect( + screen.getByText("2 minutes ago").closest(".uic-notification-item__footer"), + ).not.toBeNull(); + }); + + it("leaves out empty slots", () => { + const { container } = render(); + expect(container.querySelector(".uic-notification-item__title")).toBeNull(); + expect(container.querySelector(".uic-notification-item__action")).toBeNull(); + expect(container.querySelector(".uic-notification-item__footer")).toBeNull(); + expect(screen.getByText("Only this")).toBeInTheDocument(); + }); + + it("wraps strings and numbers in Text and renders nodes as is", () => { + render( + bold} + />, + ); + expect(screen.getByText("42").closest(".astryx-text")).not.toBeNull(); + expect(screen.getByTestId("node").closest(".astryx-text")).toBeNull(); + }); + + it("adds a class name to the root", () => { + const { container } = render(); + expect(container.firstElementChild).toHaveClass("uic-notification-item", "extra"); + }); +}); diff --git a/src/components/NotificationItem/NotificationItem.tsx b/src/components/NotificationItem/NotificationItem.tsx new file mode 100644 index 0000000..377a7ba --- /dev/null +++ b/src/components/NotificationItem/NotificationItem.tsx @@ -0,0 +1,72 @@ +/** + * NotificationItem + * + * The body of one notice: a title, a description, actions and a footer (a + * timestamp, say), stacked, with the actions and the footer at the end. A + * string or number in a slot renders as body `Text`; a node renders as is. + * The title leaves room at the end for the notice's close button. + * + * @example + * } + * footer="2 minutes ago" + * /> + */ +import type { ReactNode } from "react"; +import { HStack, VStack } from "@astryxdesign/core/Stack"; +import { Text } from "@astryxdesign/core/Text"; + +import "./NotificationItem.css"; + +export interface NotificationItemProps { + title?: ReactNode; + description?: ReactNode; + /** Actions, at the end of their own row. */ + action?: ReactNode; + /** A quiet note at the end, such as when the notice arrived. */ + footer?: ReactNode; + className?: string; +} + +const asText = (content: ReactNode) => + typeof content === "string" || + typeof content === "number" || + typeof content === "bigint" ? ( + {content} + ) : ( + content + ); + +export function NotificationItem({ + title, + description, + action, + footer, + className, +}: NotificationItemProps) { + return ( +
+ + {title &&
{asText(title)}
} + {description &&
{asText(description)}
} + {action && ( + + {action} + + )} + {footer && ( +
{asText(footer)}
+ )} +
+
+ ); +} + +NotificationItem.displayName = "NotificationItem"; diff --git a/src/components/NotificationItem/index.ts b/src/components/NotificationItem/index.ts new file mode 100644 index 0000000..02b36cc --- /dev/null +++ b/src/components/NotificationItem/index.ts @@ -0,0 +1,2 @@ +export { NotificationItem } from "./NotificationItem"; +export type { NotificationItemProps } from "./NotificationItem"; diff --git a/src/components/Statistic/Statistic.css b/src/components/Statistic/Statistic.css new file mode 100644 index 0000000..a2a8f6e --- /dev/null +++ b/src/components/Statistic/Statistic.css @@ -0,0 +1,48 @@ +/* + * Statistic: the value is set at 32px, above Text's largest body step, on a + * 1em leading so the unit sits on its baseline. The bar is a strip of 3x10px + * notches: ProgressBar is a continuous track with no steps. + */ +@layer ui-common { + .uic-statistic, + .uic-statistic__row { + position: relative; + flex-shrink: 0; + min-width: 0; + } + + .uic-statistic__label { + margin-bottom: var(--spacing-4); + line-height: 1em; + } + + .uic-statistic__row { + margin-bottom: var(--spacing-2); + } + + .uic-statistic__value { + font-size: 32px; + line-height: 1em; + } + + .uic-statistic__steps { + display: flex; + align-items: center; + gap: var(--spacing-0-5); + } + + .uic-statistic__step { + width: 3px; + height: 10px; + flex-shrink: 0; + background-color: var(--color-track); + } + + .uic-statistic__step--filled { + background-color: var(--color-text-disabled); + } + + .uic-statistic__step--placeholder { + background-color: transparent; + } +} diff --git a/src/components/Statistic/Statistic.messages.ts b/src/components/Statistic/Statistic.messages.ts new file mode 100644 index 0000000..6b63d80 --- /dev/null +++ b/src/components/Statistic/Statistic.messages.ts @@ -0,0 +1,8 @@ +import { defineMessages } from "../../i18n/catalog"; + +export const statisticMessages = defineMessages({ + "uic.Statistic.unlimited": { + defaultMessage: "Unlimited", + description: "Shown in place of a metric's value when the value has no limit", + }, +}); diff --git a/src/components/Statistic/Statistic.test.tsx b/src/components/Statistic/Statistic.test.tsx new file mode 100644 index 0000000..419f2df --- /dev/null +++ b/src/components/Statistic/Statistic.test.tsx @@ -0,0 +1,139 @@ +import { describe, expect, it } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { InternationalizationProvider } from "@astryxdesign/core/i18n"; + +import { uiCommonMessages } from "../../i18n/messages"; +import { Statistic } from "./Statistic"; + +const bar = (container: HTMLElement) => + container.querySelector('[role="progressbar"]'); + +describe("Statistic", () => { + it("renders the label, the value and its unit", () => { + render(); + expect(screen.getByText("Memory")).toBeInTheDocument(); + expect(screen.getByText("512")).toBeInTheDocument(); + expect(screen.getByText("MB")).toBeInTheDocument(); + }); + + it("renders a node label", () => { + render(CPU} value={1} />); + expect(screen.getByTestId("label")).toBeInTheDocument(); + }); + + it.each([ + [3.14159, undefined, "3.14"], + [3.14159, 3, "3.142"], + [5.0, 2, "5"], + [3.14159, 0, "3"], + [1234567.89, 2, "1234567.89"], + [0.00123, 5, "0.00123"], + [-50, 2, "-50"], + [0, 2, "0"], + ])("formats %s at precision %s as %s", (value, precision, text) => { + render(); + expect(screen.getByText(text)).toBeInTheDocument(); + }); + + it.each([Infinity, -Infinity, NaN])("shows Unlimited for %s", (value) => { + render(); + expect(screen.getByText("Unlimited")).toBeInTheDocument(); + expect(screen.queryByText("GB")).not.toBeInTheDocument(); + }); + + it("takes Unlimited from the shipped translations, or from a prop", () => { + const { rerender } = render( + + + , + ); + expect(screen.getByText("제한없음")).toBeInTheDocument(); + rerender(); + expect(screen.getByText("No cap")).toBeInTheDocument(); + }); + + it("draws no bar unless asked, and none without a total", () => { + const { container, rerender } = render( + , + ); + expect(bar(container)).toBeNull(); + rerender(); + expect(bar(container)).toBeNull(); + }); + + it("draws the bar as notches, filled by value over total", () => { + const { container } = render( + , + ); + expect(bar(container)).toHaveAttribute("aria-valuenow", "50"); + expect(bar(container)).toHaveAccessibleName("CPU"); + const steps = container.querySelectorAll(".uic-statistic__step"); + expect(steps).toHaveLength(10); + expect(container.querySelectorAll(".uic-statistic__step--filled")).toHaveLength(5); + expect(steps[0]!.style.backgroundColor).toBe("rgb(255, 0, 0)"); + expect(steps[9]!.style.backgroundColor).toBe(""); + expect(screen.getByText("4")).toHaveStyle({ color: "rgb(255, 0, 0)" }); + }); + + it.each([ + [8, 8, "100"], + [10, 8, "125"], + [0, 8, "0"], + [5, 0, "100"], + [0, 0, "0"], + [undefined, 0, "100"], + [undefined, 8, "100"], + [5, Infinity, "0"], + [Infinity, 8, "100"], + [3, 7.5, "40"], + [2.5, 10, "25"], + ])("reports %s of %s as %s%%", (value, total, percent) => { + const { container } = render( + , + ); + expect(bar(container)).toHaveAttribute("aria-valuenow", percent); + }); + + it("keeps a placeholder bar's space without painting it", () => { + const { container } = render( + , + ); + expect(bar(container)).toHaveAttribute("aria-valuenow", "0"); + expect(container.querySelector(".uic-statistic__step")).toHaveClass( + "uic-statistic__step--placeholder", + ); + }); + + it("shows value over total in the bar's tooltip", async () => { + const { container } = render( + , + ); + const strip = container.querySelector(".uic-statistic__steps")!; + fireEvent.pointerEnter(strip.parentElement ?? strip); + fireEvent.mouseOver(strip); + expect(await screen.findByText("4 GB / 8 GB")).toBeInTheDocument(); + }); + + it("passes class names, inline styles and attributes to the root", () => { + const { container } = render( + , + ); + const root = container.firstElementChild as HTMLElement; + expect(root).toHaveClass("uic-statistic", "extra"); + expect(root.style.backgroundColor).toBe("red"); + expect(root).toHaveAttribute("data-testid", "stat"); + }); +}); diff --git a/src/components/Statistic/Statistic.tsx b/src/components/Statistic/Statistic.tsx new file mode 100644 index 0000000..c8452f5 --- /dev/null +++ b/src/components/Statistic/Statistic.tsx @@ -0,0 +1,173 @@ +/** + * Statistic + * + * A dashboard metric: a caption, a large value with its unit, and an optional + * segmented usage bar of `value` against `total`. The bar is a strip of + * notches, so 3 of 20 reads differently from 4 of 20 at a glance; it carries + * `role="progressbar"` and shows "value / total" in a tooltip. + * + * A value that is not finite shows `unlimitedLabel` instead of a number. + * + * @example + * + */ +import type { CSSProperties, HTMLAttributes, ReactNode } from "react"; +import { HStack, VStack } from "@astryxdesign/core/Stack"; +import { Text } from "@astryxdesign/core/Text"; +import { Tooltip } from "@astryxdesign/core/Tooltip"; + +import { useUicTranslator } from "../../i18n/useUicTranslator"; +import "./Statistic.css"; + +export interface StatisticProps extends Omit, "color"> { + /** The caption. */ + label: ReactNode; + /** The value. Not finite renders `unlimitedLabel`. */ + value?: number; + /** What the value is measured against; the bar needs it. */ + total?: number; + /** Unit after the value. */ + unit?: string; + /** Decimal places, trailing zeros dropped. @default 2 */ + precision?: number; + /** A non-finite value or total in the bar's tooltip. @default '∞' */ + infinityLabel?: string; + /** + * The usage bar: `visible` draws it (with a `total`), `placeholder` keeps + * its space without painting it, `hidden` leaves it out. @default 'hidden' + */ + progressMode?: "hidden" | "placeholder" | "visible"; + /** Notches in the bar. @default 20 */ + progressSteps?: number; + /** Colour of the value and of the bar's filled notches. */ + color?: string; + /** Shown for a non-finite value. @default the catalog's uic.Statistic.unlimited ("Unlimited") */ + unlimitedLabel?: string; +} + +function StepBar({ + steps, + percent, + isPlaceholder, + color, + label, +}: { + steps: number; + percent: number; + isPlaceholder?: boolean; + color?: string; + label: string; +}) { + const filled = Math.round((percent / 100) * steps); + return ( +
+ {Array.from({ length: steps }, (_, index) => ( + = filled ? undefined : { backgroundColor: color } + } + /> + ))} +
+ ); +} + +export function Statistic({ + label, + value, + total, + unit = "", + precision = 2, + infinityLabel = "∞", + progressMode = "hidden", + progressSteps = 20, + color, + unlimitedLabel, + className, + ...divProps +}: StatisticProps) { + const t = useUicTranslator(); + const format = (n: number) => + Number.isFinite(n) ? parseFloat(n.toFixed(precision)).toString() : infinityLabel; + + const displayValue = value === undefined ? undefined : format(value); + const displayTotal = total === undefined ? undefined : format(total); + + const percent = (() => { + if (progressMode === "hidden" || total === undefined || total === Infinity) + return 0; + // Nothing allocated out of a zero quota is empty; anything else against + // it, a non-finite value included, is full. + if (total === 0) return value === 0 ? 0 : 100; + if (value === undefined || !Number.isFinite(value) || !Number.isFinite(total)) + return 100; + return Math.round((value / total) * 100); + })(); + const accessibleLabel = typeof label === "string" ? label : "usage"; + const colorStyle: CSSProperties | undefined = color ? { color } : undefined; + + return ( + + + {label} + + + {value !== undefined && !Number.isFinite(value) ? ( + + {unlimitedLabel ?? t("uic.Statistic.unlimited")} + + ) : ( + <> + + {displayValue} + + {unit && {unit}} + + )} + + {progressMode === "visible" && total !== undefined ? ( + + + + ) : progressMode === "placeholder" ? ( + + ) : null} + + ); +} + +Statistic.displayName = "Statistic"; diff --git a/src/components/Statistic/index.ts b/src/components/Statistic/index.ts new file mode 100644 index 0000000..6e9a2a6 --- /dev/null +++ b/src/components/Statistic/index.ts @@ -0,0 +1,2 @@ +export { Statistic } from "./Statistic"; +export type { StatisticProps } from "./Statistic"; diff --git a/src/components/TokenList/TokenList.css b/src/components/TokenList/TokenList.css new file mode 100644 index 0000000..1130c68 --- /dev/null +++ b/src/components/TokenList/TokenList.css @@ -0,0 +1,28 @@ +/* + * TokenList: the inline run sits in the line of text around it, and the + * overflow list scrolls past about ten rows. + */ +@layer ui-common { + .uic-token-list { + display: inline-flex; + position: relative; + flex-shrink: 0; + min-width: 0; + } + + .uic-token-list__text { + white-space: nowrap; + } + + .uic-token-list__count { + cursor: help; + } + + .uic-token-list__rest { + position: relative; + flex-shrink: 0; + min-width: 0; + max-height: 240px; + overflow-y: auto; + } +} diff --git a/src/components/TokenList/TokenList.test.tsx b/src/components/TokenList/TokenList.test.tsx new file mode 100644 index 0000000..c247373 --- /dev/null +++ b/src/components/TokenList/TokenList.test.tsx @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; + +import { TokenList } from "./TokenList"; + +// jsdom implements no Popover API, so "is it open" is not observable. Which +// control the trigger is wired as is: a Popover trigger carries +// aria-haspopup / aria-expanded, a HoverCard trigger does not. +const ITEMS = ["a@example.com", "b@example.com", "c@example.com", "d@example.com"]; + +const overflow = () => screen.getByText("+1"); + +// Badge and Link wrap their label in an inner span; resolve up to the +// element that carries the trigger's role and focusability. +const trigger = () => + overflow().closest('button, [role="button"], [tabindex]') ?? overflow(); + +describe("TokenList", () => { + it("renders inline items as Tokens and the rest behind +N", () => { + const { container } = render(); + const tokens = container.querySelectorAll(".astryx-token"); + expect(Array.from(tokens).map((el) => el.textContent)).toEqual(["a", "2", "c"]); + expect(overflow()).toBeInTheDocument(); + }); + + it("shows no overflow while every item fits", () => { + render(); + expect(screen.queryByText(/^\+/)).not.toBeInTheDocument(); + }); + + it("renders the text variant as plain text with a +N badge", () => { + const { container } = render( + , + ); + expect(container.querySelectorAll(".astryx-token")).toHaveLength(0); + expect(screen.getByText("a@example.com")).toHaveClass("uic-token-list__text"); + expect(screen.getByText("+2")).toBeInTheDocument(); + }); + + it("falls back to emptyText", () => { + const { container, rerender } = render(); + expect(container).toHaveTextContent("-"); + rerender(); + expect(container).toHaveTextContent("none"); + }); + + it("opens the overflow on hover by default, in both variants", () => { + const { unmount } = render(); + expect(trigger()).not.toHaveAttribute("aria-haspopup"); + expect(trigger()).not.toHaveAttribute("aria-expanded"); + unmount(); + render(); + expect(trigger()).not.toHaveAttribute("aria-haspopup"); + }); + + it('latches open as a popover for trigger="click", on a button', () => { + render(); + expect(trigger()).toHaveAttribute("aria-haspopup"); + expect(trigger()).toHaveAttribute("aria-expanded", "false"); + expect(overflow().closest("button")).toBeInTheDocument(); + }); + + it.each([ + ["token", undefined], + ["text", "text"], + ] as const)("keeps the %s hover trigger focusable", (_name, variant) => { + render(); + const el = trigger(); + expect(el.tagName === "BUTTON" || el.tabIndex >= 0).toBe(true); + }); + + it("does not put the overflow list in a Tooltip", () => { + render(); + expect(document.querySelector('[role="tooltip"]')).toBeNull(); + expect(document.querySelectorAll("[popover]")).toHaveLength(0); + }); +}); diff --git a/src/components/TokenList/TokenList.tsx b/src/components/TokenList/TokenList.tsx new file mode 100644 index 0000000..028f3f3 --- /dev/null +++ b/src/components/TokenList/TokenList.tsx @@ -0,0 +1,129 @@ +/** + * TokenList + * + * A bounded list of settled values: the first `maxInline` items inline, then + * a `+N` that shows the rest. The overflow opens on hover and focus, like a + * tooltip, on a card surface (`HoverCard`) because it is a list of values, + * not a hint. `trigger="click"` latches it open as a `Popover` instead. + * + * - `variant="token"` (default): the items are `Token`s and `+N` is a `Link`. + * Suits dialogs and forms. + * - `variant="text"`: the items are plain, unwrapped text and `+N` is a + * compact `Badge`. Suits dense table cells. + * + * The overflow lists only the items that did not fit. + * + * @example + * + */ +import type { ReactNode } from "react"; +import { Badge } from "@astryxdesign/core/Badge"; +import { HoverCard } from "@astryxdesign/core/HoverCard"; +import { Link } from "@astryxdesign/core/Link"; +import { Popover } from "@astryxdesign/core/Popover"; +import { HStack, VStack } from "@astryxdesign/core/Stack"; +import { Text } from "@astryxdesign/core/Text"; +import { Token } from "@astryxdesign/core/Token"; + +import "./TokenList.css"; + +export type TokenListItem = string | number; + +export interface TokenListProps { + /** The values. */ + items: ReadonlyArray; + /** How many items show inline before `+N`. @default 3 */ + maxInline?: number; + /** Rendered instead of the list when `items` is empty. @default '-' */ + emptyText?: ReactNode; + /** `token` for Tokens and a `+N` link, `text` for plain text and a `+N` badge. @default 'token' */ + variant?: "token" | "text"; + /** How the overflow opens. @default 'hover' */ + trigger?: "hover" | "click"; +} + +export function TokenList({ + items, + maxInline = 3, + emptyText = "-", + variant = "token", + trigger = "hover", +}: TokenListProps) { + if (items.length === 0) return <>{emptyText}; + + const inlineItems = items.slice(0, maxInline); + const restItems = items.slice(maxInline); + const restCount = restItems.length; + + const restList = ( + + {restItems.map((item, index) => ( + {item} + ))} + + ); + + const overflowControl = + trigger === "hover" ? ( + // touchTrigger="tap": the token variant's trigger is a button with no + // action of its own, so the default would leave touch users no way in. + + {variant === "text" ? ( + // A Badge is a bare ; HoverCard only attaches focus to a + // focusable trigger. + + ) : ( + +{restCount} + )} + + ) : ( + // Popover wires its handlers onto a + + ); +} + +ColorPicker.displayName = "ColorPicker"; diff --git a/src/components/ColorPicker/index.ts b/src/components/ColorPicker/index.ts new file mode 100644 index 0000000..48ca4bb --- /dev/null +++ b/src/components/ColorPicker/index.ts @@ -0,0 +1,2 @@ +export { ColorPicker, toHexColor } from "./ColorPicker"; +export type { ColorPickerProps } from "./ColorPicker"; diff --git a/src/components/UnitGrid/UnitGrid.css b/src/components/UnitGrid/UnitGrid.css new file mode 100644 index 0000000..1121252 --- /dev/null +++ b/src/components/UnitGrid/UnitGrid.css @@ -0,0 +1,66 @@ +/* + * UnitGrid. The seven group hues and the two letter inks are --uic- custom + * properties on the root, so a product can hand in its own palette from a + * class of its own. Hue overrides are stored by palette index: keep index N + * meaning the same hue in light and dark. + * + * The inks contrast the cell fill (chosen per cell by luminance), not the + * page, so they default to the mode-invariant --color-on-light/--color-on-dark. + */ +@layer ui-common { + .uic-unit-grid { + --uic-unit-grid-group-1: var(--color-icon-purple); + --uic-unit-grid-group-2: var(--color-icon-blue); + --uic-unit-grid-group-3: var(--color-icon-green); + --uic-unit-grid-group-4: var(--color-icon-cyan); + --uic-unit-grid-group-5: var(--color-icon-orange); + --uic-unit-grid-group-6: var(--color-icon-red); + --uic-unit-grid-group-7: var(--color-icon-gray); + --uic-unit-grid-cell-stroke: var(--color-border); + /* Translucent, so a partial cell stays readable when the unit's own fill + is the same muted colour. */ + --uic-unit-grid-cell-empty: color-mix( + in srgb, + var(--color-background-muted) 60%, + transparent + ); + --uic-unit-grid-ink-dark: var(--color-on-light); + --uic-unit-grid-ink-light: var(--color-on-dark); + } + + .uic-unit-grid__wrapper { + position: relative; + } + + .uic-unit-grid__svg { + display: block; + } + + .uic-unit-grid__svg--clickable { + cursor: pointer; + } + + .uic-unit-grid__swatch { + cursor: pointer; + flex-shrink: 0; + } + + /* + * Fixed, so it escapes a host card's overflow clipping; the component sets + * the coordinates and the border colour inline. --uic-unit-grid-popover-z + * places it on a product's z-index ladder. The width bounds are a readable + * measure, which Astryx has no token for. + */ + .uic-unit-grid__popover { + position: fixed; + z-index: var(--uic-unit-grid-popover-z, 1000); + min-width: 200px; + max-width: 320px; + padding: var(--spacing-2); + border-width: var(--border-width); + border-style: solid; + border-radius: var(--radius-element); + background: var(--color-background-popover); + box-shadow: var(--shadow-med); + } +} diff --git a/src/components/UnitGrid/UnitGrid.geometry.test.ts b/src/components/UnitGrid/UnitGrid.geometry.test.ts new file mode 100644 index 0000000..019b425 --- /dev/null +++ b/src/components/UnitGrid/UnitGrid.geometry.test.ts @@ -0,0 +1,321 @@ +import { describe, expect, it } from "vitest"; + +import { + chainLoops, + deriveMetrics, + extractSegments, + gridSize, + groupConsecutiveSegments, + latticeColsForWidth, + letterCellIndices, + packGroups, + placeCells, + platePath, + roundedLoopPath, + type BoundarySeg, + type UnitGridLayout, + type UnitGridPlacedCell, +} from "./UnitGrid.geometry"; + +const m = deriveMetrics(); + +const makeGroups = (sizes: Record) => + Object.entries(sizes).map(([key, n]) => ({ + key, + units: Array.from({ length: n }, (_, i) => ({ i })), + })); + +const place = (sizes: Record, layout: UnitGridLayout, cols: number) => + placeCells(packGroups(makeGroups(sizes), 256), layout, cols, m); + +const colOf = (cell: { px: number }) => Math.round((cell.px - m.pad) / m.stridePx); + +/** Position along the packing path (row-major, odd rows reversed if serpentine). */ +const flowSlot = ( + cell: { px: number; row: number }, + layout: UnitGridLayout, + cols: number, +) => { + const col = colOf(cell); + const slotInRow = + layout === "serpentine" && cell.row % 2 === 1 ? cols - 1 - col : col; + return cell.row * cols + slotInRow; +}; + +const byGroup = (placed: UnitGridPlacedCell[]) => { + const map = new Map[]>(); + placed.forEach((c) => { + const list = map.get(c.groupKey) ?? []; + list.push(c); + map.set(c.groupKey, list); + }); + return map; +}; + +describe("deriveMetrics", () => { + it("preserves the tuned prototype constants", () => { + expect(m).toEqual({ + cellPx: 16, + gapPx: 2, + radiusPx: 4, + platePadX: 5, + platePadY: 4, + rowGapPx: 14, + pad: 7, + stridePx: 18, + }); + }); + + it("derives lattice columns from width with a floor of 6", () => { + expect(latticeColsForWidth(0, m)).toBe(6); + // width = pad*2 + cols*stride - gap → exactly 10 columns + expect(latticeColsForWidth(m.pad * 2 + 10 * m.stridePx - m.gapPx, m)).toBe(10); + }); +}); + +describe("packGroups", () => { + it("flattens groups in order, marking each first cell", () => { + const packed = packGroups(makeGroups({ a: 2, b: 1 }), 256); + expect(packed.map((c) => c.groupKey)).toEqual(["a", "a", "b"]); + expect(packed.map((c) => c.first)).toEqual([true, false, true]); + }); + + it("truncates each group at maxUnitsPerGroup", () => { + const packed = packGroups(makeGroups({ a: 300, b: 5 }), 256); + expect(packed.filter((c) => c.groupKey === "a")).toHaveLength(256); + expect(packed.filter((c) => c.groupKey === "b")).toHaveLength(5); + }); + + it("yields no cells for empty groups and empty input", () => { + expect(packGroups(makeGroups({ a: 0 }), 256)).toEqual([]); + expect(packGroups([], 256)).toEqual([]); + }); + + it("preserves unit payloads (fractions ride along)", () => { + const packed = packGroups( + [{ key: "a", units: [{ fraction: 1 }, { fraction: 0.5 }] }], + 256, + ); + expect(packed[1]!.unit.fraction).toBe(0.5); + }); +}); + +describe("placeCells — serpentine", () => { + const cols = 6; + const placed = place({ a: 5, b: 7, c: 3 }, "serpentine", cols); + + it("keeps every group contiguous along the packing path (zero interior gaps)", () => { + byGroup(placed).forEach((cells) => { + for (let i = 1; i < cells.length; i++) { + expect(flowSlot(cells[i]!, "serpentine", cols)).toBe( + flowSlot(cells[i - 1]!, "serpentine", cols) + 1, + ); + } + }); + }); + + it("keeps every group one 4-connected region across row wraps", () => { + byGroup(placed).forEach((cells) => { + for (let i = 1; i < cells.length; i++) { + const dRow = cells[i]!.row - cells[i - 1]!.row; + const dCol = Math.abs(colOf(cells[i]!) - colOf(cells[i - 1]!)); + // Same row: adjacent column. Next row: directly below (serpentine). + expect((dRow === 0 && dCol === 1) || (dRow === 1 && dCol === 0)).toBe(true); + } + }); + }); + + it("separates adjacent groups by exactly one seam slot", () => { + const groups = ["a", "b", "c"].map( + (k) => byGroup(placed).get(k) as UnitGridPlacedCell[], + ); + for (let i = 1; i < groups.length; i++) { + const prevLast = flowSlot(groups[i - 1]!.at(-1)!, "serpentine", cols); + const nextFirst = flowSlot(groups[i]![0]!, "serpentine", cols); + expect(nextFirst).toBe(prevLast + 2); + } + }); + + it("preserves flow order and reverses odd rows", () => { + for (let i = 1; i < placed.length; i++) { + expect(flowSlot(placed[i]!, "serpentine", cols)).toBeGreaterThan( + flowSlot(placed[i - 1]!, "serpentine", cols), + ); + if (placed[i]!.row === placed[i - 1]!.row) { + const dx = placed[i]!.px - placed[i - 1]!.px; + // Odd rows run right→left on screen. + expect(placed[i]!.row % 2 === 1 ? dx < 0 : dx > 0).toBe(true); + } + } + }); +}); + +describe("placeCells — wordwrap", () => { + const cols = 6; + + it("always runs rows left→right", () => { + const placed = place({ a: 4, b: 4, c: 9 }, "wordwrap", cols); + for (let i = 1; i < placed.length; i++) { + if (placed[i]!.row === placed[i - 1]!.row) { + expect(placed[i]!.px).toBeGreaterThan(placed[i - 1]!.px); + } + } + }); + + it("starts a group on the next row when it fits a row but not the remainder", () => { + const placed = place({ a: 4, b: 4 }, "wordwrap", cols); + const b = byGroup(placed).get("b")!; + expect(b[0]!.row).toBe(1); + expect(colOf(b[0]!)).toBe(0); + // The gap is at the END of row 0: `a` keeps columns 0..3. + const a = byGroup(placed).get("a")!; + expect(a.every((c) => c.row === 0)).toBe(true); + expect(a.map(colOf)).toEqual([0, 1, 2, 3]); + }); + + it("never splits a group that fits in one row", () => { + const placed = place({ a: 3, b: 5, c: 6, d: 2 }, "wordwrap", cols); + byGroup(placed).forEach((cells) => { + if (cells.length <= cols) { + expect(new Set(cells.map((c) => c.row)).size).toBe(1); + } + }); + }); + + it("keeps larger-than-a-row groups connected via full middle rows", () => { + const placed = place({ a: 2, b: 14 }, "wordwrap", cols); + const b = byGroup(placed).get("b")!; + // Middle rows of a multi-row group span all columns. + const rows = Array.from(new Set(b.map((c) => c.row))); + rows.slice(1, -1).forEach((row) => { + expect(b.filter((c) => c.row === row)).toHaveLength(cols); + }); + for (let i = 1; i < b.length; i++) { + const sameRow = b[i]!.row === b[i - 1]!.row; + expect(sameRow || b[i]!.row === b[i - 1]!.row + 1).toBe(true); + } + }); +}); + +describe("placeCells — degenerate cases", () => { + it("handles zero groups", () => { + const placed = place({}, "serpentine", 6); + expect(placed).toEqual([]); + expect(gridSize(placed, 6, m).rowCount).toBe(1); + }); + + it("handles a single unit", () => { + const placed = place({ a: 1 }, "serpentine", 6); + expect(placed).toHaveLength(1); + expect(placed[0]).toMatchObject({ px: m.pad, py: m.pad, row: 0 }); + }); + + it("handles an exactly-full row in both layouts", () => { + (["serpentine", "wordwrap"] as const).forEach((layout) => { + const placed = place({ a: 6, b: 2 }, layout, 6); + const a = byGroup(placed).get("a")!; + const b = byGroup(placed).get("b")!; + expect(a.every((c) => c.row === 0)).toBe(true); + expect(b.every((c) => c.row === 1)).toBe(true); + }); + }); +}); + +describe("segments and plates", () => { + const cols = 6; + + it("emits one segment per (group, row) with min/max extents", () => { + // `a` fills slots 0–4 of row 0; the seam pushes `b` onto rows 1–2. + const placed = place({ a: 5, b: 7 }, "serpentine", cols); + const segments = extractSegments(placed, m); + const bSegs = segments.filter((s) => s.groupKey === "b"); + expect(bSegs.map((s) => s.row)).toEqual([1, 2]); + bSegs.forEach((s) => expect(s.x1).toBeGreaterThan(s.x0)); + }); + + it("groups consecutive segments per group", () => { + const placed = place({ a: 5, b: 7, c: 3 }, "serpentine", cols); + const groups = groupConsecutiveSegments(extractSegments(placed, m)); + expect(groups.map((g) => g[0]!.groupKey)).toEqual(["a", "b", "c"]); + }); + + it("chains a rectangle boundary into one closed 4-corner loop", () => { + const rect: BoundarySeg[] = [ + [0, 0, 10, 0], + [10, 0, 10, 10], + [10, 10, 0, 10], + [0, 10, 0, 0], + ]; + const loops = chainLoops(rect); + expect(loops).toHaveLength(1); + expect(loops[0]).toHaveLength(4); + }); + + it("merges collinear runs while chaining", () => { + const rect: BoundarySeg[] = [ + [0, 0, 5, 0], + [5, 0, 10, 0], // collinear continuation of the top edge + [10, 0, 10, 10], + [10, 10, 0, 10], + [0, 10, 0, 0], + ]; + const loops = chainLoops(rect); + expect(loops).toHaveLength(1); + expect(loops[0]).toHaveLength(4); + }); + + it("produces one closed rounded path for a multi-row serpentine group", () => { + const placed = place({ a: 2, b: 8 }, "serpentine", cols); + const segGroups = groupConsecutiveSegments(extractSegments(placed, m)); + const d = platePath(segGroups[1]!, m); + // Vertically-adjacent overlapping rows are bridged into ONE region. + expect(d.match(/M/g)).toHaveLength(1); + expect(d.match(/Z/g)).toHaveLength(1); + expect(d).toContain("Q"); + }); + + it("closes every loop it emits", () => { + const placed = place({ a: 3, b: 4, c: 11 }, "wordwrap", cols); + groupConsecutiveSegments(extractSegments(placed, m)).forEach((segs) => { + const d = platePath(segs, m); + expect(d.match(/M/g)?.length).toBe(d.match(/Z/g)?.length); + expect(d.length).toBeGreaterThan(0); + }); + }); + + it("rounds a unit square into a closed quadratic path", () => { + const d = roundedLoopPath( + [ + [0, 0], + [10, 0], + [10, 10], + [0, 10], + ], + 4, + ); + expect(d.startsWith("M")).toBe(true); + expect(d.endsWith("Z")).toBe(true); + expect(d.match(/Q/g)).toHaveLength(4); + }); +}); + +describe("letterCellIndices", () => { + it("picks the visual top-left cell, not the flow-first, on serpentine", () => { + // `b` starts at slot 4 of row 0 and wraps onto reversed row 1. + const placed = place({ a: 3, b: 5 }, "serpentine", 6); + const idx = letterCellIndices(placed); + const b = placed[idx.get("b")!]!; + const bCells = byGroup(placed).get("b")!; + const minRow = Math.min(...bCells.map((c) => c.row)); + const minPx = Math.min(...bCells.filter((c) => c.row === minRow).map((c) => c.px)); + expect(b.row).toBe(minRow); + expect(b.px).toBe(minPx); + }); + + it("is keyed by group key", () => { + const placed = place({ a: 2, b: 2 }, "wordwrap", 6); + const idx = letterCellIndices(placed); + expect(placed[idx.get("a")!]!.groupKey).toBe("a"); + expect(placed[idx.get("b")!]!.groupKey).toBe("b"); + }); +}); diff --git a/src/components/UnitGrid/UnitGrid.geometry.ts b/src/components/UnitGrid/UnitGrid.geometry.ts new file mode 100644 index 0000000..837df49 --- /dev/null +++ b/src/components/UnitGrid/UnitGrid.geometry.ts @@ -0,0 +1,383 @@ +/** + * UnitGrid geometry: lattice packing (serpentine or word-wrap), per-group row + * segments, boundary-loop chaining and rounded rectilinear plate paths. No + * React and no DOM, so it is tested as plain data. + */ + +export type UnitGridLayout = "serpentine" | "wordwrap"; + +export interface UnitGridMetrics { + cellPx: number; + gapPx: number; + radiusPx: number; + /** Inner padding between a group plate's border and its cells. */ + platePadX: number; + platePadY: number; + /** Clear vertical space between plates of adjacent rows. */ + rowGapPx: number; + /** Outer padding of the whole lattice. */ + pad: number; + /** Horizontal distance between adjacent lattice columns. */ + stridePx: number; +} + +export const deriveMetrics = ( + cellPx = 16, + gapPx = 2, + radiusPx = 4, +): UnitGridMetrics => { + const platePadX = gapPx * 2 + 1; + const platePadY = gapPx * 2; + return { + cellPx, + gapPx, + radiusPx, + platePadX, + platePadY, + rowGapPx: platePadY * 2 + gapPx * 3, + pad: platePadX + gapPx, + stridePx: cellPx + gapPx, + }; +}; + +export const latticeColsForWidth = (width: number, m: UnitGridMetrics): number => + Math.max(6, Math.floor((width - m.pad * 2 + m.gapPx) / m.stridePx)); + +export interface UnitGridPackedCell { + groupKey: string; + unit: TUnit; + /** Flow-first cell of its group (packing inserts the seam before it). */ + first: boolean; +} + +/** + * Flatten groups into flow-ordered cells, truncating each group at + * `maxUnitsPerGroup`. Groups with zero units yield no cells. + */ +export const packGroups = ( + groups: ReadonlyArray<{ key: string; units: readonly TUnit[] }>, + maxUnitsPerGroup: number, +): UnitGridPackedCell[] => + groups.flatMap((group) => + group.units.slice(0, Math.max(0, maxUnitsPerGroup)).map((unit, i) => ({ + groupKey: group.key, + unit, + first: i === 0, + })), + ); + +export interface UnitGridPlacedCell extends UnitGridPackedCell { + px: number; + py: number; + row: number; +} + +/** + * Place flow-ordered cells on a FIXED lattice. Serpentine: odd rows run + * right→left so a wrap continuation is directly below the previous cell + * (every group one connected region). Word-wrap: rows always run left→right; + * a group that fits in one row but not in the remaining slots starts the next + * row. In both layouts one skipped lattice slot separates adjacent groups. + */ +export const placeCells = ( + cells: ReadonlyArray>, + layout: UnitGridLayout, + latticeCols: number, + m: UnitGridMetrics, +): UnitGridPlacedCell[] => { + const countByGroup = new Map(); + cells.forEach((c) => { + countByGroup.set(c.groupKey, (countByGroup.get(c.groupKey) ?? 0) + 1); + }); + const placed: UnitGridPlacedCell[] = []; + let slot = 0; + let crow = 0; + cells.forEach((cell) => { + if (cell.first && slot > 0) { + const n = countByGroup.get(cell.groupKey) ?? 0; + if (layout === "wordwrap" && n <= latticeCols && slot + 1 + n > latticeCols) { + crow += 1; + slot = 0; + } else { + slot += 1; // seam slot between groups + } + } + if (slot >= latticeCols) { + crow += 1; + slot = 0; + } + const visualSlot = + layout === "serpentine" && crow % 2 === 1 ? latticeCols - 1 - slot : slot; + placed.push({ + ...cell, + px: m.pad + visualSlot * m.stridePx, + py: m.pad + crow * (m.cellPx + m.rowGapPx), + row: crow, + }); + slot += 1; + }); + return placed; +}; + +export const gridSize = ( + placed: ReadonlyArray<{ row: number }>, + latticeCols: number, + m: UnitGridMetrics, +): { width: number; height: number; rowCount: number } => { + const rowCount = placed.length > 0 ? placed[placed.length - 1]!.row + 1 : 1; + return { + width: m.pad * 2 + latticeCols * m.stridePx - m.gapPx, + height: m.pad * 2 + rowCount * (m.cellPx + m.rowGapPx) - m.rowGapPx, + rowCount, + }; +}; + +export interface UnitGridSegment { + groupKey: string; + row: number; + x0: number; + x1: number; + y: number; +} + +/** One horizontal segment per (group, row) — the plate building block. */ +export const extractSegments = ( + placed: ReadonlyArray<{ + groupKey: string; + row: number; + px: number; + py: number; + }>, + m: UnitGridMetrics, +): UnitGridSegment[] => { + const segments: UnitGridSegment[] = []; + placed.forEach((cell) => { + const seg = segments[segments.length - 1]; + if (seg && seg.groupKey === cell.groupKey && seg.row === cell.row) { + // min/max so serpentine's right→left rows extend segments correctly. + seg.x0 = Math.min(seg.x0, cell.px); + seg.x1 = Math.max(seg.x1, cell.px + m.cellPx); + } else { + segments.push({ + groupKey: cell.groupKey, + row: cell.row, + x0: cell.px, + x1: cell.px + m.cellPx, + y: cell.py, + }); + } + }); + return segments; +}; + +/** + * Consecutive segments of one group (a group's cells are contiguous in flow + * order, so its segments are consecutive too). + */ +export const groupConsecutiveSegments = ( + segments: ReadonlyArray, +): UnitGridSegment[][] => { + const groups: UnitGridSegment[][] = []; + segments.forEach((seg) => { + const group = groups[groups.length - 1]; + if (group && group[0]!.groupKey === seg.groupKey) group.push(seg); + else groups.push([seg]); + }); + return groups; +}; + +export type BoundarySeg = [number, number, number, number]; + +/** + * Chain axis-aligned boundary segments into closed loops (each vertex has + * exactly two incident segments by construction), merging collinear runs. + */ +export const chainLoops = ( + segs: ReadonlyArray, +): Array> => { + const key = (x: number, y: number) => `${x.toFixed(2)},${y.toFixed(2)}`; + const unused = new Set(segs.map((_, i) => i)); + const byPoint = new Map(); + segs.forEach((s, i) => { + [key(s[0], s[1]), key(s[2], s[3])].forEach((k) => { + const list = byPoint.get(k); + if (list) list.push(i); + else byPoint.set(k, [i]); + }); + }); + const near = (a: number, b: number) => Math.abs(a - b) < 0.01; + const loops: Array> = []; + while (unused.size > 0) { + const startIdx: number = unused.values().next().value as number; + unused.delete(startIdx); + const s0 = segs[startIdx]!; + const pts: Array<[number, number]> = [ + [s0[0], s0[1]], + [s0[2], s0[3]], + ]; + let cur: [number, number] = [s0[2], s0[3]]; + for (;;) { + const candidates = (byPoint.get(key(cur[0], cur[1])) ?? []).filter((i) => + unused.has(i), + ); + if (candidates.length === 0) break; + const i = candidates[0]!; + unused.delete(i); + const s = segs[i]!; + const next: [number, number] = + near(s[0], cur[0]) && near(s[1], cur[1]) ? [s[2], s[3]] : [s[0], s[1]]; + pts.push(next); + cur = next; + } + if (pts.length > 2 && near(pts[0]![0], cur[0]) && near(pts[0]![1], cur[1])) + pts.pop(); + const merged: Array<[number, number]> = []; + pts.forEach((p) => { + const a = merged[merged.length - 2]; + const b = merged[merged.length - 1]; + if ( + a && + b && + ((near(a[0], b[0]) && near(b[0], p[0])) || + (near(a[1], b[1]) && near(b[1], p[1]))) + ) + merged[merged.length - 1] = p; + else merged.push(p); + }); + while (merged.length > 3) { + const a = merged[merged.length - 1]!; + const b = merged[0]!; + const c = merged[1]!; + if ( + (near(a[0], b[0]) && near(b[0], c[0])) || + (near(a[1], b[1]) && near(b[1], c[1])) + ) + merged.shift(); + else break; + } + if (merged.length >= 4) loops.push(merged); + } + return loops; +}; + +/** + * Rounded rectilinear-polygon path: every corner (convex and concave) is + * rounded with a quadratic arc clamped to half of its shorter edge. + */ +export const roundedLoopPath = ( + pts: ReadonlyArray<[number, number]>, + radius: number, +): string => { + const n = pts.length; + let d = ""; + for (let i = 0; i < n; i++) { + const p = pts[i]!; + const prev = pts[(i - 1 + n) % n]!; + const next = pts[(i + 1) % n]!; + const inLen = Math.hypot(p[0] - prev[0], p[1] - prev[1]); + const outLen = Math.hypot(next[0] - p[0], next[1] - p[1]); + const r = Math.min(radius, inLen / 2, outLen / 2); + const pin: [number, number] = [ + p[0] - ((p[0] - prev[0]) / inLen) * r, + p[1] - ((p[1] - prev[1]) / inLen) * r, + ]; + const pout: [number, number] = [ + p[0] + ((next[0] - p[0]) / outLen) * r, + p[1] + ((next[1] - p[1]) / outLen) * r, + ]; + d += + (i === 0 ? `M${pin[0]},${pin[1]}` : `L${pin[0]},${pin[1]}`) + + `Q${p[0]},${p[1]} ${pout[0]},${pout[1]}`; + } + return d + "Z"; +}; + +/** + * One group's merged plate: vertically-adjacent overlapping row segments are + * bridged so the run reads as one region, then the union boundary is traced + * and every corner rounded. + */ +export const platePath = ( + segs: ReadonlyArray, + m: UnitGridMetrics, +): string => { + const rects = segs.map((s) => ({ + x: s.x0 - m.platePadX, + y: s.y - m.platePadY, + w: s.x1 - s.x0 + m.platePadX * 2, + h: m.cellPx + m.platePadY * 2, + })); + const bridges: Array<{ + ox0: number; + ox1: number; + y0: number; + y1: number; + } | null> = []; + for (let i = 0; i < rects.length - 1; i++) { + const A = rects[i]!; + const B = rects[i + 1]!; + const ox0 = Math.max(A.x, B.x); + const ox1 = Math.min(A.x + A.w, B.x + B.w); + bridges.push( + segs[i + 1]!.row === segs[i]!.row + 1 && ox1 - ox0 > m.gapPx + ? { ox0, ox1, y0: A.y + A.h, y1: B.y } + : null, + ); + } + const hPieces = ( + a0: number, + a1: number, + ex: { ox0: number; ox1: number } | null | undefined, + ): Array<[number, number]> => + ex + ? ( + [ + [a0, Math.max(a0, ex.ox0)], + [Math.min(a1, ex.ox1), a1], + ] as Array<[number, number]> + ).filter(([p0, p1]) => p1 - p0 > 0.5) + : [[a0, a1]]; + const boundary: BoundarySeg[] = []; + rects.forEach((g0, i) => { + const above = i > 0 ? bridges[i - 1] : null; + const below = i < rects.length - 1 ? bridges[i] : null; + hPieces(g0.x, g0.x + g0.w, above).forEach(([p0, p1]) => + boundary.push([p0, g0.y, p1, g0.y]), + ); + hPieces(g0.x, g0.x + g0.w, below).forEach(([p0, p1]) => + boundary.push([p0, g0.y + g0.h, p1, g0.y + g0.h]), + ); + boundary.push([g0.x, g0.y, g0.x, g0.y + g0.h]); + boundary.push([g0.x + g0.w, g0.y, g0.x + g0.w, g0.y + g0.h]); + }); + bridges.forEach((b) => { + if (b) { + boundary.push([b.ox0, b.y0, b.ox0, b.y1]); + boundary.push([b.ox1, b.y0, b.ox1, b.y1]); + } + }); + return chainLoops(boundary) + .map((loop) => roundedLoopPath(loop, m.radiusPx + m.platePadY)) + .join(""); +}; + +/** + * Index (into `placed`) of each group's VISUAL top-left cell — topmost row, + * then leftmost. On serpentine's right→left rows this differs from the + * flow-first cell; the group initial (and the popover anchor) go here. + */ +export const letterCellIndices = ( + placed: ReadonlyArray<{ groupKey: string; px: number; py: number }>, +): Map => { + const indices = new Map(); + placed.forEach((c, i) => { + const curIdx = indices.get(c.groupKey); + if (curIdx === undefined) { + indices.set(c.groupKey, i); + return; + } + const cur = placed[curIdx]!; + if (c.py < cur.py || (c.py === cur.py && c.px < cur.px)) indices.set(c.groupKey, i); + }); + return indices; +}; diff --git a/src/components/UnitGrid/UnitGrid.messages.ts b/src/components/UnitGrid/UnitGrid.messages.ts new file mode 100644 index 0000000..e578467 --- /dev/null +++ b/src/components/UnitGrid/UnitGrid.messages.ts @@ -0,0 +1,18 @@ +import { defineMessages } from "../../i18n/catalog"; + +export const unitGridMessages = defineMessages({ + "uic.UnitGrid.label": { + defaultMessage: "Resource grid", + description: "Accessible name of a unit grid whose caller gave it no aria-label", + }, + "uic.UnitGrid.changeGroupColor": { + defaultMessage: "Change group color", + description: + "Button in a unit grid's hover card that opens the palette for the group", + }, + "uic.UnitGrid.useColor": { + defaultMessage: "Use color {index}", + description: + "Accessible name of one palette swatch in a unit grid; {index} is its 1-based position", + }, +}); diff --git a/src/components/UnitGrid/UnitGrid.test.tsx b/src/components/UnitGrid/UnitGrid.test.tsx new file mode 100644 index 0000000..0a59730 --- /dev/null +++ b/src/components/UnitGrid/UnitGrid.test.tsx @@ -0,0 +1,299 @@ +import { describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen, within } from "@testing-library/react"; +import { InternationalizationProvider } from "@astryxdesign/core/i18n"; + +import { uiCommonMessages } from "../../i18n/messages"; +import { UnitGrid, type UnitGridGroup } from "./UnitGrid"; + +const unit = (color = "#3469d6") => ({ color }); + +const GROUPS: UnitGridGroup[] = [ + { key: "alpha", label: "Alpha", units: [unit(), unit(), unit()] }, + { key: "beta", label: "Beta", units: [unit("#b84134"), unit("#b84134")] }, +]; + +const cellsOf = (container: HTMLElement, key?: string) => + container.querySelectorAll( + key ? `.uic-unit-grid__cell[data-group-key="${key}"]` : ".uic-unit-grid__cell", + ); + +describe("UnitGrid", () => { + it("renders one cell per unit, attributed to its group by key", () => { + const { container } = render( + , + ); + expect(cellsOf(container)).toHaveLength(5); + expect(cellsOf(container, "alpha")).toHaveLength(3); + expect(cellsOf(container, "beta")).toHaveLength(2); + expect(screen.getByRole("img", { name: "Unit grid" })).toBeInTheDocument(); + }); + + it("caps each group at maxUnitsPerGroup", () => { + const { container } = render( + , + ); + expect(cellsOf(container, "big")).toHaveLength(4); + }); + + it("renders a partial-fill overlay for fraction cells", () => { + const { container } = render( + , + ); + // 2 base cells + 1 fraction overlay rect, all attributed to the group. + expect(cellsOf(container, "a")).toHaveLength(2); + expect(container.querySelectorAll('rect[data-group-key="a"]')).toHaveLength(3); + }); + + it("names each group plate for assistive tech", () => { + const { container } = render(); + // role="img" on the svg flattens its subtree for AT, so the groups are + // enumerable through the parallel sr-only list (no on the + // plates — its only rendered effect was a native tooltip competing + // with the component's own popover). + const srItems = within(screen.getByRole("list")) + .getAllByRole("listitem") + .map((el) => el.textContent); + expect(srItems).toEqual(["Alpha", "Beta"]); + expect(container.querySelectorAll("path > title")).toHaveLength(0); + }); + + it("shows the popover slot content for the hovered group", () => { + const { container } = render( + <UnitGrid + groups={GROUPS} + columns={8} + renderGroupPopover={(group) => <div>popover: {group.label}</div>} + />, + ); + fireEvent.mouseMove(cellsOf(container, "beta")[0]!); + expect(screen.getByText("popover: Beta")).toBeInTheDocument(); + expect(screen.queryByText("popover: Alpha")).not.toBeInTheDocument(); + }); + + // A `position: fixed` popover resolves its offsets against the nearest + // transformed ancestor, not the viewport — a drawer panel is one, and the + // popover landed off-screen there. + it("rebases the popover onto the fixed-positioning containing block", () => { + const WRAPPER_LEFT = 1144; + const renderWithOrigin = (originLeft: number) => { + const spy = vi + .spyOn(Element.prototype, "getBoundingClientRect") + .mockImplementation(function (this: Element) { + if (this.classList.contains("uic-unit-grid__wrapper")) { + return { left: WRAPPER_LEFT, top: 0 } as DOMRect; + } + // The zero-size probe the component appends to measure the origin. + if (this.getAttribute("aria-hidden") === "true") { + return { left: originLeft, top: 0 } as DOMRect; + } + return { left: 0, top: 0 } as DOMRect; + }); + const { container, unmount } = render( + <UnitGrid + groups={GROUPS} + columns={8} + renderGroupPopover={(group) => <div>popover: {group.label}</div>} + />, + ); + fireEvent.mouseMove(cellsOf(container, "beta")[0]!); + const popover = container.querySelector<HTMLElement>(".uic-unit-grid__popover"); + const left = parseFloat(popover?.style.left ?? "NaN"); + unmount(); + spy.mockRestore(); + return left; + }; + + const untransformed = renderWithOrigin(0); + const insideDrawer = renderWithOrigin(1120); + + expect(untransformed).toBe(WRAPPER_LEFT); + expect(insideDrawer).toBe(WRAPPER_LEFT - 1120); + }); + + it("keeps hover attribution keyed by group key when groups are reordered", () => { + const renderPopover = (group: UnitGridGroup) => <div>popover: {group.label}</div>; + const { container, rerender } = render( + <UnitGrid groups={GROUPS} columns={8} renderGroupPopover={renderPopover} />, + ); + rerender( + <UnitGrid + groups={[...GROUPS].reverse()} + columns={8} + renderGroupPopover={renderPopover} + />, + ); + fireEvent.mouseMove(cellsOf(container, "alpha")[0]!); + expect(screen.getByText("popover: Alpha")).toBeInTheDocument(); + }); + + it("fires onHueOverrideChange with (key, paletteIdx) from the picker", () => { + const onHueOverrideChange = vi.fn(); + const { container } = render( + <UnitGrid + groups={GROUPS} + columns={8} + onHueOverrideChange={onHueOverrideChange} + />, + ); + fireEvent.mouseMove(cellsOf(container, "alpha")[0]!); + fireEvent.click(screen.getByRole("button", { name: "Change group color" })); + fireEvent.click(screen.getByRole("button", { name: "Use color 3" })); + expect(onHueOverrideChange).toHaveBeenCalledWith("alpha", 2); + }); + + it("falls back to a translated accessible name when aria-label is omitted", () => { + render(<UnitGrid groups={GROUPS} columns={8} />); + expect(screen.getByRole("img", { name: "Resource grid" })).toBeInTheDocument(); + }); + + it("supports keyboard activation of the picker controls", () => { + const onHueOverrideChange = vi.fn(); + const { container } = render( + <UnitGrid + groups={GROUPS} + columns={8} + onHueOverrideChange={onHueOverrideChange} + />, + ); + fireEvent.mouseMove(cellsOf(container, "alpha")[0]!); + const toggle = screen.getByRole("button", { name: "Change group color" }); + expect(toggle).toHaveAttribute("tabindex", "0"); + // Enter on the toggle opens the palette row. + fireEvent.keyDown(toggle, { key: "Enter" }); + const swatch = screen.getByRole("button", { name: "Use color 2" }); + expect(swatch).toHaveAttribute("tabindex", "0"); + // Space activates a swatch AND is prevented from scrolling the page. + const spaceNotPrevented = fireEvent.keyDown(swatch, { key: " " }); + expect(spaceNotPrevented).toBe(false); + expect(onHueOverrideChange).toHaveBeenCalledWith("alpha", 1); + }); + + it("fires onClickGroup with the group key", () => { + const onClickGroup = vi.fn(); + const { container } = render( + <UnitGrid groups={GROUPS} columns={8} onClickGroup={onClickGroup} />, + ); + fireEvent.click(cellsOf(container, "beta")[1]!); + expect(onClickGroup).toHaveBeenCalledWith("beta"); + }); + + it("renders the empty fallback when there are no units to show", () => { + const { container } = render( + <UnitGrid groups={[]} columns={8} emptyFallback={<div>nothing here</div>} />, + ); + expect(screen.getByText("nothing here")).toBeInTheDocument(); + expect(cellsOf(container)).toHaveLength(0); + }); + + it('renders a dashed plate outline only for plateVariant "dashed" groups', () => { + const { container } = render( + <UnitGrid + groups={[ + { + key: "pending-ish", + units: [unit(), unit()], + plateVariant: "dashed", + }, + { key: "solid-default", units: [unit()] }, + { key: "solid-explicit", units: [unit()], plateVariant: "solid" }, + ]} + columns={8} + />, + ); + const plateOf = (key: string) => + container.querySelector(`path[data-group-key="${key}"]`); + expect(plateOf("pending-ish")).toHaveAttribute("stroke-dasharray", "6 4"); + expect(plateOf("solid-default")).not.toHaveAttribute("stroke-dasharray"); + expect(plateOf("solid-explicit")).not.toHaveAttribute("stroke-dasharray"); + }); + + it("renders provided legend items", () => { + render( + <UnitGrid + groups={GROUPS} + columns={8} + legendItems={[ + { color: "#42825c", label: "Low" }, + { color: "#b84134", label: "High" }, + ]} + />, + ); + expect(screen.getByText("Low")).toBeInTheDocument(); + expect(screen.getByText("High")).toBeInTheDocument(); + }); +}); + +describe("UnitGrid strings and theming", () => { + it("takes its strings from the shipped translations, or from props", () => { + const { container, rerender } = render( + <InternationalizationProvider locale="ko-KR" messages={uiCommonMessages}> + <UnitGrid groups={GROUPS} columns={8} onHueOverrideChange={() => {}} /> + </InternationalizationProvider>, + ); + expect(screen.getByRole("img", { name: "자원 그리드" })).toBeInTheDocument(); + fireEvent.mouseMove(cellsOf(container, "alpha")[0]!); + fireEvent.click(screen.getByRole("button", { name: "그룹 색상 변경" })); + expect(screen.getByRole("button", { name: "색상 2 사용" })).toBeInTheDocument(); + + rerender( + <UnitGrid + groups={GROUPS} + columns={8} + onHueOverrideChange={() => {}} + changeGroupColorLabel="Recolor" + colorSwatchLabel={(index) => `Hue ${index}`} + />, + ); + fireEvent.mouseMove(cellsOf(container, "alpha")[0]!); + fireEvent.click(screen.getByRole("button", { name: "Recolor" })); + expect(screen.getByRole("button", { name: "Hue 4" })).toBeInTheDocument(); + }); + + it("paints groups from the --uic-unit-grid-group-N hues unless given a palette", () => { + const plate = (container: HTMLElement, key: string) => + container.querySelector<SVGPathElement>(`path[data-group-key="${key}"]`)!; + const { container, rerender } = render(<UnitGrid groups={GROUPS} columns={8} />); + expect(plate(container, "alpha").style.fill).toBe("var(--uic-unit-grid-group-1)"); + expect(plate(container, "beta").style.fill).toBe("var(--uic-unit-grid-group-2)"); + rerender( + <UnitGrid + groups={GROUPS} + columns={8} + groupPalette={["red", "blue"]} + hueOverrides={{ alpha: 1 }} + />, + ); + expect(plate(container, "alpha").style.fill).toBe("blue"); + expect(plate(container, "beta").style.fill).toBe("blue"); + }); + + it("inks the group initial with one of its two ink properties", () => { + const { container } = render(<UnitGrid groups={GROUPS} columns={8} />); + const inks = Array.from(container.querySelectorAll<SVGTextElement>("text")).map( + (el) => el.style.fill, + ); + expect(inks).toHaveLength(2); + for (const ink of inks) { + expect([ + "var(--uic-unit-grid-ink-dark)", + "var(--uic-unit-grid-ink-light)", + ]).toContain(ink); + } + }); + + it("passes class names and attributes to the root", () => { + const { container } = render( + <UnitGrid groups={GROUPS} columns={8} className="extra" data-testid="grid" />, + ); + const root = container.firstElementChild as HTMLElement; + expect(root).toHaveClass("uic-unit-grid", "extra"); + expect(root).toHaveAttribute("data-testid", "grid"); + }); +}); diff --git a/src/components/UnitGrid/UnitGrid.tsx b/src/components/UnitGrid/UnitGrid.tsx new file mode 100644 index 0000000..25b9aa3 --- /dev/null +++ b/src/components/UnitGrid/UnitGrid.tsx @@ -0,0 +1,740 @@ +/** + * UnitGrid + * + * Groups of unit squares packed onto one shared lattice (serpentine or + * word-wrap), each group merged into a tinted rounded plate, with a hover card + * anchored to the group, an optional palette picker in it, and a legend row. + * It knows nothing about what a unit is: callers pass the resolved colour of + * every unit, the legend entries and the hover card body. + * + * Unit colours may be `var()` or `color-mix()` strings. The component resolves + * them against its own cascade to pick, per group initial, the ink with the + * better WCAG contrast. + * + * @example + * <UnitGrid + * aria-label="GPU allocation" + * groups={[{ key: "a", label: "Alpha", units: [{ color: "var(--color-icon-blue)" }] }]} + * /> + */ +import { + useEffect, + useEffectEvent, + useLayoutEffect, + useRef, + useState, + type HTMLAttributes, + type KeyboardEvent, + type MouseEvent, + type ReactNode, +} from "react"; +import { HStack, VStack } from "@astryxdesign/core/Stack"; +import { Text } from "@astryxdesign/core/Text"; +import { VisuallyHidden } from "@astryxdesign/core/VisuallyHidden"; + +import { useUicTranslator } from "../../i18n/useUicTranslator"; +import { + deriveMetrics, + extractSegments, + gridSize, + groupConsecutiveSegments, + latticeColsForWidth, + letterCellIndices, + packGroups, + placeCells, + platePath, + type UnitGridLayout, + type UnitGridMetrics, +} from "./UnitGrid.geometry"; +import "./UnitGrid.css"; + +export type { UnitGridLayout } from "./UnitGrid.geometry"; + +const DEFAULT_MAX_UNITS_PER_GROUP = 256; +/** The seven hues declared in UnitGrid.css. */ +const DEFAULT_PALETTE = Array.from( + { length: 7 }, + (_, i) => `var(--uic-unit-grid-group-${i + 1})`, +); +// Hover card geometry: clamp width, flip threshold, anchor offset, and the +// hide delay that lets the pointer travel onto the card. +const POPOVER_CLAMP_WIDTH = 330; +const POPOVER_FLIP_MIN_TOP = 340; +const POPOVER_OFFSET = 6; +const POPOVER_HIDE_DELAY_MS = 150; +// Legible at both plate stroke widths (1.5 at rest, 2.5 on hover). +const PLATE_DASH_PATTERN = "6 4"; +// The initial is vertically centred and fills grow bottom-up, so below this +// fraction the initial sits on the empty underlay: pick ink against that. +const LETTER_ON_EMPTY_MAX_FRACTION = 0.6; +const EMPTY_FILL_COLOR = "var(--uic-unit-grid-cell-empty)"; +// The empty fill is translucent; composite it over this before judging it. +const EMPTY_FILL_BACKDROP = "var(--color-background-card)"; +const INK_DARK = "var(--uic-unit-grid-ink-dark)"; +const INK_LIGHT = "var(--uic-unit-grid-ink-light)"; + +const cx = (...names: Array<string | false | undefined>) => + names.filter(Boolean).join(" "); + +const parseAlpha = (raw: string | undefined): number => { + if (raw === undefined) return 1; + const v = parseFloat(raw); + if (!Number.isFinite(v)) return 1; + return raw.endsWith("%") ? v / 100 : v; +}; + +/** `[r, g, b (0..255), alpha (0..1)]` of `#rrggbb`, `rgb()` or `color(srgb)`. */ +const parseColorChannels = (color: string): [number, number, number, number] | null => { + const trimmed = color.trim(); + const hex = /^#?([0-9a-f]{6})$/i.exec(trimmed); + if (hex) { + const [r, g, b] = [0, 2, 4].map((i) => parseInt(hex[1]!.slice(i, i + 2), 16)); + return [r!, g!, b!, 1]; + } + const rgb = + /^rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)(?:\s*[,/]\s*([\d.%]+))?/.exec( + trimmed, + ); + if (rgb) { + return [ + parseFloat(rgb[1]!), + parseFloat(rgb[2]!), + parseFloat(rgb[3]!), + parseAlpha(rgb[4]), + ]; + } + // color-mix() and color() fills serialize as `color(srgb r g b / a)`, 0..1. + const srgb = + /^color\(srgb\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)(?:\s*\/\s*([\d.%]+))?/.exec(trimmed); + if (srgb) { + return [ + parseFloat(srgb[1]!) * 255, + parseFloat(srgb[2]!) * 255, + parseFloat(srgb[3]!) * 255, + parseAlpha(srgb[4]), + ]; + } + return null; +}; + +const luminanceOfChannels = (channels: [number, number, number]): number => { + const [r, g, b] = channels.map((c) => { + const v = c / 255; + return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; + }); + return 0.2126 * r! + 0.7152 * g! + 0.0722 * b!; +}; + +/** WCAG relative luminance, alpha ignored. */ +const relativeLuminance = (color: string | undefined): number | null => { + const channels = color === undefined ? null : parseColorChannels(color); + return channels ? luminanceOfChannels([channels[0], channels[1], channels[2]]) : null; +}; + +const contrastRatio = (a: number, b: number): number => + (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05); + +/** Keyboard activation for the SVG `role="button"` picker controls. */ +const activateOnKey = (e: KeyboardEvent<SVGSVGElement>, action: () => void): void => { + if (e.key === "Enter" || e.key === " ") { + // Space must not scroll the page while it activates the control. + if (e.key === " ") e.preventDefault(); + action(); + } +}; + +/** + * Resolve CSS colour strings, `var()` included, to computed colours through + * hidden probes under `host`, so they see the component's own cascade. + */ +const resolveColorsInDOM = ( + host: HTMLElement, + colors: readonly string[], +): Record<string, string> => { + const probeHost = document.createElement("div"); + probeHost.setAttribute("aria-hidden", "true"); + probeHost.style.cssText = + "position:absolute;visibility:hidden;pointer-events:none;width:0;height:0;overflow:hidden"; + const probes = colors.map((color) => { + const el = document.createElement("div"); + el.style.color = color; + probeHost.appendChild(el); + return el; + }); + host.appendChild(probeHost); + const out: Record<string, string> = {}; + colors.forEach((color, i) => { + out[color] = getComputedStyle(probes[i]!).color; + }); + host.removeChild(probeHost); + return out; +}; + +/** + * Origin of the hover card's `position: fixed` containing block, in viewport + * coordinates. A transformed ancestor (a drawer panel is one) takes over from + * the viewport, so measured viewport rects are rebased onto it. (0, 0) when + * the viewport is the origin. + */ +const fixedOriginIn = (host: HTMLElement): { left: number; top: number } => { + const probe = document.createElement("div"); + probe.setAttribute("aria-hidden", "true"); + probe.style.cssText = + "position:fixed;top:0;left:0;width:0;height:0;visibility:hidden;pointer-events:none"; + host.appendChild(probe); + const r = probe.getBoundingClientRect(); + host.removeChild(probe); + return { left: r.left, top: r.top }; +}; + +const readMetricsFromDOM = (host: HTMLElement): UnitGridMetrics => { + const cs = getComputedStyle(host); + const len = (name: string, fallback: number): number => { + const v = parseFloat(cs.getPropertyValue(name)); + return Number.isFinite(v) ? v : fallback; + }; + return deriveMetrics( + len("--spacing-4", 16), + len("--spacing-0-5", 2), + Math.min(len("--radius-element", 4), 4), + ); +}; + +export interface UnitGridUnit { + /** Fill colour of this unit's cell. */ + color: string; + /** 0..1 partial fill; a fractional cell fills bottom-up. */ + fraction?: number; +} + +export interface UnitGridGroup { + key: string; + /** The group initial and the hover card title. @default key */ + label?: string; + units: UnitGridUnit[]; + /** Plate outline; `dashed` marks a group as tentative. @default 'solid' */ + plateVariant?: "solid" | "dashed"; +} + +export interface UnitGridLegendItem { + color: string; + label: string; +} + +export interface UnitGridProps extends Omit< + HTMLAttributes<HTMLDivElement>, + "children" +> { + groups: UnitGridGroup[]; + /** How groups flow along the lattice. @default 'serpentine' */ + layout?: UnitGridLayout; + /** Group hues, cycled in flow order. @default the seven --uic-unit-grid-group-N hues */ + groupPalette?: string[]; + /** Controlled palette-index overrides, keyed by group key. */ + hueOverrides?: Record<string, number>; + /** Shows the palette picker in the hover card. */ + onHueOverrideChange?: (key: string, paletteIndex: number) => void; + legendItems?: UnitGridLegendItem[]; + /** The hover card body for a group. */ + renderGroupPopover?: ( + group: UnitGridGroup, + context: { hue: string; closePopover: () => void }, + ) => ReactNode; + onClickGroup?: (key: string) => void; + /** Rendered instead of the grid when no group has a unit. */ + emptyFallback?: ReactNode; + /** Units drawn per group at most. @default 256 */ + maxUnitsPerGroup?: number; + /** + * Fixed lattice column count. Without it the count follows the measured + * width (ResizeObserver); pass it for fixed layouts and in jsdom. + */ + columns?: number; + /** @default the catalog's uic.UnitGrid.changeGroupColor ("Change group color") */ + changeGroupColorLabel?: string; + /** @default the catalog's uic.UnitGrid.useColor ("Use color {index}") */ + colorSwatchLabel?: (index: number) => string; +} + +export function UnitGrid({ + groups, + layout = "serpentine", + groupPalette, + hueOverrides, + onHueOverrideChange, + legendItems, + renderGroupPopover, + onClickGroup, + emptyFallback, + maxUnitsPerGroup = DEFAULT_MAX_UNITS_PER_GROUP, + columns, + changeGroupColorLabel, + colorSwatchLabel, + className, + ...divProps +}: UnitGridProps) { + const t = useUicTranslator(); + const palette = + groupPalette && groupPalette.length > 0 ? groupPalette : DEFAULT_PALETTE; + + const rootRef = useRef<HTMLDivElement | null>(null); + const wrapperRef = useRef<HTMLDivElement | null>(null); + const [wrapperWidth, setWrapperWidth] = useState(0); + + const visibleGroups = groups.filter((g) => g.units.length > 0); + const hasGroups = visibleGroups.length > 0; + + useEffect(() => { + const el = wrapperRef.current; + if (!el) return; + const ro = new ResizeObserver((entries) => { + setWrapperWidth(entries[0]?.contentRect.width ?? 0); + }); + ro.observe(el); + return () => ro.disconnect(); + }, [hasGroups]); + + // Concrete colours (for the ink decision) and token-derived metrics, + // resolved against the live cascade. Paint uses the raw strings, so var() + // fills keep following the theme; only the ink decision is a snapshot. + const [metrics, setMetrics] = useState<UnitGridMetrics>(() => deriveMetrics()); + const [resolvedColors, setResolvedColors] = useState<Record<string, string>>({}); + const distinctColors = Array.from( + new Set(visibleGroups.flatMap((g) => g.units.map((u) => u.color))), + ); + const colorsKey = distinctColors.join(" "); + const refreshResolved = useEffectEvent(() => { + const host = rootRef.current; + if (!host) return; + const nextMetrics = readMetricsFromDOM(host); + setMetrics((prev) => + prev.cellPx === nextMetrics.cellPx && + prev.gapPx === nextMetrics.gapPx && + prev.radiusPx === nextMetrics.radiusPx + ? prev + : nextMetrics, + ); + const next = resolveColorsInDOM(host, [ + ...distinctColors, + EMPTY_FILL_COLOR, + EMPTY_FILL_BACKDROP, + INK_DARK, + INK_LIGHT, + ]); + setResolvedColors((prev) => { + const keys = Object.keys(next); + const same = + keys.length === Object.keys(prev).length && + keys.every((k) => prev[k] === next[k]); + return same ? prev : next; + }); + }); + // A theme flip changes what the same var() strings resolve to, so the ink + // snapshot re-probes when the root's theme attributes change. + const [themeEpoch, setThemeEpoch] = useState(0); + useEffect(() => { + const observer = new MutationObserver(() => setThemeEpoch((e) => e + 1)); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["data-theme", "data-astryx-theme", "class", "style"], + }); + return () => observer.disconnect(); + }, []); + useLayoutEffect(() => { + refreshResolved(); + // refreshResolved is an effect event: it is never a dependency. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [colorsKey, themeEpoch]); + + // Hover card: hiding is delayed so the pointer can travel onto it. + const [hoverKey, setHoverKey] = useState<string | null>(null); + const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null); + const cancelHide = () => { + if (hideTimer.current) { + clearTimeout(hideTimer.current); + hideTimer.current = null; + } + }; + const scheduleHide = () => { + cancelHide(); + hideTimer.current = setTimeout(() => { + setHoverKey(null); + setPickerFor(null); + }, POPOVER_HIDE_DELAY_MS); + }; + useEffect(() => cancelHide, []); + + // The group the picker was opened for; hovering another group closes it. + const [pickerFor, setPickerFor] = useState<string | null>(null); + const pickerOpen = pickerFor !== null && pickerFor === hoverKey; + + const [wrapperRect, setWrapperRect] = useState<{ + left: number; + top: number; + originLeft: number; + originTop: number; + } | null>(null); + useEffect(() => { + if (hoverKey !== null) { + const host = wrapperRef.current; + const r = host?.getBoundingClientRect(); + const origin = host ? fixedOriginIn(host) : null; + setWrapperRect( + r && origin + ? { left: r.left, top: r.top, originLeft: origin.left, originTop: origin.top } + : null, + ); + } + }, [hoverKey]); + + // The card is fixed, so any scroll moves its anchor: dismiss it. + const hoverOpen = hoverKey !== null; + useEffect(() => { + if (!hoverOpen) return; + const onScroll = () => { + setHoverKey(null); + setPickerFor(null); + }; + window.addEventListener("scroll", onScroll, true); + return () => window.removeEventListener("scroll", onScroll, true); + }, [hoverOpen]); + + const groupByKey = new Map(visibleGroups.map((g) => [g.key, g])); + const flowIndexByKey = new Map(visibleGroups.map((g, i) => [g.key, i])); + + // Default hues cycle the palette in flow order, so neighbours differ; + // overrides are keyed by the group's stable key. + const hueIndexFor = (key: string): number => { + const base = hueOverrides?.[key] ?? flowIndexByKey.get(key) ?? 0; + return ((base % palette.length) + palette.length) % palette.length; + }; + const hueFor = (key: string): string => palette[hueIndexFor(key)]!; + + const letterFor = (key: string): string => { + const group = groupByKey.get(key); + return ((group?.label || key).charAt(0) || "?").toUpperCase(); + }; + + const inkDarkLuminance = relativeLuminance(resolvedColors[INK_DARK]) ?? 0; + const inkLightLuminance = relativeLuminance(resolvedColors[INK_LIGHT]) ?? 1; + const inkForLuminance = (fillLuminance: number | null): string => { + // An unresolved fill (first paint, jsdom) is taken as light. + const lum = fillLuminance ?? 0.8; + return contrastRatio(lum, inkDarkLuminance) >= contrastRatio(lum, inkLightLuminance) + ? INK_DARK + : INK_LIGHT; + }; + + const inkFor = (color: string): string => + inkForLuminance(relativeLuminance(resolvedColors[color] ?? color)); + + const emptyFillLuminance = ((): number | null => { + const fg = parseColorChannels(resolvedColors[EMPTY_FILL_COLOR] ?? ""); + if (!fg) return null; + if (fg[3] >= 1) return luminanceOfChannels([fg[0], fg[1], fg[2]]); + const bg = parseColorChannels(resolvedColors[EMPTY_FILL_BACKDROP] ?? ""); + if (!bg) return null; + const a = fg[3]; + return luminanceOfChannels([ + fg[0] * a + bg[0] * (1 - a), + fg[1] * a + bg[1] * (1 - a), + fg[2] * a + bg[2] * (1 - a), + ]); + })(); + + const letterInkFor = (unit: UnitGridUnit): string => + unit.fraction !== undefined && unit.fraction < LETTER_ON_EMPTY_MAX_FRACTION + ? inkForLuminance(emptyFillLuminance) + : inkFor(unit.color); + + const latticeCols = columns ?? latticeColsForWidth(wrapperWidth, metrics); + const packed = packGroups(visibleGroups, maxUnitsPerGroup); + const placed = placeCells(packed, layout, latticeCols, metrics); + const { width: svgWidth, height: svgHeight } = gridSize(placed, latticeCols, metrics); + const segGroups = groupConsecutiveSegments(extractSegments(placed, metrics)); + const letterIdx = letterCellIndices(placed); + + const hoveredGroup = hoverKey === null ? null : (groupByKey.get(hoverKey) ?? null); + const hoverAnchorIdx = hoverKey === null ? undefined : letterIdx.get(hoverKey); + const hoverAnchor = + hoverAnchorIdx === undefined ? null : (placed[hoverAnchorIdx] ?? null); + const closePopover = () => { + setHoverKey(null); + setPickerFor(null); + }; + + const groupKeyFromEvent = (e: MouseEvent): string | null => + (e.target as Element).getAttribute?.("data-group-key") ?? null; + + const showGrid = hasGroups && (columns !== undefined || wrapperWidth > 0); + const showPopover = + hoveredGroup !== null && + hoverKey !== null && + hoverAnchor !== null && + wrapperRect !== null && + (renderGroupPopover !== undefined || onHueOverrideChange !== undefined); + + const togglePicker = () => setPickerFor((v) => (v === hoverKey ? null : hoverKey)); + + return ( + <VStack + ref={rootRef} + gap={3} + align="stretch" + className={cx("uic-unit-grid", className)} + {...divProps} + > + {legendItems && legendItems.length > 0 && ( + <HStack gap={3} wrap="wrap" align="center"> + {legendItems.map((item, i) => ( + <HStack key={i} gap={1} align="center"> + <svg width={10} height={10} role="img" aria-label={item.label}> + <rect width={10} height={10} rx={2} style={{ fill: item.color }} /> + </svg> + <Text size="sm" color="secondary"> + {item.label} + </Text> + </HStack> + ))} + </HStack> + )} + {!hasGroups ? ( + (emptyFallback ?? null) + ) : ( + <div ref={wrapperRef} className="uic-unit-grid__wrapper"> + {showGrid && ( + <svg + width={svgWidth} + height={svgHeight} + role="img" + aria-label={divProps["aria-label"] ?? t("uic.UnitGrid.label")} + className={cx( + "uic-unit-grid__svg", + onClickGroup !== undefined && "uic-unit-grid__svg--clickable", + )} + onMouseMove={(e) => { + const key = groupKeyFromEvent(e); + if (key === null) { + scheduleHide(); + } else { + cancelHide(); + setHoverKey(key); + } + }} + onMouseLeave={scheduleHide} + onClick={(e) => { + const key = groupKeyFromEvent(e); + if (key !== null && onClickGroup) onClickGroup(key); + }} + > + {segGroups.map((segs) => { + const key = segs[0]!.groupKey; + const group = groupByKey.get(key); + const hue = hueFor(key); + const hovered = hoverKey === key; + // Hover strengthens the hovered group's tint and border + // rather than dimming the others. + return ( + <path + key={key} + data-group-key={key} + d={platePath(segs, metrics)} + style={{ fill: hue, stroke: hue }} + fillOpacity={hovered ? 0.32 : 0.15} + strokeWidth={hovered ? 2.5 : 1.5} + strokeDasharray={ + group?.plateVariant === "dashed" ? PLATE_DASH_PATTERN : undefined + } + /> + ); + })} + {placed.map((cell, i) => { + const isPartial = + cell.unit.fraction !== undefined && cell.unit.fraction < 1; + const cellHovered = hoverKey === cell.groupKey; + return ( + <g key={i}> + <rect + className="uic-unit-grid__cell" + data-group-key={cell.groupKey} + x={cell.px} + y={cell.py} + width={metrics.cellPx} + height={metrics.cellPx} + rx={metrics.radiusPx} + style={{ + fill: isPartial ? EMPTY_FILL_COLOR : cell.unit.color, + stroke: cellHovered + ? hueFor(cell.groupKey) + : "var(--uic-unit-grid-cell-stroke)", + }} + strokeWidth={cellHovered ? 1 : 0.5} + /> + {isPartial && ( + <rect + data-group-key={cell.groupKey} + x={cell.px} + y={cell.py + metrics.cellPx * (1 - (cell.unit.fraction ?? 0))} + width={metrics.cellPx} + height={metrics.cellPx * (cell.unit.fraction ?? 0)} + rx={1} + style={{ fill: cell.unit.color }} + /> + )} + {letterIdx.get(cell.groupKey) === i && ( + <text + x={cell.px + metrics.cellPx / 2} + y={cell.py + metrics.cellPx / 2} + textAnchor="middle" + dominantBaseline="central" + fontSize={metrics.cellPx * 0.62} + fontWeight={700} + pointerEvents="none" + style={{ fill: letterInkFor(cell.unit) }} + > + {letterFor(cell.groupKey)} + </text> + )} + </g> + ); + })} + </svg> + )} + {/* role="img" flattens the svg for assistive technology, so the + groups are listed here as well. */} + <VisuallyHidden as="ul"> + {visibleGroups.map((g) => ( + <li key={g.key}>{g.label || g.key}</li> + ))} + </VisuallyHidden> + {showPopover && ( + <div + className="uic-unit-grid__popover" + onMouseEnter={cancelHide} + onMouseLeave={scheduleHide} + style={{ + borderColor: hueFor(hoverKey), + ...(() => { + const viewportLeft = + wrapperRect.left + + Math.max( + 0, + Math.min( + hoverAnchor.px - metrics.platePadX, + Math.max(0, wrapperWidth - POPOVER_CLAMP_WIDTH), + ), + ); + const cellTop = wrapperRect.top + hoverAnchor.py; + // Above the cell when the viewport has room, else below; + // decided in viewport space, then rebased onto the block + // the offsets resolve against. + const flipAbove = cellTop >= POPOVER_FLIP_MIN_TOP; + const viewportTop = flipAbove + ? cellTop - metrics.platePadY - POPOVER_OFFSET + : cellTop + metrics.cellPx + metrics.platePadY + POPOVER_OFFSET; + const left = viewportLeft - wrapperRect.originLeft; + const top = viewportTop - wrapperRect.originTop; + return flipAbove + ? { left, top, transform: "translateY(-100%)" } + : { left, top }; + })(), + }} + > + <VStack gap={1.5} align="stretch"> + {pickerOpen && onHueOverrideChange && ( + <HStack gap={1} align="center"> + {palette.map((hue, pi) => { + const pickThis = () => { + onHueOverrideChange(hoverKey, pi); + setPickerFor(null); + }; + const selected = hueIndexFor(hoverKey) === pi; + return ( + <svg + key={pi} + width={20} + height={20} + role="button" + tabIndex={0} + aria-label={ + colorSwatchLabel + ? colorSwatchLabel(pi + 1) + : t("uic.UnitGrid.useColor", { index: pi + 1 }) + } + className="uic-unit-grid__swatch" + onClick={pickThis} + onKeyDown={(e) => activateOnKey(e, pickThis)} + > + <rect + x={1} + y={1} + width={18} + height={18} + rx={5} + style={{ fill: hue, stroke: hue }} + fillOpacity={0.35} + strokeWidth={selected ? 2 : 1} + /> + {selected && ( + <text + x={10} + y={10.5} + textAnchor="middle" + dominantBaseline="central" + fontSize={11} + fontWeight={700} + pointerEvents="none" + style={{ fill: hue }} + > + ✓ + </text> + )} + </svg> + ); + })} + </HStack> + )} + {onHueOverrideChange && ( + <HStack gap={1.5} align="center"> + <svg + width={16} + height={16} + role="button" + tabIndex={0} + aria-label={ + changeGroupColorLabel ?? t("uic.UnitGrid.changeGroupColor") + } + className="uic-unit-grid__swatch" + onClick={togglePicker} + onKeyDown={(e) => activateOnKey(e, togglePicker)} + > + <rect + x={1} + y={1} + width={14} + height={14} + rx={4} + style={{ fill: hueFor(hoverKey), stroke: hueFor(hoverKey) }} + fillOpacity={0.15} + strokeWidth={1.5} + /> + </svg> + <Text size="sm" weight="semibold"> + {hoveredGroup.label || hoveredGroup.key} + </Text> + </HStack> + )} + {renderGroupPopover?.(hoveredGroup, { + hue: hueFor(hoverKey), + closePopover, + })} + </VStack> + </div> + )} + </div> + )} + </VStack> + ); +} + +UnitGrid.displayName = "UnitGrid"; diff --git a/src/components/UnitGrid/UnitGridSkeleton.test.tsx b/src/components/UnitGrid/UnitGridSkeleton.test.tsx new file mode 100644 index 0000000..7538532 --- /dev/null +++ b/src/components/UnitGrid/UnitGridSkeleton.test.tsx @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { render } from "@testing-library/react"; + +import { UnitGridSkeleton } from "./UnitGridSkeleton"; + +const rowsOf = (container: HTMLElement) => + container.querySelectorAll(".uic-unit-grid-skeleton__row"); + +describe("UnitGridSkeleton", () => { + it("renders 3 lattice rows by default, two blocks each", () => { + const { container } = render(<UnitGridSkeleton />); + const rows = rowsOf(container); + expect(rows).toHaveLength(3); + rows.forEach((row) => { + expect(row.children).toHaveLength(2); + }); + }); + + it("renders `rows` rows", () => { + const { container } = render(<UnitGridSkeleton rows={5} />); + expect(rowsOf(container)).toHaveLength(5); + }); + + it("renders no lattice rows for rows=0", () => { + const { container } = render(<UnitGridSkeleton rows={0} />); + expect(rowsOf(container)).toHaveLength(0); + }); + + it("spreads className and other DOM props onto the root", () => { + const { container } = render( + <UnitGridSkeleton className="custom-class" data-testid="grid-skeleton" />, + ); + const root = container.firstElementChild; + expect(root).toHaveClass("uic-unit-grid-skeleton", "custom-class"); + expect(root).toHaveAttribute("data-testid", "grid-skeleton"); + }); +}); diff --git a/src/components/UnitGrid/UnitGridSkeleton.tsx b/src/components/UnitGrid/UnitGridSkeleton.tsx new file mode 100644 index 0000000..e12e036 --- /dev/null +++ b/src/components/UnitGrid/UnitGridSkeleton.tsx @@ -0,0 +1,112 @@ +/** + * UnitGridSkeleton + * + * The loading stand-in for a UnitGrid and the toolbar above it: a row of + * control-sized bars, a legend row, then two blocks per lattice row. The + * lattice is drawn at low fidelity on purpose; mimicking plates and cells + * would read as detail while nothing is loaded. + * + * @example + * <Suspense fallback={<UnitGridSkeleton />}>...</Suspense> + */ +import type { HTMLAttributes } from "react"; +import { HStack, VStack } from "@astryxdesign/core/Stack"; +import { Skeleton } from "@astryxdesign/core/Skeleton"; + +const DEFAULT_ROW_COUNT = 3; + +/** The toolbar's control widths: per control, not a scale, so no token. */ +const TOOLBAR_PILL_WIDTHS = [140, 180, 100] as const; +const TOOLBAR_PILL_HEIGHT = "var(--size-element-sm)"; + +const LEGEND_ITEM_COUNT = 5; +const LEGEND_SWATCH_SIZE = 10; +const LEGEND_LABEL_WIDTH = 40; +const LEGEND_LABEL_HEIGHT = 12; + +/** Two blocks per row, widths varied so the rows read organic. */ +const ROW_BLOCK_WIDTHS = [ + ["45%", "25%"], + ["30%", "40%"], + ["55%", "20%"], +] as const; +const ROW_BLOCK_HEIGHT = "var(--size-element-sm)"; + +export interface UnitGridSkeletonProps extends Omit< + HTMLAttributes<HTMLDivElement>, + "children" +> { + /** Lattice rows, two blocks each. @default 3 */ + rows?: number; +} + +export function UnitGridSkeleton({ + rows = DEFAULT_ROW_COUNT, + className, + ...divProps +}: UnitGridSkeletonProps) { + // One running index across every bar, so the shimmer reads as one wave. + let waveIndex = 0; + + return ( + <VStack + gap={3} + align="stretch" + className={["uic-unit-grid-skeleton", className].filter(Boolean).join(" ")} + {...divProps} + > + <HStack gap={3} align="center"> + {TOOLBAR_PILL_WIDTHS.map((width, i) => ( + <Skeleton + key={i} + width={width} + height={TOOLBAR_PILL_HEIGHT} + radius={2} + index={waveIndex++} + /> + ))} + </HStack> + <HStack gap={3} wrap="wrap" align="center"> + {Array.from({ length: LEGEND_ITEM_COUNT }, (_unused, i) => ( + <HStack key={i} gap={1} align="center"> + <Skeleton + width={LEGEND_SWATCH_SIZE} + height={LEGEND_SWATCH_SIZE} + radius={1} + index={waveIndex++} + /> + <Skeleton + width={LEGEND_LABEL_WIDTH} + height={LEGEND_LABEL_HEIGHT} + radius={1} + index={waveIndex++} + /> + </HStack> + ))} + </HStack> + {Array.from({ length: Math.max(0, rows) }, (_unused, rowIdx) => { + const widths = ROW_BLOCK_WIDTHS[rowIdx % ROW_BLOCK_WIDTHS.length]!; + return ( + <HStack + key={rowIdx} + gap={3} + align="center" + className="uic-unit-grid-skeleton__row" + > + {widths.map((width, blockIdx) => ( + <Skeleton + key={blockIdx} + width={width} + height={ROW_BLOCK_HEIGHT} + radius={1} + index={waveIndex++} + /> + ))} + </HStack> + ); + })} + </VStack> + ); +} + +UnitGridSkeleton.displayName = "UnitGridSkeleton"; diff --git a/src/components/UnitGrid/index.ts b/src/components/UnitGrid/index.ts new file mode 100644 index 0000000..3ae8288 --- /dev/null +++ b/src/components/UnitGrid/index.ts @@ -0,0 +1,10 @@ +export { UnitGrid } from "./UnitGrid"; +export type { + UnitGridGroup, + UnitGridLayout, + UnitGridLegendItem, + UnitGridProps, + UnitGridUnit, +} from "./UnitGrid"; +export { UnitGridSkeleton } from "./UnitGridSkeleton"; +export type { UnitGridSkeletonProps } from "./UnitGridSkeleton"; diff --git a/src/i18n/catalog.ts b/src/i18n/catalog.ts index 3203b0e..9a1775f 100644 --- a/src/i18n/catalog.ts +++ b/src/i18n/catalog.ts @@ -25,6 +25,7 @@ import type { Catalog, MessageEntry } from "@astryxdesign/core/i18n"; // These modules import `defineMessages` back from here. The cycle is safe: // it is a function declaration, so it is bound before either module runs. import { booleanTokenMessages } from "../components/BooleanToken/BooleanToken.messages"; +import { colorPickerMessages } from "../components/ColorPicker/ColorPicker.messages"; import { deleteConfirmModalMessages } from "../components/DeleteConfirmModal/DeleteConfirmModal.messages"; import { notificationStackMessages } from "../components/NotificationStack/NotificationStack.messages"; import { pageHeaderMessages } from "../components/PageHeader/PageHeader.messages"; @@ -34,6 +35,7 @@ import { statisticMessages } from "../components/Statistic/Statistic.messages"; import { numberStepperMessages } from "../components/StepNumberInput/NumberStepper.messages"; import { tokenRowMessages } from "../components/TokenRow/TokenRow.messages"; import { uncontrolledInputMessages } from "../components/UncontrolledInput/UncontrolledInput.messages"; +import { unitGridMessages } from "../components/UnitGrid/UnitGrid.messages"; import { commonMessages } from "./common.messages"; /** @@ -55,6 +57,7 @@ export function defineMessages<const T extends Record<UicMessageKey, MessageEntr export const uiCommonCatalog: Catalog = { ...commonMessages, ...booleanTokenMessages, + ...colorPickerMessages, ...deleteConfirmModalMessages, ...notificationStackMessages, ...numberStepperMessages, @@ -64,4 +67,5 @@ export const uiCommonCatalog: Catalog = { ...statisticMessages, ...tokenRowMessages, ...uncontrolledInputMessages, + ...unitGridMessages, }; diff --git a/src/i18n/locales/de-DE.json b/src/i18n/locales/de-DE.json index ed2d3ab..09e121b 100644 --- a/src/i18n/locales/de-DE.json +++ b/src/i18n/locales/de-DE.json @@ -11,6 +11,22 @@ "defaultMessage": "Löschen", "description": "Generic label of a button that deletes something" }, + "uic.ColorPicker.label": { + "defaultMessage": "Farbe auswählen", + "description": "Accessible name of a color picker whose caller gave it no label" + }, + "uic.ColorPicker.hexValue": { + "defaultMessage": "Hex-Wert", + "description": "Hidden label of the color picker's text field for a #rrggbb value" + }, + "uic.ColorPicker.clear": { + "defaultMessage": "Löschen", + "description": "Button in a color picker that removes the chosen color" + }, + "uic.ColorPicker.noColor": { + "defaultMessage": "Keine Farbe", + "description": "Shown on a color picker's trigger when no color is set" + }, "uic.DeleteConfirmModal.title": { "defaultMessage": "Löschen", "description": "Title of the dialog that confirms deleting one item" @@ -66,5 +82,17 @@ "uic.UncontrolledInput.label": { "defaultMessage": "Auswählen", "description": "Hidden accessible name of an input whose caller gave it no label of its own" + }, + "uic.UnitGrid.label": { + "defaultMessage": "Ressourcenraster", + "description": "Accessible name of a unit grid whose caller gave it no aria-label" + }, + "uic.UnitGrid.changeGroupColor": { + "defaultMessage": "Gruppenfarbe ändern", + "description": "Button in a unit grid's hover card that opens the palette for the group" + }, + "uic.UnitGrid.useColor": { + "defaultMessage": "Farbe {index} verwenden", + "description": "Accessible name of one palette swatch in a unit grid; {index} is its 1-based position" } } diff --git a/src/i18n/locales/el-GR.json b/src/i18n/locales/el-GR.json index 659459f..50861c4 100644 --- a/src/i18n/locales/el-GR.json +++ b/src/i18n/locales/el-GR.json @@ -11,6 +11,22 @@ "defaultMessage": "Διαγράφω", "description": "Generic label of a button that deletes something" }, + "uic.ColorPicker.label": { + "defaultMessage": "Επιλογή χρώματος", + "description": "Accessible name of a color picker whose caller gave it no label" + }, + "uic.ColorPicker.hexValue": { + "defaultMessage": "Δεκαεξαδική τιμή", + "description": "Hidden label of the color picker's text field for a #rrggbb value" + }, + "uic.ColorPicker.clear": { + "defaultMessage": "Καθαρισμός", + "description": "Button in a color picker that removes the chosen color" + }, + "uic.ColorPicker.noColor": { + "defaultMessage": "Χωρίς χρώμα", + "description": "Shown on a color picker's trigger when no color is set" + }, "uic.DeleteConfirmModal.title": { "defaultMessage": "Διαγραφή", "description": "Title of the dialog that confirms deleting one item" @@ -66,5 +82,17 @@ "uic.UncontrolledInput.label": { "defaultMessage": "Επιλογή", "description": "Hidden accessible name of an input whose caller gave it no label of its own" + }, + "uic.UnitGrid.label": { + "defaultMessage": "Πλέγμα πόρων", + "description": "Accessible name of a unit grid whose caller gave it no aria-label" + }, + "uic.UnitGrid.changeGroupColor": { + "defaultMessage": "Αλλαγή χρώματος ομάδας", + "description": "Button in a unit grid's hover card that opens the palette for the group" + }, + "uic.UnitGrid.useColor": { + "defaultMessage": "Χρήση χρώματος {index}", + "description": "Accessible name of one palette swatch in a unit grid; {index} is its 1-based position" } } diff --git a/src/i18n/locales/es-ES.json b/src/i18n/locales/es-ES.json index 5ffd0b0..b7af326 100644 --- a/src/i18n/locales/es-ES.json +++ b/src/i18n/locales/es-ES.json @@ -11,6 +11,22 @@ "defaultMessage": "Borrar", "description": "Generic label of a button that deletes something" }, + "uic.ColorPicker.label": { + "defaultMessage": "Seleccionar color", + "description": "Accessible name of a color picker whose caller gave it no label" + }, + "uic.ColorPicker.hexValue": { + "defaultMessage": "Valor hexadecimal", + "description": "Hidden label of the color picker's text field for a #rrggbb value" + }, + "uic.ColorPicker.clear": { + "defaultMessage": "Limpiar", + "description": "Button in a color picker that removes the chosen color" + }, + "uic.ColorPicker.noColor": { + "defaultMessage": "Sin color", + "description": "Shown on a color picker's trigger when no color is set" + }, "uic.DeleteConfirmModal.title": { "defaultMessage": "Eliminar", "description": "Title of the dialog that confirms deleting one item" @@ -66,5 +82,17 @@ "uic.UncontrolledInput.label": { "defaultMessage": "Seleccionar", "description": "Hidden accessible name of an input whose caller gave it no label of its own" + }, + "uic.UnitGrid.label": { + "defaultMessage": "Cuadrícula de recursos", + "description": "Accessible name of a unit grid whose caller gave it no aria-label" + }, + "uic.UnitGrid.changeGroupColor": { + "defaultMessage": "Cambiar el color del grupo", + "description": "Button in a unit grid's hover card that opens the palette for the group" + }, + "uic.UnitGrid.useColor": { + "defaultMessage": "Usar el color {index}", + "description": "Accessible name of one palette swatch in a unit grid; {index} is its 1-based position" } } diff --git a/src/i18n/locales/fi-FI.json b/src/i18n/locales/fi-FI.json index 9ebe8b2..23e151a 100644 --- a/src/i18n/locales/fi-FI.json +++ b/src/i18n/locales/fi-FI.json @@ -11,6 +11,22 @@ "defaultMessage": "Poistaa", "description": "Generic label of a button that deletes something" }, + "uic.ColorPicker.label": { + "defaultMessage": "Valitse väri", + "description": "Accessible name of a color picker whose caller gave it no label" + }, + "uic.ColorPicker.hexValue": { + "defaultMessage": "Heksa-arvo", + "description": "Hidden label of the color picker's text field for a #rrggbb value" + }, + "uic.ColorPicker.clear": { + "defaultMessage": "Tyhjennä", + "description": "Button in a color picker that removes the chosen color" + }, + "uic.ColorPicker.noColor": { + "defaultMessage": "Ei väriä", + "description": "Shown on a color picker's trigger when no color is set" + }, "uic.DeleteConfirmModal.title": { "defaultMessage": "Poista", "description": "Title of the dialog that confirms deleting one item" @@ -66,5 +82,17 @@ "uic.UncontrolledInput.label": { "defaultMessage": "Valitse", "description": "Hidden accessible name of an input whose caller gave it no label of its own" + }, + "uic.UnitGrid.label": { + "defaultMessage": "Resurssiruudukko", + "description": "Accessible name of a unit grid whose caller gave it no aria-label" + }, + "uic.UnitGrid.changeGroupColor": { + "defaultMessage": "Vaihda ryhmän väri", + "description": "Button in a unit grid's hover card that opens the palette for the group" + }, + "uic.UnitGrid.useColor": { + "defaultMessage": "Käytä väriä {index}", + "description": "Accessible name of one palette swatch in a unit grid; {index} is its 1-based position" } } diff --git a/src/i18n/locales/fr-FR.json b/src/i18n/locales/fr-FR.json index 3d7d622..7863138 100644 --- a/src/i18n/locales/fr-FR.json +++ b/src/i18n/locales/fr-FR.json @@ -11,6 +11,22 @@ "defaultMessage": "Supprimer", "description": "Generic label of a button that deletes something" }, + "uic.ColorPicker.label": { + "defaultMessage": "Sélectionner une couleur", + "description": "Accessible name of a color picker whose caller gave it no label" + }, + "uic.ColorPicker.hexValue": { + "defaultMessage": "Valeur hexadécimale", + "description": "Hidden label of the color picker's text field for a #rrggbb value" + }, + "uic.ColorPicker.clear": { + "defaultMessage": "Effacer", + "description": "Button in a color picker that removes the chosen color" + }, + "uic.ColorPicker.noColor": { + "defaultMessage": "Aucune couleur", + "description": "Shown on a color picker's trigger when no color is set" + }, "uic.DeleteConfirmModal.title": { "defaultMessage": "Supprimer", "description": "Title of the dialog that confirms deleting one item" @@ -66,5 +82,17 @@ "uic.UncontrolledInput.label": { "defaultMessage": "Sélectionner", "description": "Hidden accessible name of an input whose caller gave it no label of its own" + }, + "uic.UnitGrid.label": { + "defaultMessage": "Grille de ressources", + "description": "Accessible name of a unit grid whose caller gave it no aria-label" + }, + "uic.UnitGrid.changeGroupColor": { + "defaultMessage": "Modifier la couleur du groupe", + "description": "Button in a unit grid's hover card that opens the palette for the group" + }, + "uic.UnitGrid.useColor": { + "defaultMessage": "Utiliser la couleur {index}", + "description": "Accessible name of one palette swatch in a unit grid; {index} is its 1-based position" } } diff --git a/src/i18n/locales/id-ID.json b/src/i18n/locales/id-ID.json index 3149e3d..8141ded 100644 --- a/src/i18n/locales/id-ID.json +++ b/src/i18n/locales/id-ID.json @@ -11,6 +11,22 @@ "defaultMessage": "Menghapus", "description": "Generic label of a button that deletes something" }, + "uic.ColorPicker.label": { + "defaultMessage": "Pilih warna", + "description": "Accessible name of a color picker whose caller gave it no label" + }, + "uic.ColorPicker.hexValue": { + "defaultMessage": "Nilai heksa", + "description": "Hidden label of the color picker's text field for a #rrggbb value" + }, + "uic.ColorPicker.clear": { + "defaultMessage": "Hapus", + "description": "Button in a color picker that removes the chosen color" + }, + "uic.ColorPicker.noColor": { + "defaultMessage": "Tanpa warna", + "description": "Shown on a color picker's trigger when no color is set" + }, "uic.DeleteConfirmModal.title": { "defaultMessage": "Hapus", "description": "Title of the dialog that confirms deleting one item" @@ -66,5 +82,17 @@ "uic.UncontrolledInput.label": { "defaultMessage": "Pilih", "description": "Hidden accessible name of an input whose caller gave it no label of its own" + }, + "uic.UnitGrid.label": { + "defaultMessage": "Kisi sumber daya", + "description": "Accessible name of a unit grid whose caller gave it no aria-label" + }, + "uic.UnitGrid.changeGroupColor": { + "defaultMessage": "Ubah warna grup", + "description": "Button in a unit grid's hover card that opens the palette for the group" + }, + "uic.UnitGrid.useColor": { + "defaultMessage": "Gunakan warna {index}", + "description": "Accessible name of one palette swatch in a unit grid; {index} is its 1-based position" } } diff --git a/src/i18n/locales/it-IT.json b/src/i18n/locales/it-IT.json index aad32a0..525d9dc 100644 --- a/src/i18n/locales/it-IT.json +++ b/src/i18n/locales/it-IT.json @@ -11,6 +11,22 @@ "defaultMessage": "Eliminare", "description": "Generic label of a button that deletes something" }, + "uic.ColorPicker.label": { + "defaultMessage": "Seleziona colore", + "description": "Accessible name of a color picker whose caller gave it no label" + }, + "uic.ColorPicker.hexValue": { + "defaultMessage": "Valore esadecimale", + "description": "Hidden label of the color picker's text field for a #rrggbb value" + }, + "uic.ColorPicker.clear": { + "defaultMessage": "Cancella", + "description": "Button in a color picker that removes the chosen color" + }, + "uic.ColorPicker.noColor": { + "defaultMessage": "Nessun colore", + "description": "Shown on a color picker's trigger when no color is set" + }, "uic.DeleteConfirmModal.title": { "defaultMessage": "Elimina", "description": "Title of the dialog that confirms deleting one item" @@ -66,5 +82,17 @@ "uic.UncontrolledInput.label": { "defaultMessage": "Seleziona", "description": "Hidden accessible name of an input whose caller gave it no label of its own" + }, + "uic.UnitGrid.label": { + "defaultMessage": "Griglia delle risorse", + "description": "Accessible name of a unit grid whose caller gave it no aria-label" + }, + "uic.UnitGrid.changeGroupColor": { + "defaultMessage": "Cambia colore del gruppo", + "description": "Button in a unit grid's hover card that opens the palette for the group" + }, + "uic.UnitGrid.useColor": { + "defaultMessage": "Usa il colore {index}", + "description": "Accessible name of one palette swatch in a unit grid; {index} is its 1-based position" } } diff --git a/src/i18n/locales/ja-JP.json b/src/i18n/locales/ja-JP.json index e28a2fd..be383d2 100644 --- a/src/i18n/locales/ja-JP.json +++ b/src/i18n/locales/ja-JP.json @@ -27,6 +27,22 @@ "defaultMessage": "いいえ", "description": "Default label of a token showing a boolean value that is false" }, + "uic.ColorPicker.label": { + "defaultMessage": "色を選択", + "description": "Accessible name of a color picker whose caller gave it no label" + }, + "uic.ColorPicker.hexValue": { + "defaultMessage": "16進数の値", + "description": "Hidden label of the color picker's text field for a #rrggbb value" + }, + "uic.ColorPicker.clear": { + "defaultMessage": "クリア", + "description": "Button in a color picker that removes the chosen color" + }, + "uic.ColorPicker.noColor": { + "defaultMessage": "色なし", + "description": "Shown on a color picker's trigger when no color is set" + }, "uic.DeleteConfirmModal.title": { "defaultMessage": "削除", "description": "Title of the dialog that confirms deleting one item" @@ -106,5 +122,17 @@ "uic.UncontrolledInput.label": { "defaultMessage": "選択", "description": "Hidden accessible name of an input whose caller gave it no label of its own" + }, + "uic.UnitGrid.label": { + "defaultMessage": "リソースグリッド", + "description": "Accessible name of a unit grid whose caller gave it no aria-label" + }, + "uic.UnitGrid.changeGroupColor": { + "defaultMessage": "グループの色を変更", + "description": "Button in a unit grid's hover card that opens the palette for the group" + }, + "uic.UnitGrid.useColor": { + "defaultMessage": "色{index}を使用", + "description": "Accessible name of one palette swatch in a unit grid; {index} is its 1-based position" } } diff --git a/src/i18n/locales/ko-KR.json b/src/i18n/locales/ko-KR.json index b5d2ccd..f8d43bf 100644 --- a/src/i18n/locales/ko-KR.json +++ b/src/i18n/locales/ko-KR.json @@ -27,6 +27,22 @@ "defaultMessage": "아니요", "description": "Default label of a token showing a boolean value that is false" }, + "uic.ColorPicker.label": { + "defaultMessage": "색상 선택", + "description": "Accessible name of a color picker whose caller gave it no label" + }, + "uic.ColorPicker.hexValue": { + "defaultMessage": "16진수 값", + "description": "Hidden label of the color picker's text field for a #rrggbb value" + }, + "uic.ColorPicker.clear": { + "defaultMessage": "지우기", + "description": "Button in a color picker that removes the chosen color" + }, + "uic.ColorPicker.noColor": { + "defaultMessage": "색상 없음", + "description": "Shown on a color picker's trigger when no color is set" + }, "uic.DeleteConfirmModal.title": { "defaultMessage": "삭제", "description": "Title of the dialog that confirms deleting one item" @@ -106,5 +122,17 @@ "uic.UncontrolledInput.label": { "defaultMessage": "선택", "description": "Hidden accessible name of an input whose caller gave it no label of its own" + }, + "uic.UnitGrid.label": { + "defaultMessage": "자원 그리드", + "description": "Accessible name of a unit grid whose caller gave it no aria-label" + }, + "uic.UnitGrid.changeGroupColor": { + "defaultMessage": "그룹 색상 변경", + "description": "Button in a unit grid's hover card that opens the palette for the group" + }, + "uic.UnitGrid.useColor": { + "defaultMessage": "색상 {index} 사용", + "description": "Accessible name of one palette swatch in a unit grid; {index} is its 1-based position" } } diff --git a/src/i18n/locales/mn-MN.json b/src/i18n/locales/mn-MN.json index 025c671..45dc513 100644 --- a/src/i18n/locales/mn-MN.json +++ b/src/i18n/locales/mn-MN.json @@ -11,6 +11,22 @@ "defaultMessage": "Эдгээх", "description": "Generic label of a button that deletes something" }, + "uic.ColorPicker.label": { + "defaultMessage": "Өнгө сонгох", + "description": "Accessible name of a color picker whose caller gave it no label" + }, + "uic.ColorPicker.hexValue": { + "defaultMessage": "Hex утга", + "description": "Hidden label of the color picker's text field for a #rrggbb value" + }, + "uic.ColorPicker.clear": { + "defaultMessage": "Цэвэрлэх", + "description": "Button in a color picker that removes the chosen color" + }, + "uic.ColorPicker.noColor": { + "defaultMessage": "Өнгөгүй", + "description": "Shown on a color picker's trigger when no color is set" + }, "uic.DeleteConfirmModal.title": { "defaultMessage": "Устгах", "description": "Title of the dialog that confirms deleting one item" @@ -66,5 +82,17 @@ "uic.UncontrolledInput.label": { "defaultMessage": "Сонгох", "description": "Hidden accessible name of an input whose caller gave it no label of its own" + }, + "uic.UnitGrid.label": { + "defaultMessage": "Нөөцийн тор", + "description": "Accessible name of a unit grid whose caller gave it no aria-label" + }, + "uic.UnitGrid.changeGroupColor": { + "defaultMessage": "Бүлгийн өнгийг өөрчлөх", + "description": "Button in a unit grid's hover card that opens the palette for the group" + }, + "uic.UnitGrid.useColor": { + "defaultMessage": "Өнгө {index} ашиглах", + "description": "Accessible name of one palette swatch in a unit grid; {index} is its 1-based position" } } diff --git a/src/i18n/locales/ms-MY.json b/src/i18n/locales/ms-MY.json index f54c15d..62e5261 100644 --- a/src/i18n/locales/ms-MY.json +++ b/src/i18n/locales/ms-MY.json @@ -11,6 +11,22 @@ "defaultMessage": "Padam", "description": "Generic label of a button that deletes something" }, + "uic.ColorPicker.label": { + "defaultMessage": "Pilih warna", + "description": "Accessible name of a color picker whose caller gave it no label" + }, + "uic.ColorPicker.hexValue": { + "defaultMessage": "Nilai heks", + "description": "Hidden label of the color picker's text field for a #rrggbb value" + }, + "uic.ColorPicker.clear": { + "defaultMessage": "Kosongkan", + "description": "Button in a color picker that removes the chosen color" + }, + "uic.ColorPicker.noColor": { + "defaultMessage": "Tiada warna", + "description": "Shown on a color picker's trigger when no color is set" + }, "uic.DeleteConfirmModal.title": { "defaultMessage": "Padam", "description": "Title of the dialog that confirms deleting one item" @@ -66,5 +82,17 @@ "uic.UncontrolledInput.label": { "defaultMessage": "Pilih", "description": "Hidden accessible name of an input whose caller gave it no label of its own" + }, + "uic.UnitGrid.label": { + "defaultMessage": "Grid sumber", + "description": "Accessible name of a unit grid whose caller gave it no aria-label" + }, + "uic.UnitGrid.changeGroupColor": { + "defaultMessage": "Tukar warna kumpulan", + "description": "Button in a unit grid's hover card that opens the palette for the group" + }, + "uic.UnitGrid.useColor": { + "defaultMessage": "Guna warna {index}", + "description": "Accessible name of one palette swatch in a unit grid; {index} is its 1-based position" } } diff --git a/src/i18n/locales/pl-PL.json b/src/i18n/locales/pl-PL.json index 0203840..83075d9 100644 --- a/src/i18n/locales/pl-PL.json +++ b/src/i18n/locales/pl-PL.json @@ -11,6 +11,22 @@ "defaultMessage": "Usuwać", "description": "Generic label of a button that deletes something" }, + "uic.ColorPicker.label": { + "defaultMessage": "Wybierz kolor", + "description": "Accessible name of a color picker whose caller gave it no label" + }, + "uic.ColorPicker.hexValue": { + "defaultMessage": "Wartość szesnastkowa", + "description": "Hidden label of the color picker's text field for a #rrggbb value" + }, + "uic.ColorPicker.clear": { + "defaultMessage": "Wyczyść", + "description": "Button in a color picker that removes the chosen color" + }, + "uic.ColorPicker.noColor": { + "defaultMessage": "Brak koloru", + "description": "Shown on a color picker's trigger when no color is set" + }, "uic.DeleteConfirmModal.title": { "defaultMessage": "Usuń", "description": "Title of the dialog that confirms deleting one item" @@ -66,5 +82,17 @@ "uic.UncontrolledInput.label": { "defaultMessage": "Wybierz", "description": "Hidden accessible name of an input whose caller gave it no label of its own" + }, + "uic.UnitGrid.label": { + "defaultMessage": "Siatka zasobów", + "description": "Accessible name of a unit grid whose caller gave it no aria-label" + }, + "uic.UnitGrid.changeGroupColor": { + "defaultMessage": "Zmień kolor grupy", + "description": "Button in a unit grid's hover card that opens the palette for the group" + }, + "uic.UnitGrid.useColor": { + "defaultMessage": "Użyj koloru {index}", + "description": "Accessible name of one palette swatch in a unit grid; {index} is its 1-based position" } } diff --git a/src/i18n/locales/pt-BR.json b/src/i18n/locales/pt-BR.json index 8f683bd..9e068e7 100644 --- a/src/i18n/locales/pt-BR.json +++ b/src/i18n/locales/pt-BR.json @@ -11,6 +11,22 @@ "defaultMessage": "Excluir", "description": "Generic label of a button that deletes something" }, + "uic.ColorPicker.label": { + "defaultMessage": "Selecionar cor", + "description": "Accessible name of a color picker whose caller gave it no label" + }, + "uic.ColorPicker.hexValue": { + "defaultMessage": "Valor hexadecimal", + "description": "Hidden label of the color picker's text field for a #rrggbb value" + }, + "uic.ColorPicker.clear": { + "defaultMessage": "Limpar", + "description": "Button in a color picker that removes the chosen color" + }, + "uic.ColorPicker.noColor": { + "defaultMessage": "Sem cor", + "description": "Shown on a color picker's trigger when no color is set" + }, "uic.DeleteConfirmModal.title": { "defaultMessage": "Excluir", "description": "Title of the dialog that confirms deleting one item" @@ -66,5 +82,17 @@ "uic.UncontrolledInput.label": { "defaultMessage": "Selecionar", "description": "Hidden accessible name of an input whose caller gave it no label of its own" + }, + "uic.UnitGrid.label": { + "defaultMessage": "Grade de recursos", + "description": "Accessible name of a unit grid whose caller gave it no aria-label" + }, + "uic.UnitGrid.changeGroupColor": { + "defaultMessage": "Alterar a cor do grupo", + "description": "Button in a unit grid's hover card that opens the palette for the group" + }, + "uic.UnitGrid.useColor": { + "defaultMessage": "Usar a cor {index}", + "description": "Accessible name of one palette swatch in a unit grid; {index} is its 1-based position" } } diff --git a/src/i18n/locales/pt-PT.json b/src/i18n/locales/pt-PT.json index b29cf88..0adea76 100644 --- a/src/i18n/locales/pt-PT.json +++ b/src/i18n/locales/pt-PT.json @@ -11,6 +11,22 @@ "defaultMessage": "Excluir", "description": "Generic label of a button that deletes something" }, + "uic.ColorPicker.label": { + "defaultMessage": "Selecionar cor", + "description": "Accessible name of a color picker whose caller gave it no label" + }, + "uic.ColorPicker.hexValue": { + "defaultMessage": "Valor hexadecimal", + "description": "Hidden label of the color picker's text field for a #rrggbb value" + }, + "uic.ColorPicker.clear": { + "defaultMessage": "Limpar", + "description": "Button in a color picker that removes the chosen color" + }, + "uic.ColorPicker.noColor": { + "defaultMessage": "Sem cor", + "description": "Shown on a color picker's trigger when no color is set" + }, "uic.DeleteConfirmModal.title": { "defaultMessage": "Eliminar", "description": "Title of the dialog that confirms deleting one item" @@ -66,5 +82,17 @@ "uic.UncontrolledInput.label": { "defaultMessage": "Selecionar", "description": "Hidden accessible name of an input whose caller gave it no label of its own" + }, + "uic.UnitGrid.label": { + "defaultMessage": "Grelha de recursos", + "description": "Accessible name of a unit grid whose caller gave it no aria-label" + }, + "uic.UnitGrid.changeGroupColor": { + "defaultMessage": "Alterar a cor do grupo", + "description": "Button in a unit grid's hover card that opens the palette for the group" + }, + "uic.UnitGrid.useColor": { + "defaultMessage": "Usar a cor {index}", + "description": "Accessible name of one palette swatch in a unit grid; {index} is its 1-based position" } } diff --git a/src/i18n/locales/ru-RU.json b/src/i18n/locales/ru-RU.json index 795455a..5917842 100644 --- a/src/i18n/locales/ru-RU.json +++ b/src/i18n/locales/ru-RU.json @@ -11,6 +11,22 @@ "defaultMessage": "Удалить", "description": "Generic label of a button that deletes something" }, + "uic.ColorPicker.label": { + "defaultMessage": "Выбрать цвет", + "description": "Accessible name of a color picker whose caller gave it no label" + }, + "uic.ColorPicker.hexValue": { + "defaultMessage": "Шестнадцатеричное значение", + "description": "Hidden label of the color picker's text field for a #rrggbb value" + }, + "uic.ColorPicker.clear": { + "defaultMessage": "Очистить", + "description": "Button in a color picker that removes the chosen color" + }, + "uic.ColorPicker.noColor": { + "defaultMessage": "Без цвета", + "description": "Shown on a color picker's trigger when no color is set" + }, "uic.DeleteConfirmModal.title": { "defaultMessage": "Удалить", "description": "Title of the dialog that confirms deleting one item" @@ -66,5 +82,17 @@ "uic.UncontrolledInput.label": { "defaultMessage": "Выбрать", "description": "Hidden accessible name of an input whose caller gave it no label of its own" + }, + "uic.UnitGrid.label": { + "defaultMessage": "Сетка ресурсов", + "description": "Accessible name of a unit grid whose caller gave it no aria-label" + }, + "uic.UnitGrid.changeGroupColor": { + "defaultMessage": "Изменить цвет группы", + "description": "Button in a unit grid's hover card that opens the palette for the group" + }, + "uic.UnitGrid.useColor": { + "defaultMessage": "Использовать цвет {index}", + "description": "Accessible name of one palette swatch in a unit grid; {index} is its 1-based position" } } diff --git a/src/i18n/locales/th-TH.json b/src/i18n/locales/th-TH.json index 3ae478a..5d23fe9 100644 --- a/src/i18n/locales/th-TH.json +++ b/src/i18n/locales/th-TH.json @@ -11,6 +11,22 @@ "defaultMessage": "ลบ", "description": "Generic label of a button that deletes something" }, + "uic.ColorPicker.label": { + "defaultMessage": "เลือกสี", + "description": "Accessible name of a color picker whose caller gave it no label" + }, + "uic.ColorPicker.hexValue": { + "defaultMessage": "ค่าเลขฐานสิบหก", + "description": "Hidden label of the color picker's text field for a #rrggbb value" + }, + "uic.ColorPicker.clear": { + "defaultMessage": "ล้าง", + "description": "Button in a color picker that removes the chosen color" + }, + "uic.ColorPicker.noColor": { + "defaultMessage": "ไม่มีสี", + "description": "Shown on a color picker's trigger when no color is set" + }, "uic.DeleteConfirmModal.title": { "defaultMessage": "ลบ", "description": "Title of the dialog that confirms deleting one item" @@ -66,5 +82,17 @@ "uic.UncontrolledInput.label": { "defaultMessage": "เลือก", "description": "Hidden accessible name of an input whose caller gave it no label of its own" + }, + "uic.UnitGrid.label": { + "defaultMessage": "ตารางทรัพยากร", + "description": "Accessible name of a unit grid whose caller gave it no aria-label" + }, + "uic.UnitGrid.changeGroupColor": { + "defaultMessage": "เปลี่ยนสีกลุ่ม", + "description": "Button in a unit grid's hover card that opens the palette for the group" + }, + "uic.UnitGrid.useColor": { + "defaultMessage": "ใช้สี {index}", + "description": "Accessible name of one palette swatch in a unit grid; {index} is its 1-based position" } } diff --git a/src/i18n/locales/tr-TR.json b/src/i18n/locales/tr-TR.json index abe4cd5..632e712 100644 --- a/src/i18n/locales/tr-TR.json +++ b/src/i18n/locales/tr-TR.json @@ -11,6 +11,22 @@ "defaultMessage": "Silmek", "description": "Generic label of a button that deletes something" }, + "uic.ColorPicker.label": { + "defaultMessage": "Renk seç", + "description": "Accessible name of a color picker whose caller gave it no label" + }, + "uic.ColorPicker.hexValue": { + "defaultMessage": "Onaltılık değer", + "description": "Hidden label of the color picker's text field for a #rrggbb value" + }, + "uic.ColorPicker.clear": { + "defaultMessage": "Temizle", + "description": "Button in a color picker that removes the chosen color" + }, + "uic.ColorPicker.noColor": { + "defaultMessage": "Renk yok", + "description": "Shown on a color picker's trigger when no color is set" + }, "uic.DeleteConfirmModal.title": { "defaultMessage": "Sil", "description": "Title of the dialog that confirms deleting one item" @@ -66,5 +82,17 @@ "uic.UncontrolledInput.label": { "defaultMessage": "Seç", "description": "Hidden accessible name of an input whose caller gave it no label of its own" + }, + "uic.UnitGrid.label": { + "defaultMessage": "Kaynak ızgarası", + "description": "Accessible name of a unit grid whose caller gave it no aria-label" + }, + "uic.UnitGrid.changeGroupColor": { + "defaultMessage": "Grup rengini değiştir", + "description": "Button in a unit grid's hover card that opens the palette for the group" + }, + "uic.UnitGrid.useColor": { + "defaultMessage": "Renk {index} kullan", + "description": "Accessible name of one palette swatch in a unit grid; {index} is its 1-based position" } } diff --git a/src/i18n/locales/vi-VN.json b/src/i18n/locales/vi-VN.json index 2544060..f6004ef 100644 --- a/src/i18n/locales/vi-VN.json +++ b/src/i18n/locales/vi-VN.json @@ -11,6 +11,22 @@ "defaultMessage": "Xóa bỏ", "description": "Generic label of a button that deletes something" }, + "uic.ColorPicker.label": { + "defaultMessage": "Chọn màu", + "description": "Accessible name of a color picker whose caller gave it no label" + }, + "uic.ColorPicker.hexValue": { + "defaultMessage": "Giá trị hex", + "description": "Hidden label of the color picker's text field for a #rrggbb value" + }, + "uic.ColorPicker.clear": { + "defaultMessage": "Xóa", + "description": "Button in a color picker that removes the chosen color" + }, + "uic.ColorPicker.noColor": { + "defaultMessage": "Không có màu", + "description": "Shown on a color picker's trigger when no color is set" + }, "uic.DeleteConfirmModal.title": { "defaultMessage": "Xóa", "description": "Title of the dialog that confirms deleting one item" @@ -66,5 +82,17 @@ "uic.UncontrolledInput.label": { "defaultMessage": "Chọn", "description": "Hidden accessible name of an input whose caller gave it no label of its own" + }, + "uic.UnitGrid.label": { + "defaultMessage": "Lưới tài nguyên", + "description": "Accessible name of a unit grid whose caller gave it no aria-label" + }, + "uic.UnitGrid.changeGroupColor": { + "defaultMessage": "Đổi màu nhóm", + "description": "Button in a unit grid's hover card that opens the palette for the group" + }, + "uic.UnitGrid.useColor": { + "defaultMessage": "Dùng màu {index}", + "description": "Accessible name of one palette swatch in a unit grid; {index} is its 1-based position" } } diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index d539cd3..9a6c2f6 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -11,6 +11,22 @@ "defaultMessage": "删除", "description": "Generic label of a button that deletes something" }, + "uic.ColorPicker.label": { + "defaultMessage": "选择颜色", + "description": "Accessible name of a color picker whose caller gave it no label" + }, + "uic.ColorPicker.hexValue": { + "defaultMessage": "十六进制值", + "description": "Hidden label of the color picker's text field for a #rrggbb value" + }, + "uic.ColorPicker.clear": { + "defaultMessage": "清除", + "description": "Button in a color picker that removes the chosen color" + }, + "uic.ColorPicker.noColor": { + "defaultMessage": "无颜色", + "description": "Shown on a color picker's trigger when no color is set" + }, "uic.DeleteConfirmModal.title": { "defaultMessage": "删除", "description": "Title of the dialog that confirms deleting one item" @@ -66,5 +82,17 @@ "uic.UncontrolledInput.label": { "defaultMessage": "选择", "description": "Hidden accessible name of an input whose caller gave it no label of its own" + }, + "uic.UnitGrid.label": { + "defaultMessage": "资源网格", + "description": "Accessible name of a unit grid whose caller gave it no aria-label" + }, + "uic.UnitGrid.changeGroupColor": { + "defaultMessage": "更改分组颜色", + "description": "Button in a unit grid's hover card that opens the palette for the group" + }, + "uic.UnitGrid.useColor": { + "defaultMessage": "使用颜色 {index}", + "description": "Accessible name of one palette swatch in a unit grid; {index} is its 1-based position" } } diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index eeac58c..7ce433b 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -11,6 +11,22 @@ "defaultMessage": "刪除", "description": "Generic label of a button that deletes something" }, + "uic.ColorPicker.label": { + "defaultMessage": "選擇顏色", + "description": "Accessible name of a color picker whose caller gave it no label" + }, + "uic.ColorPicker.hexValue": { + "defaultMessage": "十六進位值", + "description": "Hidden label of the color picker's text field for a #rrggbb value" + }, + "uic.ColorPicker.clear": { + "defaultMessage": "清除", + "description": "Button in a color picker that removes the chosen color" + }, + "uic.ColorPicker.noColor": { + "defaultMessage": "無顏色", + "description": "Shown on a color picker's trigger when no color is set" + }, "uic.DeleteConfirmModal.title": { "defaultMessage": "刪除", "description": "Title of the dialog that confirms deleting one item" @@ -66,5 +82,17 @@ "uic.UncontrolledInput.label": { "defaultMessage": "選擇", "description": "Hidden accessible name of an input whose caller gave it no label of its own" + }, + "uic.UnitGrid.label": { + "defaultMessage": "資源網格", + "description": "Accessible name of a unit grid whose caller gave it no aria-label" + }, + "uic.UnitGrid.changeGroupColor": { + "defaultMessage": "變更群組顏色", + "description": "Button in a unit grid's hover card that opens the palette for the group" + }, + "uic.UnitGrid.useColor": { + "defaultMessage": "使用顏色 {index}", + "description": "Accessible name of one palette swatch in a unit grid; {index} is its 1-based position" } } diff --git a/src/index.ts b/src/index.ts index 7ec69c8..e7b2ad7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1321,6 +1321,10 @@ export type { BoardItemTitleProps } from "./components/BoardItemTitle"; export { BooleanToken } from "./components/BooleanToken"; export type { BooleanTokenProps } from "./components/BooleanToken"; +// ColorPicker +export { ColorPicker, toHexColor } from "./components/ColorPicker"; +export type { ColorPickerProps } from "./components/ColorPicker"; + // ConfirmPopover export { ConfirmPopover } from "./components/ConfirmPopover"; export type { ConfirmPopoverProps } from "./components/ConfirmPopover"; @@ -1471,5 +1475,16 @@ export type { TokenRowItem, TokenRowProps } from "./components/TokenRow"; export { UncontrolledInput } from "./components/UncontrolledInput"; export type { UncontrolledInputProps } from "./components/UncontrolledInput"; +// UnitGrid +export { UnitGrid, UnitGridSkeleton } from "./components/UnitGrid"; +export type { + UnitGridGroup, + UnitGridLayout, + UnitGridLegendItem, + UnitGridProps, + UnitGridSkeletonProps, + UnitGridUnit, +} from "./components/UnitGrid"; + // usePrefersReducedMotion export { usePrefersReducedMotion } from "./hooks"; From b311ea69b3b30190d212f4fb49a3d2114d49e276 Mon Sep 17 00:00:00 2001 From: Jongeun <jongeun@lablup.com> Date: Fri, 25 Sep 2026 22:13:42 +0000 Subject: [PATCH 38/87] chore: prepare 0.2.0-alpha.6 --- CHANGELOG.md | 5 +++++ package.json | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cddcc12..7123bdd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ Versioning follows the policy in [CONTRIBUTING.md](CONTRIBUTING.md#versioning). ## [Unreleased] +## [0.2.0-alpha.6] + +The last component moves from backend.ai-ui that do not wait on its theme +shim: its unit grid and its colour picker. + ### Added - **Components moved from backend.ai-ui**, with Astryx-shaped props and diff --git a/package.json b/package.json index 1491de6..be59a30 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@lablup/ui-common", - "version": "0.2.0-alpha.5", + "version": "0.2.0-alpha.6", "description": "Lablup's UI layer on Astryx: the Astryx surface re-exported 1:1, the Lablup theme, and shared components", "license": "Apache-2.0", "author": "Lablup Inc.", From 67c3fe3918417b572fedc8634845c55e04ca403f Mon Sep 17 00:00:00 2001 From: Jongeun <jongeun@lablup.com> Date: Sat, 26 Sep 2026 06:27:45 +0000 Subject: [PATCH 39/87] fix(codemods): keep the Drawer onClose handler's own reads when wrapping it The onClose -> onOpenChange wrapper inlined the old body under a fixed `isOpen` parameter, so a handler that read the consumer's own `isOpen` (`() => { if (isOpen) close(); }`) read the new argument instead and never ran. The parameter now takes a name no identifier in the file spells, and only an arrow body is inlined; anything else is called with no arguments, as 0.1 called onClose. --- codemods/0.2/components.mjs | 10 ++- codemods/0.2/elements.mjs | 16 +++-- codemods/lib/jsx.mjs | 37 +++++++++++ test/upgrade/codemods.test.ts | 114 ++++++++++++++++++++++++++++++++++ 4 files changed, 171 insertions(+), 6 deletions(-) create mode 100644 test/upgrade/codemods.test.ts diff --git a/codemods/0.2/components.mjs b/codemods/0.2/components.mjs index 55adeb9..214b8f0 100644 --- a/codemods/0.2/components.mjs +++ b/codemods/0.2/components.mjs @@ -9,7 +9,13 @@ * - Re-exports (`export { Select } from …`) keep their public name. * - Every JSX element of a moved component goes through ./elements.mjs. */ -import { hasSpread, renameElement, tagName } from "../lib/jsx.mjs"; +import { + freeName, + hasSpread, + identifierNames, + renameElement, + tagName, +} from "../lib/jsx.mjs"; import { addTodo } from "../lib/todo.mjs"; import { ELEMENT_TRANSFORMS } from "./elements.mjs"; import { MOVED, REMOVED, REMOVED_TYPES, UIC } from "./map.mjs"; @@ -517,6 +523,7 @@ export default function transform(file, api, ctx) { if (!touched) return undefined; // Elements. + const spelled = identifierNames(j, root); for (const [local, binding] of bindings) { const transformElement = /** @type {Record<string, any>} */ (ELEMENT_TRANSFORMS)[ binding.component @@ -536,6 +543,7 @@ export default function transform(file, api, ctx) { setTag: (name) => { tag = name; }, + freeName: (candidates) => freeName(spelled, candidates), }; if (hasSpread(el)) { addTodo( diff --git a/codemods/0.2/elements.mjs b/codemods/0.2/elements.mjs index 61542ba..5e830d3 100644 --- a/codemods/0.2/elements.mjs +++ b/codemods/0.2/elements.mjs @@ -33,6 +33,8 @@ import { manualNote } from "./map.mjs"; * @property {(message: string) => void} todo * @property {(name: string, subpath: string) => string} ensureImport * @property {(name: string) => void} setTag switch to an alternative component + * @property {(candidates: string[]) => string} freeName the first candidate no + * identifier in the file uses, else the last one numbered * @property {boolean} isTS */ @@ -646,13 +648,17 @@ export function Drawer(h) { const onClose = getAttr(el, "onClose"); if (onClose) { // The map: onClose -> onOpenChange, wrapped as (open) => { if (!open) onClose(); }. + // 0.1 called onClose with no arguments. The parameter takes a name no + // identifier in the file uses, so the handler's own reads (an `isOpen` + // prop, say) keep their meaning. const handler = attrExpression(j, onClose); - const isOpen = j.identifier("isOpen"); - const notOpen = j.unaryExpression("!", isOpen); + const param = j.identifier(h.freeName(["open", "isOpen", "nextOpen"])); + const notOpen = j.unaryExpression("!", param); let body; + // Only an arrow's body is inlined: `this` and `arguments` mean the same + // inside another arrow, which a `function` body's would not. if ( - (handler.type === "ArrowFunctionExpression" || - handler.type === "FunctionExpression") && + handler.type === "ArrowFunctionExpression" && handler.params.length === 0 && !handler.async ) { @@ -675,7 +681,7 @@ export function Drawer(h) { makeAttr( j, "onOpenChange", - j.arrowFunctionExpression([isOpen], j.blockStatement([body])), + j.arrowFunctionExpression([param], j.blockStatement([body])), ), ); } diff --git a/codemods/lib/jsx.mjs b/codemods/lib/jsx.mjs index 32b4ebb..b2ddcb2 100644 --- a/codemods/lib/jsx.mjs +++ b/codemods/lib/jsx.mjs @@ -291,3 +291,40 @@ export function renameElement(el, name) { el.openingElement.name = { type: "JSXIdentifier", name }; if (el.closingElement) el.closingElement.name = { type: "JSXIdentifier", name }; } + +/** + * Every identifier the file spells: bindings, references, property keys and + * JSX tag names (attribute names bind nothing, so they are left out). A name + * outside this set can be introduced anywhere in the file without shadowing + * or capturing anything. + * + * @param {any} j + * @param {any} root + * @returns {Set<string>} + */ +export function identifierNames(j, root) { + const names = new Set(); + root.find(j.Identifier).forEach((/** @type {any} */ p) => { + if (p.node.type === "JSXIdentifier" && p.parent?.node.type === "JSXAttribute") + return; + names.add(p.node.name); + }); + return names; +} + +/** + * The first of `candidates` that `taken` does not hold, else the last one + * numbered (`nextOpen2`, …). The name is added to `taken`. + * + * @param {Set<string>} taken + * @param {string[]} candidates + */ +export function freeName(taken, candidates) { + let name = candidates.find((c) => !taken.has(c)); + const base = candidates[candidates.length - 1]; + for (let i = 2; name === undefined; i++) { + if (!taken.has(`${base}${i}`)) name = `${base}${i}`; + } + taken.add(name); + return name; +} diff --git a/test/upgrade/codemods.test.ts b/test/upgrade/codemods.test.ts new file mode 100644 index 0000000..c84742e --- /dev/null +++ b/test/upgrade/codemods.test.ts @@ -0,0 +1,114 @@ +/** + * The 0.1 -> 0.2 component codemod on single files, for the cases where the + * output has to be read for its meaning rather than compared: the Drawer + * `onClose` wrapper is evaluated, and the renames are checked against the + * scopes the file declares. + */ +import jscodeshift from "jscodeshift"; +import { describe, expect, it, vi } from "vitest"; + +import transformComponents from "../../codemods/0.2/components.mjs"; + +const j = jscodeshift.withParser("tsx"); + +function upgrade(source: string) { + const ctx = { flags: { packages: new Map(), touched: new Set() } }; + const out = transformComponents( + { path: "src/A.tsx", source }, + { jscodeshift: j }, + ctx, + ); + if (out == null) throw new Error("the codemod left the file alone"); + return out as string; +} + +/** The source of `name`'s value on the first element that carries it. */ +function attributeSource(source: string, name: string) { + const attr = j(source).find(j.JSXAttribute, { name: { name } }).paths()[0]; + if (!attr) throw new Error(`no ${name} attribute in:\n${source}`); + return j(attr.node.value.expression).toSource(); +} + +/** + * Evaluate the migrated `onOpenChange` with `scope` bound, the way the + * consumer's component would see it. + */ +function onOpenChange(source: string, scope: Record<string, unknown>) { + const names = Object.keys(scope); + const fn = new Function( + ...names, + `return (${attributeSource(source, "onOpenChange")});`, + ); + return fn(...names.map((n) => scope[n])) as (open: boolean) => void; +} + +describe("Drawer onClose -> onOpenChange", () => { + it("keeps a handler that reads the consumer's own isOpen", () => { + const out = upgrade( + `import { Drawer } from "@lablup/ui-common"; +export function P({ isOpen, close }: { isOpen: boolean; close: () => void }) { + return <Drawer open={isOpen} onClose={() => { if (isOpen) close(); }}>x</Drawer>; +} +`, + ); + const close = vi.fn(); + const handler = onOpenChange(out, { isOpen: true, close }); + handler(true); + expect(close).not.toHaveBeenCalled(); + handler(false); + expect(close).toHaveBeenCalledTimes(1); + }); + + it("keeps an expression body that toggles on isOpen", () => { + const out = upgrade( + `import { Drawer } from "@lablup/ui-common"; +export function P({ isOpen, setIsOpen }: { isOpen: boolean; setIsOpen: (v: boolean) => void }) { + return <Drawer open={isOpen} onClose={() => setIsOpen(!isOpen)}>x</Drawer>; +} +`, + ); + const setIsOpen = vi.fn(); + onOpenChange(out, { isOpen: true, setIsOpen })(false); + expect(setIsOpen).toHaveBeenCalledWith(false); + }); + + it("never names its parameter after anything the file binds or reads", () => { + const source = `import { Drawer } from "@lablup/ui-common"; +const next = 1; +export function P({ open, isOpen, nextOpen, close }: any) { + return <Drawer open={open} onClose={() => { if (open && isOpen && nextOpen && next) close(); }}>x</Drawer>; +} +`; + const out = upgrade(source); + const param = j(attributeSource(out, "onOpenChange")) + .find(j.ArrowFunctionExpression) + .paths()[0]!.node.params[0].name as string; + for (const taken of ["next", "open", "isOpen", "nextOpen", "close", "Drawer", "P"]) + expect(param).not.toBe(taken); + const close = vi.fn(); + onOpenChange(out, { open: true, isOpen: true, nextOpen: true, next: 1, close })( + false, + ); + expect(close).toHaveBeenCalledTimes(1); + }); + + it("calls a referenced or parameterised handler with no arguments, as 0.1 did", () => { + const byName = upgrade( + `import { Drawer } from "@lablup/ui-common"; +export const P = ({ close }: any) => <Drawer onClose={close}>x</Drawer>; +`, + ); + const close = vi.fn(); + onOpenChange(byName, { close })(false); + expect(close).toHaveBeenCalledWith(); + + const withParam = upgrade( + `import { Drawer } from "@lablup/ui-common"; +export const P = ({ log }: any) => <Drawer onClose={(reason = "closed") => log(reason)}>x</Drawer>; +`, + ); + const log = vi.fn(); + onOpenChange(withParam, { log })(false); + expect(log).toHaveBeenCalledWith("closed"); + }); +}); From 5129b0b3121c1f94f391225dbf09cab6a0b307f4 Mon Sep 17 00:00:00 2001 From: Jongeun <jongeun@lablup.com> Date: Sat, 26 Sep 2026 06:30:37 +0000 Subject: [PATCH 40/87] fix(codemods): check component renames against every scope in the file The rename of a moved 0.1 component (BaseCard -> Card, Tabs -> TabList) checked only module-level bindings. A function-local `const Card` or `const TabList` then captured the renamed import, and a local that shadowed the 0.1 name (a `const Select`, a `StatusTag` parameter) was migrated and renamed as if it were ui-common's. The new name is now checked against every name bound or read anywhere in the file; when one uses it, the import takes a free `Uic`-prefixed alias (`Card as UicCard`). Elements, value references and renames are applied only where the name resolves to the module-level import. --- codemods/0.2/components.mjs | 89 +++++++++++++++++++++++------------ test/upgrade/codemods.test.ts | 67 ++++++++++++++++++++++++++ 2 files changed, 126 insertions(+), 30 deletions(-) diff --git a/codemods/0.2/components.mjs b/codemods/0.2/components.mjs index 214b8f0..db4fb35 100644 --- a/codemods/0.2/components.mjs +++ b/codemods/0.2/components.mjs @@ -64,34 +64,47 @@ function detectQuote(source) { } /** - * Names bound at module level: every import binding, and top-level - * declarations. A new import must not shadow one. + * Names a new import binding must not take: every import binding, and every + * name bound or read in any scope of the file (a function-local `const Card`, + * a parameter, a global). A rename onto one of them would capture the + * other's references, or be captured by it. * * @param {any} j * @param {any} root */ -function boundNames(j, root) { +function takenNames(j, root) { const names = new Set(); root.find(j.ImportDeclaration).forEach((/** @type {any} */ p) => { for (const s of p.node.specifiers ?? []) if (s.local) names.add(s.local.name); }); - const program = root.find(j.Program).get().node; - for (let statement of program.body) { - if ( - statement.type === "ExportNamedDeclaration" || - statement.type === "ExportDefaultDeclaration" - ) { - statement = statement.declaration ?? statement; - } - if (statement.id?.name) names.add(statement.id.name); - if (statement.type === "VariableDeclaration") { - for (const d of statement.declarations) - if (d.id.type === "Identifier") names.add(d.id.name); + root.find(j.Identifier).forEach((/** @type {any} */ p) => { + if (p.node.type === "JSXIdentifier") { + const parent = p.parent?.node; + const isTag = + parent?.type === "JSXOpeningElement" || + parent?.type === "JSXClosingElement" || + (parent?.type === "JSXMemberExpression" && parent.object === p.node); + if (isTag) names.add(p.node.name); + return; } - } + if (isReference(p)) names.add(p.node.name); + }); return names; } +/** + * Whether `name`, read at `path`, is the module-level binding (an import) + * rather than a local that shadows it. + * + * @param {any} path + * @param {string} name + * @param {any} programScope + */ +function isModuleBinding(path, name, programScope) { + const scope = path.scope?.lookup(name); + return scope == null || scope === programScope; +} + /** * Whether an Identifier path is a reference to a binding, not a property * name, an import/export name slot, or a declaration key. @@ -129,16 +142,19 @@ function isReference(path) { } /** - * Rename every reference to a module-level binding, JSX tags included. + * Rename every reference to a module-level binding, JSX tags included. A + * local that shadows it keeps its name. * * @param {any} j * @param {any} root * @param {string} from * @param {string} to + * @param {any} programScope */ -function renameReferences(j, root, from, to) { +function renameReferences(j, root, from, to, programScope) { root.find(j.Identifier, { name: from }).forEach((/** @type {any} */ p) => { if (p.node.type === "JSXIdentifier") return; + if (!isModuleBinding(p, from, programScope)) return; const parent = p.parent?.node; if ( (parent?.type === "ObjectProperty" || parent?.type === "Property") && @@ -158,6 +174,7 @@ function renameReferences(j, root, from, to) { p.node.name = to; }); root.find(j.JSXIdentifier, { name: from }).forEach((/** @type {any} */ p) => { + if (!isModuleBinding(p, from, programScope)) return; const parent = p.parent?.node; if ( parent?.type === "JSXOpeningElement" || @@ -263,9 +280,20 @@ export default function transform(file, api, ctx) { const j = api.jscodeshift; const root = j(file.source); const isTS = /\.[cm]?tsx?$/.test(file.path); - const bound = boundNames(j, root); + const taken = takenNames(j, root); + const programScope = root.find(j.Program).get().scope; let touched = false; + /** + * The local name for an import of `target` that replaces `local`: the + * target itself when nothing in any scope of the file uses it, else a + * `Uic`-prefixed alias that is free. + * + * @param {string} target + */ + const localFor = (target) => + freeName(taken, taken.has(target) ? [`Uic${target}`] : [target]); + /** @type {Map<string, string>} */ const renames = new Map(); /** @type {Map<string, {component: string, entry: any, local: string, jsxCount: number, valueRefs: number, add: any}>} */ @@ -295,10 +323,8 @@ export default function transform(file, api, ctx) { } const existing = extra.find((a) => a.source === source && a.imported === name); if (existing) return existing.local; - let local = name; - if (bound.has(local) || [...renames.values()].includes(local)) local = `Uic${name}`; + const local = localFor(name); extra.push({ source, kind: "value", imported: name, local }); - bound.add(local); return local; }; @@ -353,9 +379,9 @@ export default function transform(file, api, ctx) { if (resolved.action === "component") { const entry = resolved.entry; let finalLocal = local; - if (local === imported && entry.to !== local && !bound.has(entry.to)) { - renames.set(local, entry.to); - finalLocal = entry.to; + if (local === imported && entry.to !== local) { + finalLocal = localFor(entry.to); + renames.set(local, finalLocal); } const add = { source: `${UIC}/${entry.subpath}`, @@ -390,9 +416,9 @@ export default function transform(file, api, ctx) { continue; } let finalLocal = local; - if (local === imported && resolved.to !== local && !bound.has(resolved.to)) { - renames.set(local, resolved.to); - finalLocal = resolved.to; + if (local === imported && resolved.to !== local) { + finalLocal = localFor(resolved.to); + renames.set(local, finalLocal); } adds.push({ source: `${UIC}/${REMOVED.get(resolved.component).subpath}`, @@ -523,12 +549,14 @@ export default function transform(file, api, ctx) { if (!touched) return undefined; // Elements. - const spelled = identifierNames(j, root); + const spelled = new Set([...identifierNames(j, root), ...taken]); for (const [local, binding] of bindings) { const transformElement = /** @type {Record<string, any>} */ (ELEMENT_TRANSFORMS)[ binding.component ]; root.findJSXElements(local).forEach((/** @type {any} */ path) => { + // A local component that shadows the import is not ui-common's. + if (!isModuleBinding(path, local, programScope)) return; const el = path.node; let tag = null; /** @type {import('./elements.mjs').Helpers} */ @@ -558,6 +586,7 @@ export default function transform(file, api, ctx) { }); root.find(j.Identifier, { name: local }).forEach((/** @type {any} */ path) => { if (path.node.type === "JSXIdentifier" || !isReference(path)) return; + if (!isModuleBinding(path, local, programScope)) return; const parent = path.parent?.node; if (parent?.type === "TSTypeQuery" || parent?.type === "TSTypeReference") return; binding.valueRefs++; @@ -569,7 +598,7 @@ export default function transform(file, api, ctx) { }); } - for (const [from, to] of renames) renameReferences(j, root, from, to); + for (const [from, to] of renames) renameReferences(j, root, from, to, programScope); // Imports: the planned moves, minus a Card import every BaseCard outgrew. const unused = new Set( diff --git a/test/upgrade/codemods.test.ts b/test/upgrade/codemods.test.ts index c84742e..981a891 100644 --- a/test/upgrade/codemods.test.ts +++ b/test/upgrade/codemods.test.ts @@ -112,3 +112,70 @@ export const P = ({ log }: any) => <Drawer onClose={(reason = "closed") => log(r expect(log).toHaveBeenCalledWith("closed"); }); }); + +describe("renames respect every scope", () => { + it("does not capture a local that already has the new name", () => { + const out = upgrade( + `import { BaseCard } from "@lablup/ui-common"; +export function Panel({ compact }: { compact: boolean }) { + const Card = compact ? "section" : "article"; + return ( + <Card className="outer"> + <BaseCard>inside</BaseCard> + </Card> + ); +} +`, + ); + expect(out).toContain('import { Card as UicCard } from "@lablup/ui-common/Card";'); + expect(out).toContain("<UicCard>inside</UicCard>"); + expect(out).toContain('<Card className="outer">'); + expect(out).toContain('const Card = compact ? "section" : "article";'); + }); + + it("does not capture a function-local binding, and leaves shadowed names alone", () => { + const out = upgrade( + `import { Tabs, Select, StatusTag } from "@lablup/ui-common"; + +export function A({ items }: { items: string[] }) { + const TabList = items.length; + return ( + <div> + <Tabs activeTab="a" onTabChange={() => {}} tabs={[]} /> + <span>{TabList}</span> + </div> + ); +} + +function B() { + const Select = (p: any) => <em>{p.children}</em>; + return <Select disabled>inner</Select>; +} + +export function C() { + return <Select disabled options={[]} aria-label="x" />; +} + +export function D({ StatusTag }: { StatusTag: any }) { + return <StatusTag state="x" />; +} +`, + ); + // A: the import takes a free alias; the local keeps its name. + expect(out).toMatch( + /import \{ TabList as UicTabList \} from "@lablup\/ui-common\/TabList";/, + ); + expect(out).toContain("<UicTabList value="); + expect(out).toContain("const TabList = items.length;"); + expect(out).toContain("<span>{TabList}</span>"); + // B: the local Select is not the import; nothing about it changes. + expect(out).toContain("const Select = (p: any) => <em>{p.children}</em>;"); + expect(out).toContain("return <Select disabled>inner</Select>;"); + // C: the module-level Select is migrated. + expect(out).toMatch(/<(Selector|UicSelector) isDisabled options=\{\[\]\}/); + // D: the parameter shadows the import. + expect(out).toContain("export function D({ StatusTag }: { StatusTag: any }) {"); + expect(out).toContain('return <StatusTag state="x" />;'); + expect(out).not.toContain("is used as a value here"); + }); +}); From 9d0d7dbd25b6c3caee940225257fd9da93b86346 Mon Sep 17 00:00:00 2001 From: Jongeun <jongeun@lablup.com> Date: Sat, 26 Sep 2026 06:34:19 +0000 Subject: [PATCH 41/87] fix(cli): never let upgrade overwrite a file it did not read or create The stylesheet-entry step checked for an existing ui-common-entry.css only among the scanned files, so an entry outside the scanned paths was overwritten, and one inside them was left alone while the script import was still pointed at it. createFile now returns the file it will write: an existing file holding the same content is reused, anything else is left alone and the entry goes to the next free numbered sibling (ui-common-entry-2.css), with a note in the report. At write time a file is written only if it still holds what the run read (or, for a new file, is still absent). The report path is overwritten only when it holds an earlier report; anything else makes upgrade exit 2 before touching the tree. --- cli/report.mjs | 8 +++- cli/upgrade.mjs | 80 +++++++++++++++++++++++++++--------- codemods/0.2/stylesheets.mjs | 10 +++-- test/upgrade/upgrade.test.ts | 63 ++++++++++++++++++++++++++++ 4 files changed, 135 insertions(+), 26 deletions(-) diff --git a/cli/report.mjs b/cli/report.mjs index d825837..b767475 100644 --- a/cli/report.mjs +++ b/cli/report.mjs @@ -4,6 +4,9 @@ * committed and diffed between runs. */ +/** The report's first line; `ui-common upgrade` overwrites only a file that starts with it. */ +export const REPORT_HEADING = "# ui-common upgrade report"; + /** @param {string} text */ function code(text) { const ticks = text.includes("`") ? "``" : "`"; @@ -31,11 +34,12 @@ function escapeCell(text) { * @param {Array<{category: string, file: string, line: number, text: string, detail?: string}>} data.findings * @param {Record<string, {title: string, help: string}>} data.categories * @param {Array<{file: string, transform: string, error: string}>} data.errors + * @param {string[]} [data.notices] where the run did something other than the usual * @param {number} data.tokenReads */ export function renderReport(data) { const out = []; - out.push("# ui-common upgrade report", ""); + out.push(REPORT_HEADING, ""); out.push( `\`ui-common upgrade\` ${data.from} → ${data.to} (installed @lablup/ui-common ${data.version})` + `${data.dryRun ? ", **dry run: nothing was written**" : ""}.`, @@ -125,7 +129,7 @@ export function renderReport(data) { out.push(""); } - const notes = data.steps.flatMap((s) => s.notes); + const notes = [...(data.notices ?? []), ...data.steps.flatMap((s) => s.notes)]; if (notes.length > 0 || data.tokenReads > 0) { out.push("## Notes", ""); for (const note of notes) out.push(`- ${note}`); diff --git a/cli/upgrade.mjs b/cli/upgrade.mjs index 90dda41..eb182c5 100644 --- a/cli/upgrade.mjs +++ b/cli/upgrade.mjs @@ -20,7 +20,7 @@ import { registeredVersions, stepsBetween } from "../codemods/registry.mjs"; import { TODO_TAG } from "../codemods/lib/jsx.mjs"; import { diffStat, unifiedDiff } from "./diff.mjs"; import { findProjectDir, ownPackageJson } from "./paths.mjs"; -import { renderReport } from "./report.mjs"; +import { renderReport, REPORT_HEADING } from "./report.mjs"; import { coerce, compare, parse } from "./semver.mjs"; const IGNORED_DIRS = new Set([ @@ -166,6 +166,18 @@ export async function runUpgrade(options) { } const files = collectFiles(roots); + // The report replaces an earlier report, never anything else. + const reportFile = resolve(cwd, options.report ?? "ui-common-upgrade-report.md"); + if ( + existsSync(reportFile) && + !readFileSync(reportFile, "utf8").startsWith(REPORT_HEADING) + ) { + warn( + `ui-common upgrade: ${relative(cwd, reportFile)} exists and is not an upgrade report; not overwriting it. Pass --report <path>.`, + ); + return { code: 2 }; + } + const { default: jscodeshift } = await import("jscodeshift"); /** @type {Map<string, {original: string, current: string, transforms: string[], created: boolean}>} */ @@ -186,31 +198,46 @@ export async function runUpgrade(options) { const packageNotes = []; /** @type {Array<{file: string, transform: string, error: string}>} */ const errors = []; + /** @type {string[]} */ + const notices = []; const ctx = { from, to, flags: { packages: new Map(), touched: new Set() }, note: (/** @type {string} */ message) => packageNotes.push(message), - createFile: (/** @type {string} */ path, /** @type {string} */ content) => { - const existing = state.get(path); - if (existing) { - if (!existing.created && existing.current !== content) { - // Never clobber a file the project already has. - errors.push({ - file: rel(path), - transform: "stylesheet-entry", - error: - "exists already; left alone. Import the 0.2 stylesheets from it by hand.", - }); + /** + * Claim a new file and return the path it will be written to. A file the + * project already has, scanned or not, is never overwritten: one holding + * exactly `content` is reused, otherwise the next free numbered sibling + * (`ui-common-entry-2.css`) is used and the report says so. + * + * @param {string} path + * @param {string} content + */ + createFile: (path, content) => { + const ext = extname(path); + const stem = path.slice(0, path.length - ext.length); + for (let n = 1; ; n++) { + const candidate = n === 1 ? path : `${stem}-${n}${ext}`; + const known = state.get(candidate); + const onDisk = + known?.current ?? + (existsSync(candidate) ? readFileSync(candidate, "utf8") : null); + if (onDisk === content) return candidate; + if (onDisk != null) continue; + if (n > 1 && !notices.some((m) => m.startsWith(`${rel(path)} `))) { + notices.push( + `${rel(path)} exists already and is not the 0.2 stylesheet entry, so it was left alone. The entry was written to ${rel(candidate)} instead, and the script that imported styles/base.css imports it.`, + ); } - return; + state.set(candidate, { + original: "", + current: content, + transforms: ["stylesheet-entry"], + created: true, + }); + return candidate; } - state.set(path, { - original: "", - current: content, - transforms: ["stylesheet-entry"], - created: true, - }); }, }; @@ -319,13 +346,24 @@ export async function runUpgrade(options) { const dryRun = Boolean(options.dryRun); if (!dryRun) { for (const c of changed) { + // Only what this run read, or a file it claimed that is still absent. + const now = existsSync(c.abs) ? readFileSync(c.abs, "utf8") : null; + if (c.created ? now != null : now !== c.original) { + errors.push({ + file: c.file, + transform: c.transforms.join(", "), + error: c.created + ? "appeared on disk during the run; left alone." + : "changed on disk during the run; left alone.", + }); + continue; + } mkdirSync(dirname(c.abs), { recursive: true }); writeFileSync(c.abs, c.current); } if (pkgChanged && pkgAfter != null) writeFileSync(pkgFile, pkgAfter); } - const reportFile = resolve(cwd, options.report ?? "ui-common-upgrade-report.md"); const report = renderReport({ from, to, @@ -349,6 +387,7 @@ export async function runUpgrade(options) { findings, categories, errors, + notices, tokenReads, }); mkdirSync(dirname(reportFile), { recursive: true }); @@ -370,6 +409,7 @@ export async function runUpgrade(options) { log(unifiedDiff("package.json", pkgText, pkgAfter)); } for (const e of errors) warn(` ! ${e.file} [${e.transform}]: ${e.error}`); + for (const notice of notices) warn(` note: ${notice}`); log(`Report: ${relative(cwd, reportFile) || reportFile}`); if (dryRun) log("Dry run: no source file was written. Run without --dry-run to apply."); diff --git a/codemods/0.2/stylesheets.mjs b/codemods/0.2/stylesheets.mjs index dd47186..d8c8a70 100644 --- a/codemods/0.2/stylesheets.mjs +++ b/codemods/0.2/stylesheets.mjs @@ -12,7 +12,7 @@ * - `styles/themes/orange-{light,dark}.css` imports are dropped: the Lablup * theme covers both colour schemes. */ -import { dirname, join } from "node:path"; +import { basename, dirname, join } from "node:path"; import postcss from "postcss"; @@ -192,7 +192,7 @@ export const jsMeta = { /** * @param {{source: string, path: string}} file * @param {{jscodeshift: any}} api - * @param {{createFile: (path: string, content: string) => void, flags: {packages: Map<string, string>}}} ctx + * @param {{createFile: (path: string, content: string) => string, flags: {packages: Map<string, string>}}} ctx */ export function transformScriptImports(file, api, ctx) { if (!file.source.includes("@lablup/ui-common/styles/")) return undefined; @@ -223,8 +223,10 @@ export function transformScriptImports(file, api, ctx) { path.prune(); return; } - path.node.source = j.stringLiteral(entrySpecifier); - ctx.createFile(join(dirname(file.path), entryFile), entryCss(ctx)); + // createFile never overwrites: it hands back the file it will write, + // which is a numbered sibling when the project has its own entry there. + const entry = ctx.createFile(join(dirname(file.path), entryFile), entryCss(ctx)); + path.node.source = j.stringLiteral(`./${basename(entry)}`); }); root.find(j.CallExpression).forEach((/** @type {any} */ path) => { diff --git a/test/upgrade/upgrade.test.ts b/test/upgrade/upgrade.test.ts index 8ad4a04..abd7362 100644 --- a/test/upgrade/upgrade.test.ts +++ b/test/upgrade/upgrade.test.ts @@ -142,6 +142,69 @@ describe("ui-common upgrade 0.1 -> 0.2", () => { ); }); + describe("never overwrites a file this run did not produce", () => { + const own = "/* the project's own entry */\n@import './brand.css';\n"; + + it.each([ + ["outside the scanned paths", ["src/main.tsx"]], + ["inside the scanned paths", ["src"]], + ])("an existing ui-common-entry.css %s", async (_label, paths) => { + const dir = copyFixture("adapter"); + writeFileSync(join(dir, "src/ui-common-entry.css"), own); + const result = await runUpgrade({ cwd: dir, paths, to: TO, ...quiet }); + expect(result.code, JSON.stringify(result.errors)).toBe(0); + expect(readFileSync(join(dir, "src/ui-common-entry.css"), "utf8")).toBe(own); + // The script imports a fresh entry beside it instead, and the report says so. + const main = readFileSync(join(dir, "src/main.tsx"), "utf8"); + expect(main).toContain('import "./ui-common-entry-2.css";'); + expect(main).not.toContain('import "./ui-common-entry.css";'); + expect(readFileSync(join(dir, "src/ui-common-entry-2.css"), "utf8")).toContain( + '@import "@lablup/ui-common/reset.css";', + ); + expect(readFileSync(join(dir, "ui-common-upgrade-report.md"), "utf8")).toContain( + "src/ui-common-entry.css exists already", + ); + }); + + it("reuses an existing entry that already holds the 0.2 stylesheet set", async () => { + const dir = copyFixture("adapter"); + const main = join(dir, "src/main.tsx"); + const original = readFileSync(main, "utf8"); + const first = await runUpgrade({ cwd: dir, paths: [main], to: TO, ...quiet }); + expect(first.code).toBe(0); + const entry = readFileSync(join(dir, "src/ui-common-entry.css"), "utf8"); + // A second script still importing base.css, beside the first one's entry. + writeFileSync(main, original); + const again = await runUpgrade({ + cwd: dir, + paths: ["src/main.tsx"], + from: "0.1.0", + to: TO, + ...quiet, + }); + expect(again.code).toBe(0); + expect(readFileSync(join(dir, "src/main.tsx"), "utf8")).toContain( + 'import "./ui-common-entry.css";', + ); + expect(readFileSync(join(dir, "src/ui-common-entry.css"), "utf8")).toBe(entry); + expect(existsSync(join(dir, "src/ui-common-entry-2.css"))).toBe(false); + }); + + it("refuses to overwrite a report path that holds something else", async () => { + const dir = copyFixture("css-entry"); + writeFileSync(join(dir, "NOTES.md"), "# my notes\n"); + const result = await runUpgrade({ + cwd: dir, + paths: ["src"], + to: TO, + report: "NOTES.md", + ...quiet, + }); + expect(result.code).toBe(2); + expect(readFileSync(join(dir, "NOTES.md"), "utf8")).toBe("# my notes\n"); + }); + }); + it("reads --from from package.json and refuses a non-upgrade", async () => { const dir = copyFixture("css-entry"); const same = await runUpgrade({ From 8709af77696d1628fc4027f1722bbb3022c6d1e6 Mon Sep 17 00:00:00 2001 From: Jongeun <jongeun@lablup.com> Date: Sat, 26 Sep 2026 06:36:02 +0000 Subject: [PATCH 42/87] fix(cli): make upgrade --dry-run write nothing, the report included A dry run wrote ui-common-upgrade-report.md, so previewing an upgrade left a file behind (and could replace an earlier report). It now prints the report after the summary; --report <path> still writes it, to that path only. --- README.md | 4 ++-- cli/upgrade.mjs | 31 ++++++++++++++++++++++--------- test/upgrade/upgrade.test.ts | 26 +++++++++++++++++++++----- 3 files changed, 45 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 5b52fee..be5f2d9 100644 --- a/README.md +++ b/README.md @@ -285,7 +285,7 @@ tool reads. Let the upgrade tool do the mechanical part. After bumping the dependency: ``` -pnpm exec ui-common upgrade --from 0.1 --dry-run # writes only the report +pnpm exec ui-common upgrade --from 0.1 --dry-run # writes nothing; prints the changes and the report pnpm exec ui-common upgrade --from 0.1 # applies it ``` @@ -332,7 +332,7 @@ pnpm exec ui-common upgrade --from 0.1 --dry-run | `ui-common <astryx command> …` | Runs the pinned Astryx CLI and rewrites its output to ui-common: `@astryxdesign/core/<X>` is `@lablup/ui-common/<X>`, `@astryxdesign/lab` is `@lablup/ui-common/lab`, `@astryxdesign/theme-neutral` is `@lablup/ui-common/theme/neutral`, and commands read `ui-common …`. A name ui-common hides gets a note ("Use Modal, not Dialog"). `--json` output stays valid JSON; the note goes to stderr. The exit code is Astryx's. | | `ui-common astryx …` | The same, without rewriting. | | `ui-common agents [--write <file>] [--check]` | Prints the agent block: Astryx's `init --features agents` block, rewritten, plus ui-common's rules. It sits between `<!-- UI-COMMON:START -->` and `<!-- UI-COMMON:END -->`, which `astryx init` never touches. `--write` replaces the block in place and keeps the rest of the file; `--check` exits 1 when it is stale. | -| `ui-common upgrade [--from <v>] [--to <v>] [--dry-run] [--diff] [--report <path>] [paths…]` | Runs the codemods between two ui-common versions over `src/` (or `paths`), updates `package.json`, and writes `ui-common-upgrade-report.md`. `--from` defaults to the version `package.json` declares, `--to` to the installed one. | +| `ui-common upgrade [--from <v>] [--to <v>] [--dry-run] [--diff] [--report <path>] [paths…]` | Runs the codemods between two ui-common versions over `src/` (or `paths`), updates `package.json`, and writes `ui-common-upgrade-report.md` (a `--dry-run` writes nothing and prints the report, unless `--report` names a file). `--from` defaults to the version `package.json` declares, `--to` to the installed one. | | `ui-common sync-astryx <version> [--lab <v>] [--as <v>] [--dry-run]` | Maintainers only; see [CONTRIBUTING.md](CONTRIBUTING.md#bumping-astryx). | Exit codes: a passed-through command exits with Astryx's code. ui-common's own diff --git a/cli/upgrade.mjs b/cli/upgrade.mjs index eb182c5..445e33d 100644 --- a/cli/upgrade.mjs +++ b/cli/upgrade.mjs @@ -4,8 +4,10 @@ * manual-review report. * * All edits are made in memory first. `--dry-run` stops there and prints what - * would change; otherwise the files are written. The report is written either - * way, since it is how a dry run is read. + * would change and the report, writing nothing (the report only to an + * explicit `--report <path>`); otherwise the files and the report are + * written. A file is written only if this run read it, or created it where + * nothing existed. */ import { existsSync, @@ -166,9 +168,12 @@ export async function runUpgrade(options) { } const files = collectFiles(roots); - // The report replaces an earlier report, never anything else. + // A dry run prints the report unless --report names a file for it. The + // report replaces an earlier report, never anything else. + const writeReport = !options.dryRun || options.report != null; const reportFile = resolve(cwd, options.report ?? "ui-common-upgrade-report.md"); if ( + writeReport && existsSync(reportFile) && !readFileSync(reportFile, "utf8").startsWith(REPORT_HEADING) ) { @@ -390,8 +395,10 @@ export async function runUpgrade(options) { notices, tokenReads, }); - mkdirSync(dirname(reportFile), { recursive: true }); - writeFileSync(reportFile, report); + if (writeReport) { + mkdirSync(dirname(reportFile), { recursive: true }); + writeFileSync(reportFile, report); + } // Terminal summary. const verb = dryRun ? "Would change" : "Changed"; @@ -410,9 +417,12 @@ export async function runUpgrade(options) { } for (const e of errors) warn(` ! ${e.file} [${e.transform}]: ${e.error}`); for (const notice of notices) warn(` note: ${notice}`); - log(`Report: ${relative(cwd, reportFile) || reportFile}`); + if (writeReport) log(`Report: ${relative(cwd, reportFile) || reportFile}`); + else log(`\n${report}`); if (dryRun) - log("Dry run: no source file was written. Run without --dry-run to apply."); + log( + `Dry run: nothing was written${writeReport ? " but the report" : ""}. Run without --dry-run to apply${writeReport ? "" : ", or pass --report <path> to keep the report"}.`, + ); if (!dryRun && pkgChanged) log("package.json changed: run your package manager's install."); @@ -436,9 +446,12 @@ package.json, and write a manual-review report. --from <version> The ui-common version the code is written against. Default: the version package.json declares. --to <version> Default: the installed @lablup/ui-common version. - --dry-run Change nothing on disk except the report; list what would change. + --dry-run Write nothing; list what would change and print the report. --diff With --dry-run, also print unified diffs. - --report <path> Default: ui-common-upgrade-report.md + --report <path> Where to write the report. Default: ui-common-upgrade-report.md, + except in a dry run, which writes a report only to a path + given here. An existing file is replaced only if it is an + earlier report. paths… Directories or files to scan. Default: src/ Exit codes: 0 done, 1 some files could not be transformed (see the report), diff --git a/test/upgrade/upgrade.test.ts b/test/upgrade/upgrade.test.ts index abd7362..11844a5 100644 --- a/test/upgrade/upgrade.test.ts +++ b/test/upgrade/upgrade.test.ts @@ -118,7 +118,7 @@ describe("ui-common upgrade 0.1 -> 0.2", () => { } }); - it("--dry-run writes only the report", async () => { + it("--dry-run writes nothing and prints the report", async () => { const dir = copyFixture("root-barrel"); const before = new Map( tree(dir).map((f) => [f, readFileSync(join(dir, f), "utf8")]), @@ -135,11 +135,27 @@ describe("ui-common upgrade 0.1 -> 0.2", () => { expect(result.code).toBe(0); for (const [file, text] of before) expect(readFileSync(join(dir, file), "utf8"), file).toBe(text); - expect(tree(dir)).toEqual([...before.keys(), "ui-common-upgrade-report.md"].sort()); + expect(tree(dir)).toEqual([...before.keys()].sort()); expect(lines.some((l) => l.includes("~ src/pages/ModelsPage.tsx"))).toBe(true); - expect(readFileSync(join(dir, "ui-common-upgrade-report.md"), "utf8")).toContain( - "dry run", - ); + const printed = lines.join("\n"); + expect(printed).toContain("# ui-common upgrade report"); + expect(printed).toContain("dry run: nothing was written"); + }); + + it("--dry-run with an explicit --report writes the report there, and only it", async () => { + const dir = copyFixture("root-barrel"); + const before = tree(dir); + const result = await runUpgrade({ + cwd: dir, + paths: ["src"], + to: TO, + dryRun: true, + report: "reports/dry.md", + ...quiet, + }); + expect(result.code).toBe(0); + expect(tree(dir)).toEqual([...before, "reports/dry.md"].sort()); + expect(readFileSync(join(dir, "reports/dry.md"), "utf8")).toContain("dry run"); }); describe("never overwrites a file this run did not produce", () => { From ded0d2d41db83847c2faf927cf7c8038a357663f Mon Sep 17 00:00:00 2001 From: Jongeun <jongeun@lablup.com> Date: Sat, 26 Sep 2026 06:39:04 +0000 Subject: [PATCH 43/87] fix(codemods): keep @use and @forward above the layer order in SCSS The SCSS rewrite of the styles/base.css import prepended the @layer statement to the file, above any @use or @forward, which Sass rejects ("@use rules must be written before any other rules"). It now goes after the leading @charset/@use/@forward rules and the variables that configure them; CSS keeps its @charset first as before. sass joins the devDependencies so the tests compile the rewritten SCSS. --- codemods/0.2/stylesheets.mjs | 68 +++++- package.json | 1 + pnpm-lock.yaml | 209 ++++++++++++++++-- pnpm-workspace.yaml | 2 + .../fixtures/adapter/expected/src/index.scss | 4 +- .../expected/ui-common-upgrade-report.md | 2 +- .../fixtures/adapter/input/src/index.scss | 4 +- test/upgrade/stylesheets.test.ts | 89 ++++++++ 8 files changed, 355 insertions(+), 24 deletions(-) create mode 100644 test/upgrade/stylesheets.test.ts diff --git a/codemods/0.2/stylesheets.mjs b/codemods/0.2/stylesheets.mjs index d8c8a70..5404c7b 100644 --- a/codemods/0.2/stylesheets.mjs +++ b/codemods/0.2/stylesheets.mjs @@ -5,7 +5,8 @@ * Astryx's reset and component sheets, the Lablup theme, ui-common's global * sheet and the deprecated `legacy-tokens.css` bridge. * - In a stylesheet the `@import` is replaced in place, and the `@layer` - * statement goes first in the file. + * statement goes first in the file (in SCSS, after the leading + * `@use`/`@forward` rules, which Sass requires above every other rule). * - In a script (`import "@lablup/ui-common/styles/base.css"`) a CSS * `@layer` statement cannot be expressed, so the import is pointed at a * new `ui-common-entry.css` beside the script, which holds all of it. @@ -141,13 +142,65 @@ function transformCss(source, path, ctx) { return root.toString(); } +/** + * Where the leading run of statements Sass requires first ends: `@charset`, + * `@use`, `@forward`, and the variable declarations that may configure them, + * with the comments between them. Returns 0 when there is none. Sass rejects + * any other rule, a CSS `@layer` statement included, above an `@use`. + * + * @param {string} source + */ +function sassPreludeEnd(source) { + let i = 0; + let end = 0; + const skipSpaceAndComments = () => { + for (;;) { + const rest = source.slice(i); + const space = /^\s+/.exec(rest); + if (space) i += space[0].length; + else if (rest.startsWith("//")) { + const nl = source.indexOf("\n", i); + i = nl === -1 ? source.length : nl + 1; + } else if (rest.startsWith("/*")) { + const close = source.indexOf("*/", i + 2); + i = close === -1 ? source.length : close + 2; + } else return; + } + }; + for (;;) { + skipSpaceAndComments(); + if (!/^(?:@(?:charset|use|forward)\b|\$[\w-]+\s*:)/.test(source.slice(i))) break; + // To the `;` that ends the statement, outside strings and parentheses + // (`@use "x" with ($a: 1, $b: 2);` spans lines). + let depth = 0; + /** @type {string | null} */ + let quote = null; + for (; i < source.length; i++) { + const ch = source[i]; + if (quote) { + if (ch === "\\") i++; + else if (ch === quote) quote = null; + } else if (ch === '"' || ch === "'") quote = ch; + else if (ch === "(") depth++; + else if (ch === ")") depth--; + else if (ch === ";" && depth <= 0) break; + } + if (i >= source.length) break; + i += 1; + end = i; + } + return end; +} + /** * Sass and Less: postcss cannot parse them, and the imports in question are - * single lines, so rewrite the lines. + * single lines, so rewrite the lines. The layer order goes first, or in SCSS + * right after the `@use`/`@forward` prelude. * * @param {string} source + * @param {string} path */ -function transformPreprocessed(source, ctx) { +function transformPreprocessed(source, path, ctx) { const urls = replacementFor(ctx); const importLine = /^([ \t]*)@import\s+(?:url\()?["']([^"']+)["']\)?\s*;[ \t]*\r?\n?/gm; @@ -159,9 +212,10 @@ function transformPreprocessed(source, ctx) { return urls.map((r) => `${indent}@import "${r}";\n`).join(""); }); if (out === source) return undefined; - return replacedBase && !/^\s*@layer\s+[^{]+;/m.test(out) - ? `${layerOrder}\n\n${out}` - : out; + if (!replacedBase || /^\s*@layer\s+[^{]+;/m.test(out)) return out; + const at = path.endsWith(".scss") ? sassPreludeEnd(out) : 0; + if (at === 0) return `${layerOrder}\n\n${out}`; + return `${out.slice(0, at)}\n\n${layerOrder}\n\n${out.slice(at).replace(/^[ \t]*\r?\n+/, "")}`; } export const cssMeta = { @@ -179,7 +233,7 @@ export const cssMeta = { export function transformStylesheet(file, _api, ctx) { if (!file.source.includes("@lablup/ui-common/styles/")) return undefined; if (file.path.endsWith(".css")) return transformCss(file.source, file.path, ctx); - return transformPreprocessed(file.source, ctx); + return transformPreprocessed(file.source, file.path, ctx); } export const jsMeta = { diff --git a/package.json b/package.json index be59a30..2864fdf 100644 --- a/package.json +++ b/package.json @@ -621,6 +621,7 @@ "prettier": "^3.8.4", "react": "^19.2.7", "react-dom": "^19.2.7", + "sass": "^1.105.0", "tinyglobby": "^0.2.15", "typescript": "^5.9.3", "typescript-eslint": "^8.47.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b3ef7a3..24ff003 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -59,7 +59,7 @@ importers: version: 19.2.4(@types/react@19.2.18) '@vitejs/plugin-react': specifier: ^6.0.2 - version: 6.0.5(vite@8.2.0(@types/node@22.20.1)(jiti@2.7.0)) + version: 6.0.5(vite@8.2.0(@types/node@22.20.1)(jiti@2.7.0)(sass@1.105.0)) eslint: specifier: ^9.39.0 version: 9.39.5(jiti@2.7.0)(supports-color@8.1.1) @@ -81,6 +81,9 @@ importers: react-dom: specifier: ^19.2.7 version: 19.2.8(react@19.2.8) + sass: + specifier: ^1.105.0 + version: 1.105.0 tinyglobby: specifier: ^0.2.15 version: 0.2.17 @@ -92,13 +95,13 @@ importers: version: 8.66.0(eslint@9.39.5(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3) vite: specifier: ^8.0.16 - version: 8.2.0(@types/node@22.20.1)(jiti@2.7.0) + version: 8.2.0(@types/node@22.20.1)(jiti@2.7.0)(sass@1.105.0) vite-plugin-dts: specifier: ^4.5.4 - version: 4.5.4(@types/node@22.20.1)(supports-color@8.1.1)(typescript@5.9.3)(vite@8.2.0(@types/node@22.20.1)(jiti@2.7.0)) + version: 4.5.4(@types/node@22.20.1)(supports-color@8.1.1)(typescript@5.9.3)(vite@8.2.0(@types/node@22.20.1)(jiti@2.7.0)(sass@1.105.0)) vitest: specifier: ^4.1.8 - version: 4.1.10(@types/node@22.20.1)(jsdom@29.1.1)(vite@8.2.0(@types/node@22.20.1)(jiti@2.7.0)) + version: 4.1.10(@types/node@22.20.1)(jsdom@29.1.1)(vite@8.2.0(@types/node@22.20.1)(jiti@2.7.0)(sass@1.105.0)) packages: @@ -490,6 +493,88 @@ packages: '@oxc-project/types@0.142.0': resolution: {integrity: sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==} + '@parcel/watcher-android-arm64@2.6.0': + resolution: {integrity: sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [android] + + '@parcel/watcher-darwin-arm64@2.6.0': + resolution: {integrity: sha512-Y3QV0gl7Q1zbfueunkWIERICbEojQFCgpyG7YqOGNFLsckXyI1xu9mAIUpKY9QBYzBtSkN8dBPwd3yiAO9ovMw==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [darwin] + + '@parcel/watcher-darwin-x64@2.6.0': + resolution: {integrity: sha512-Ohv6OpzhUfKYD7Beb8kDvG0jbIxORCYY1JRdZnaBtnjjkJxgD7ZVL0nw2sCYd0yTMKTvz3nnTnOF3cDifK+kvw==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [darwin] + + '@parcel/watcher-freebsd-x64@2.6.0': + resolution: {integrity: sha512-5HmXvDgs8VK+74jF9y9/2FE3/OnlcKmc56tjmSrEuZjpSZOGL+fvAu+HKJBdPs9uwoP2hE6TlSUpXZ/C5jUFmQ==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [freebsd] + + '@parcel/watcher-linux-arm-glibc@2.6.0': + resolution: {integrity: sha512-Ps/hui3A+vMbjdqlqAowK2ZL8+BO8dBjxeWXj6npTBs3jx4wWmbPpaLuqwrQrSqIVMCnpWo238bJ1U37GhQOYg==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@parcel/watcher-linux-arm-musl@2.6.0': + resolution: {integrity: sha512-9c6AUHgHoG+IY88MRIHupztQiQnrbqHYQjkM2btA+Bf/wQnQMuiD0Wfk1EVv3TlNT3x41uU71rn6E4xh/+zvkw==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + libc: [musl] + + '@parcel/watcher-linux-arm64-glibc@2.6.0': + resolution: {integrity: sha512-yHRqS2owEXe6Hic9z6Mh1ECsCd+ODVOGvZDyciqRd21+v+o+DnXMOrw50DSpIG2sb8GPEaPPmfeCAWKPJdq46g==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@parcel/watcher-linux-arm64-musl@2.6.0': + resolution: {integrity: sha512-WhB2e/V7rqdHHWZusBSPuy5Ei8S6lSz6FE5TKKQz5h3a0O+C+mhY7vxU9b/stqvMb8beLnPY82ZrFTLKs+SrKA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@parcel/watcher-linux-x64-glibc@2.6.0': + resolution: {integrity: sha512-ulGE6x6Oz6iAwg75T8YQSoguBWasniIbX+QWpaYPcCnDOpdWX3k+4xbEYPZVLxOuoJI+svJJPD3sEj8G7lrQ3A==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@parcel/watcher-linux-x64-musl@2.6.0': + resolution: {integrity: sha512-tkBYKt7YQrjIJWYDnto2YgO8MRkjlMTSNoRHzsXinBqbLdeOM3L32wPZJvIZxqaLMfSlS/4sUjH/6STVP/XDLw==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@parcel/watcher-win32-arm64@2.6.0': + resolution: {integrity: sha512-gIZAP23jaHjGWasY/TY6yL7NHFClf0Ga7FN+iINvk+KN94rhm94lYZhFsbYFNcA04/onvGD9kKmiJLJB2HbNwQ==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [win32] + + '@parcel/watcher-win32-x64@2.6.0': + resolution: {integrity: sha512-cA+/pXV2YkfxlIcXOQ5fSWqAzzPyD78/x5qbK/I0vUkrlYHA8TIz+MXjAbGouguKVSI4bOmkTSJ1/poVSsgt+A==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [win32] + + '@parcel/watcher@2.6.0': + resolution: {integrity: sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==} + engines: {node: '>= 10.0.0'} + '@rolldown/binding-android-arm64@1.2.2': resolution: {integrity: sha512-l7x215OGvo1s52JWmR8U/DAVzEDWBCIbTm28aeJV/WDTSHgcKXaZTuBT0hJMs5NggilfJTW3clZVvd24yfKJxA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -940,6 +1025,10 @@ packages: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + clone-deep@4.0.1: resolution: {integrity: sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==} engines: {node: '>=6'} @@ -1284,6 +1373,9 @@ packages: resolution: {integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==} engines: {node: '>= 4'} + immutable@5.1.9: + resolution: {integrity: sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg==} + import-fresh@3.3.1: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} @@ -1566,6 +1658,9 @@ packages: neo-async@2.6.2: resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + node-addon-api@7.1.1: + resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} + node-diff3@3.2.1: resolution: {integrity: sha512-eKZcJ8RtMQ3cIaA15EgmtwG927fYnRlhtdA4Q9HAcCpAZPQhhU2XptnTH9GeAkMiX2bAXxlJSAFa9shFbztPgw==} engines: {bun: '>=1.3.10'} @@ -1695,6 +1790,10 @@ packages: resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} engines: {node: '>=0.10.0'} + readdirp@5.1.1: + resolution: {integrity: sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==} + engines: {node: '>= 20.19.0'} + recast@0.23.21: resolution: {integrity: sha512-mFAyJq9vUbSTARLZUvAEf1z3YxlvAwswbmxMx2mPA/MSm4KmpwvwvhsH/NIrZhyOuwD60Lzyw2qh83uCbgTPYw==} engines: {node: '>= 4'} @@ -1721,6 +1820,11 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + sass@1.105.0: + resolution: {integrity: sha512-4EjSuRTyG8M4yRXXY1PT9OUV5zz23xBxJkbIAprohKHqf6HOU4MCpE3U3vAC+iO1gxobBjESNbcj8rYAQZWnyA==} + engines: {node: '>=20.19.0'} + hasBin: true + saxes@6.0.0: resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} engines: {node: '>=v12.22.7'} @@ -2530,6 +2634,63 @@ snapshots: '@oxc-project/types@0.142.0': {} + '@parcel/watcher-android-arm64@2.6.0': + optional: true + + '@parcel/watcher-darwin-arm64@2.6.0': + optional: true + + '@parcel/watcher-darwin-x64@2.6.0': + optional: true + + '@parcel/watcher-freebsd-x64@2.6.0': + optional: true + + '@parcel/watcher-linux-arm-glibc@2.6.0': + optional: true + + '@parcel/watcher-linux-arm-musl@2.6.0': + optional: true + + '@parcel/watcher-linux-arm64-glibc@2.6.0': + optional: true + + '@parcel/watcher-linux-arm64-musl@2.6.0': + optional: true + + '@parcel/watcher-linux-x64-glibc@2.6.0': + optional: true + + '@parcel/watcher-linux-x64-musl@2.6.0': + optional: true + + '@parcel/watcher-win32-arm64@2.6.0': + optional: true + + '@parcel/watcher-win32-x64@2.6.0': + optional: true + + '@parcel/watcher@2.6.0': + dependencies: + detect-libc: 2.1.2 + is-glob: 4.0.3 + node-addon-api: 7.1.1 + picomatch: 4.0.5 + optionalDependencies: + '@parcel/watcher-android-arm64': 2.6.0 + '@parcel/watcher-darwin-arm64': 2.6.0 + '@parcel/watcher-darwin-x64': 2.6.0 + '@parcel/watcher-freebsd-x64': 2.6.0 + '@parcel/watcher-linux-arm-glibc': 2.6.0 + '@parcel/watcher-linux-arm-musl': 2.6.0 + '@parcel/watcher-linux-arm64-glibc': 2.6.0 + '@parcel/watcher-linux-arm64-musl': 2.6.0 + '@parcel/watcher-linux-x64-glibc': 2.6.0 + '@parcel/watcher-linux-x64-musl': 2.6.0 + '@parcel/watcher-win32-arm64': 2.6.0 + '@parcel/watcher-win32-x64': 2.6.0 + optional: true + '@rolldown/binding-android-arm64@1.2.2': optional: true @@ -2779,10 +2940,10 @@ snapshots: '@typescript-eslint/types': 8.66.0 eslint-visitor-keys: 5.0.1 - '@vitejs/plugin-react@6.0.5(vite@8.2.0(@types/node@22.20.1)(jiti@2.7.0))': + '@vitejs/plugin-react@6.0.5(vite@8.2.0(@types/node@22.20.1)(jiti@2.7.0)(sass@1.105.0))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: 8.2.0(@types/node@22.20.1)(jiti@2.7.0) + vite: 8.2.0(@types/node@22.20.1)(jiti@2.7.0)(sass@1.105.0) '@vitest/expect@4.1.10': dependencies: @@ -2793,13 +2954,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.1 - '@vitest/mocker@4.1.10(vite@8.2.0(@types/node@22.20.1)(jiti@2.7.0))': + '@vitest/mocker@4.1.10(vite@8.2.0(@types/node@22.20.1)(jiti@2.7.0)(sass@1.105.0))': dependencies: '@vitest/spy': 4.1.10 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.2.0(@types/node@22.20.1)(jiti@2.7.0) + vite: 8.2.0(@types/node@22.20.1)(jiti@2.7.0)(sass@1.105.0) '@vitest/pretty-format@4.1.10': dependencies: @@ -2979,6 +3140,10 @@ snapshots: ansi-styles: 4.3.0 supports-color: 7.2.0 + chokidar@5.0.0: + dependencies: + readdirp: 5.1.1 + clone-deep@4.0.1: dependencies: is-plain-object: 2.0.4 @@ -3288,6 +3453,8 @@ snapshots: ignore@7.0.6: {} + immutable@5.1.9: {} + import-fresh@3.3.1: dependencies: parent-module: 1.0.1 @@ -3548,6 +3715,9 @@ snapshots: neo-async@2.6.2: {} + node-addon-api@7.1.1: + optional: true + node-diff3@3.2.1: {} node-releases@2.0.51: {} @@ -3659,6 +3829,8 @@ snapshots: react@19.2.8: {} + readdirp@5.1.1: {} + recast@0.23.21: dependencies: ast-types: 0.16.3 @@ -3703,6 +3875,14 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.2.2 '@rolldown/binding-win32-x64-msvc': 1.2.2 + sass@1.105.0: + dependencies: + chokidar: 5.0.0 + immutable: 5.1.9 + source-map-js: 1.2.1 + optionalDependencies: + '@parcel/watcher': 2.6.0 + saxes@6.0.0: dependencies: xmlchars: 2.2.0 @@ -3840,7 +4020,7 @@ snapshots: util-deprecate@1.0.2: {} - vite-plugin-dts@4.5.4(@types/node@22.20.1)(supports-color@8.1.1)(typescript@5.9.3)(vite@8.2.0(@types/node@22.20.1)(jiti@2.7.0)): + vite-plugin-dts@4.5.4(@types/node@22.20.1)(supports-color@8.1.1)(typescript@5.9.3)(vite@8.2.0(@types/node@22.20.1)(jiti@2.7.0)(sass@1.105.0)): dependencies: '@microsoft/api-extractor': 7.58.12(@types/node@22.20.1) '@rollup/pluginutils': 5.4.0 @@ -3853,13 +4033,13 @@ snapshots: magic-string: 0.30.21 typescript: 5.9.3 optionalDependencies: - vite: 8.2.0(@types/node@22.20.1)(jiti@2.7.0) + vite: 8.2.0(@types/node@22.20.1)(jiti@2.7.0)(sass@1.105.0) transitivePeerDependencies: - '@types/node' - rollup - supports-color - vite@8.2.0(@types/node@22.20.1)(jiti@2.7.0): + vite@8.2.0(@types/node@22.20.1)(jiti@2.7.0)(sass@1.105.0): dependencies: lightningcss: 1.33.0 picomatch: 4.0.5 @@ -3870,11 +4050,12 @@ snapshots: '@types/node': 22.20.1 fsevents: 2.3.3 jiti: 2.7.0 + sass: 1.105.0 - vitest@4.1.10(@types/node@22.20.1)(jsdom@29.1.1)(vite@8.2.0(@types/node@22.20.1)(jiti@2.7.0)): + vitest@4.1.10(@types/node@22.20.1)(jsdom@29.1.1)(vite@8.2.0(@types/node@22.20.1)(jiti@2.7.0)(sass@1.105.0)): dependencies: '@vitest/expect': 4.1.10 - '@vitest/mocker': 4.1.10(vite@8.2.0(@types/node@22.20.1)(jiti@2.7.0)) + '@vitest/mocker': 4.1.10(vite@8.2.0(@types/node@22.20.1)(jiti@2.7.0)(sass@1.105.0)) '@vitest/pretty-format': 4.1.10 '@vitest/runner': 4.1.10 '@vitest/snapshot': 4.1.10 @@ -3891,7 +4072,7 @@ snapshots: tinyexec: 1.3.0 tinyglobby: 0.2.17 tinyrainbow: 3.1.1 - vite: 8.2.0(@types/node@22.20.1)(jiti@2.7.0) + vite: 8.2.0(@types/node@22.20.1)(jiti@2.7.0)(sass@1.105.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.20.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index aa15fac..c8ac3c8 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -21,3 +21,5 @@ allowBuilds: # needed. Listing them keeps pnpm from failing with ERR_PNPM_IGNORED_BUILDS. "@astryxdesign/cli": false "@astryxdesign/core": false + # sass's optional native file watcher; the tests compile, they never watch. + "@parcel/watcher": false diff --git a/test/upgrade/fixtures/adapter/expected/src/index.scss b/test/upgrade/fixtures/adapter/expected/src/index.scss index 40fea62..eac5bcd 100644 --- a/test/upgrade/fixtures/adapter/expected/src/index.scss +++ b/test/upgrade/fixtures/adapter/expected/src/index.scss @@ -1,3 +1,5 @@ +@use "sass:math"; + @layer reset, theme, base, astryx-base, astryx-theme, ui-common, components, utilities; @import "@lablup/ui-common/reset.css"; @@ -9,6 +11,6 @@ .app { .button--primary { - margin: 0; + margin: math.div(8px, 2); } } diff --git a/test/upgrade/fixtures/adapter/expected/ui-common-upgrade-report.md b/test/upgrade/fixtures/adapter/expected/ui-common-upgrade-report.md index df75880..2cd9e16 100644 --- a/test/upgrade/fixtures/adapter/expected/ui-common-upgrade-report.md +++ b/test/upgrade/fixtures/adapter/expected/ui-common-upgrade-report.md @@ -51,7 +51,7 @@ A removed component's classes are gone: Astryx renders its own. Restyle through | Where | What | Detail | |---|---|---| | `src/design-system/common-components.css:1` | `.empty-state__title` | .empty-state__title (EmptyState): gone | -| `src/index.scss:11` | `.button--primary` | .button--primary (Button): gone | +| `src/index.scss:13` | `.button--primary` | .button--primary (Button): gone | ### DOM hooks on 0.1 class names (0) diff --git a/test/upgrade/fixtures/adapter/input/src/index.scss b/test/upgrade/fixtures/adapter/input/src/index.scss index 3f8bb58..1c98306 100644 --- a/test/upgrade/fixtures/adapter/input/src/index.scss +++ b/test/upgrade/fixtures/adapter/input/src/index.scss @@ -1,7 +1,9 @@ +@use "sass:math"; + @import "@lablup/ui-common/styles/base.css"; .app { .button--primary { - margin: 0; + margin: math.div(8px, 2); } } diff --git a/test/upgrade/stylesheets.test.ts b/test/upgrade/stylesheets.test.ts new file mode 100644 index 0000000..7426a9e --- /dev/null +++ b/test/upgrade/stylesheets.test.ts @@ -0,0 +1,89 @@ +/** + * The base.css rewrite in stylesheets, checked by compiling the output: Sass + * rejects a rule placed above `@use`, and CSS ignores a `@layer` statement + * placed above `@charset`. + */ +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { compileString } from "sass"; +import { describe, expect, it } from "vitest"; + +import { transformStylesheet } from "../../codemods/0.2/stylesheets.mjs"; + +const ctx = { flags: { packages: new Map() } }; +const LAYER = + "@layer reset, theme, base, astryx-base, astryx-theme, ui-common, components, utilities;"; + +function rewrite(path: string, source: string) { + const out = transformStylesheet({ path, source }, undefined, ctx); + if (out == null) throw new Error("the codemod left the file alone"); + return out as string; +} + +/** `@lablup/ui-common/*.css` imports compile to plain CSS imports. */ +const compile = (scss: string) => compileString(scss, { syntax: "scss" }).css; + +describe("stylesheet entry rewrite", () => { + it("puts the layer order after the leading @use and @forward rules in SCSS", () => { + const out = rewrite( + "src/index.scss", + `// theme entry +@use "sass:math"; +@forward "sass:color"; +$pad: 2px; +@import "@lablup/ui-common/styles/base.css"; + +.a { width: math.div(10px, 2); padding: $pad; } +`, + ); + expect(out.startsWith('// theme entry\n@use "sass:math";')).toBe(true); + expect(out.indexOf(LAYER)).toBeGreaterThan(out.indexOf('@forward "sass:color";')); + expect(out.indexOf(LAYER)).toBeLessThan( + out.indexOf('@import "@lablup/ui-common/reset.css";'), + ); + const css = compile(out); + expect(css).toContain(LAYER.replace(/;$/, "")); + expect(css).toContain("width: 5px"); + expect(css).toContain("padding: 2px"); + }); + + it("treats a multi-line `@use … with (…)` as one rule", () => { + const out = rewrite( + "src/index.scss", + `@use "./tokens" as t with ( + $radius: 4px, + $gap: 8px +); +@import "@lablup/ui-common/styles/base.css"; +`, + ); + expect(out).toContain(` $gap: 8px\n);\n\n${LAYER}\n`); + }); + + it("keeps the layer order first in SCSS with no module rules", () => { + const out = rewrite( + "src/index.scss", + `@import "@lablup/ui-common/styles/base.css";\n.c { color: red; }\n`, + ); + expect(out.startsWith(LAYER)).toBe(true); + expect(compile(out)).toContain("color: red"); + }); + + it("leaves the adapter fixture's SCSS entry compiling", () => { + const expected = join( + dirname(fileURLToPath(import.meta.url)), + "fixtures/adapter/expected/src/index.scss", + ); + expect(compile(readFileSync(expected, "utf8"))).toContain("margin: 4px"); + }); + + it("keeps @charset first in CSS", () => { + const out = rewrite( + "src/index.css", + `@charset "utf-8";\n@import "@lablup/ui-common/styles/base.css";\n.d { color: red; }\n`, + ); + expect(out.startsWith(`@charset "utf-8";\n${LAYER}`)).toBe(true); + }); +}); From a684443eac34070e9f666574c1cd2eacab466c28 Mon Sep 17 00:00:00 2001 From: Jongeun <jongeun@lablup.com> Date: Sat, 26 Sep 2026 06:43:56 +0000 Subject: [PATCH 44/87] fix(Modal)!: make the page behind an open modal inert, and mark the dialog aria-modal Modal renders into a body portal rather than the top layer and left the page behind it reachable: Tab and screen-reader browsing could leave the dialog, and the dialog did not claim to be modal (the test asserted the missing aria-modal on purpose, to keep the notification stack reachable). While any modal is open, the modal stack now makes every other child of document.body inert, and, where a kept element is nested, every sibling on the way down to it. Kept are the modal roots claimed through useModalLevel (Modal's, and a drawer portal's) and elements marked data-uic-modal-live (MODAL_LIVE_ATTRIBUTE), which NotificationStack sets on its root. Only an inert the stack set is removed on close. The topmost dialog carries aria-modal="true". refreshModalBackground() re-reads the marks when a live region mounts while a modal is open; NotificationStack calls it itself. BREAKING CHANGE: a portal or overlay of the app's own that must stay usable over an open Modal (a toaster, a chat widget) needs data-uic-modal-live, or it is inert until the modal closes. --- README.md | 14 ++- migration/0.1-to-0.2.json | 2 + src/components/Modal/Modal.test.tsx | 87 ++++++++++++++-- src/components/Modal/Modal.tsx | 15 +-- src/components/Modal/index.ts | 2 + src/components/Modal/modalStack.test.ts | 99 +++++++++++++++++++ src/components/Modal/modalStack.ts | 85 +++++++++++++++- .../NotificationStack/NotificationStack.tsx | 13 ++- src/index.ts | 2 + 9 files changed, 301 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index be5f2d9..2259ef2 100644 --- a/README.md +++ b/README.md @@ -191,11 +191,19 @@ What it adds: - It renders into a `document.body` portal instead of the browser's top layer, so whatever the app layers above the modal band, such as a notification - stack, stays visible and clickable. The band is `z-index` 1100 to 10999 by - default; `configureModalZIndex({ base, step, max })` moves it. + stack, stays visible. The band is `z-index` 1100 to 10999 by default; + `configureModalZIndex({ base, step, max })` moves it. +- While a modal is open, the topmost one is `aria-modal="true"` and the rest + of the page is `inert`, as `showModal()` would make it. Two things stay + reachable: modal roots, and elements marked `data-uic-modal-live` + (`MODAL_LIVE_ATTRIBUTE`). `NotificationStack` marks itself, so notices over a + modal can still be read and dismissed; mark your own live region the same + way, and call `refreshModalBackground()` if it mounts while a modal is open. + An `inert` the page set itself is left as it was. - A modal opened from inside another paints above it. Only the topmost one traps focus and takes Escape; covered ones are `inert`. Other portalled - surfaces can join the same stack with `useModalLevel`. + surfaces (a drawer) join the same stack with `useModalLevel`, which also + keeps them out of the inert background. - Content mounts on first open and stays mounted while closed. `unmountOnClose` drops it. `afterOpenChange` reports each open and close. - With `title`, `onAction` or `footer`, it lays out a header, the body and a diff --git a/migration/0.1-to-0.2.json b/migration/0.1-to-0.2.json index 380c135..418ec4a 100644 --- a/migration/0.1-to-0.2.json +++ b/migration/0.1-to-0.2.json @@ -629,6 +629,8 @@ "configureModalZIndex", "useModalLevel", "MODAL_OPEN_ATTRIBUTE", + "MODAL_LIVE_ATTRIBUTE", + "refreshModalBackground", "MAX_MODAL_LEVEL" ] } diff --git a/src/components/Modal/Modal.test.tsx b/src/components/Modal/Modal.test.tsx index 03095ba..c379713 100644 --- a/src/components/Modal/Modal.test.tsx +++ b/src/components/Modal/Modal.test.tsx @@ -1,7 +1,7 @@ /** - * Modal: the portalled surface (no native `<dialog>`, nothing outside made - * unavailable), nesting, dismissal by purpose, the content lifecycle, and the - * structured header/footer mode with its catalog defaults. + * Modal: the portalled surface (no native `<dialog>`; the page behind it inert, + * notification stacks excepted), nesting, dismissal by purpose, the content + * lifecycle, and the structured header/footer mode with its catalog defaults. * * jsdom treats `inert` as markup only, and there is no layout, so these check * the attributes and custom properties the browser acts on. @@ -16,7 +16,12 @@ import { Layout, LayoutContent } from "@astryxdesign/core/Layout"; import { Theme, defineTheme } from "@astryxdesign/core/theme"; import { DialogHeader, Modal, ModalHeader } from "."; -import { MODAL_OPEN_ATTRIBUTE, configureModalZIndex } from "./modalStack"; +import { NotificationStack } from "../NotificationStack"; +import { + MODAL_LIVE_ATTRIBUTE, + MODAL_OPEN_ATTRIBUTE, + configureModalZIndex, +} from "./modalStack"; type ModalTestProps = Partial<ComponentProps<typeof Modal>>; @@ -63,9 +68,70 @@ describe("Modal surface", () => { expect(rootOf()).toHaveAttribute(MODAL_OPEN_ATTRIBUTE); }); - it("claims nothing outside the modal is unavailable", () => { - renderModal(); - expect(screen.getByRole("dialog")).not.toHaveAttribute("aria-modal"); + it("is aria-modal, and makes the page behind it inert until it closes", () => { + const page = document.createElement("main"); + document.body.prepend(page); + const { rerender, container } = renderModal(); + expect(screen.getByRole("dialog")).toHaveAttribute("aria-modal", "true"); + expect(page).toHaveAttribute("inert"); + expect(container).toHaveAttribute("inert"); + expect(rootOf()).not.toHaveAttribute("inert"); + + rerender( + <Modal isOpen={false} onOpenChange={vi.fn()}> + body + </Modal>, + ); + expect(page).not.toHaveAttribute("inert"); + expect(container).not.toHaveAttribute("inert"); + page.remove(); + }); + + it("keeps a NotificationStack reachable over the modal", () => { + render( + <> + <main>page</main> + <NotificationStack + notifications={[{ key: "n", title: "Saved" }]} + onClose={vi.fn()} + data-testid="stack" + /> + <Modal isOpen onOpenChange={vi.fn()} aria-label="over"> + <button type="button">inside</button> + </Modal> + </>, + ); + const stack = screen.getByTestId("stack"); + expect(stack).toHaveAttribute(MODAL_LIVE_ATTRIBUTE); + for (let el: HTMLElement | null = stack; el; el = el.parentElement) + expect(el).not.toHaveAttribute("inert"); + expect(screen.getByText("page")).toHaveAttribute("inert"); + }); + + it("picks up a NotificationStack that appears while the modal is open", () => { + function App({ notices }: { notices: number }) { + return ( + <> + <main>page</main> + <NotificationStack + notifications={Array.from({ length: notices }, (_, i) => ({ + key: i, + title: `Notice ${i}`, + }))} + onClose={vi.fn()} + data-testid="stack" + /> + <Modal isOpen onOpenChange={vi.fn()} aria-label="over"> + body + </Modal> + </> + ); + } + const { rerender, container } = render(<App notices={0} />); + expect(container).toHaveAttribute("inert"); + rerender(<App notices={1} />); + expect(container).not.toHaveAttribute("inert"); + expect(screen.getByText("page")).toHaveAttribute("inert"); }); it("keeps the .astryx-dialog surface themes key off", () => { @@ -210,6 +276,13 @@ describe("Modal nesting", () => { expect(zOf(inner)).toBeGreaterThan(zOf(outer)); expect(outer).toHaveAttribute("inert"); expect(inner).not.toHaveAttribute("inert"); + expect(screen.getByRole("dialog", { name: "inner" })).toHaveAttribute( + "aria-modal", + "true", + ); + expect(screen.getByRole("dialog", { name: "outer" })).not.toHaveAttribute( + "aria-modal", + ); }); it("keeps Tab inside the modal opened on top", async () => { diff --git a/src/components/Modal/Modal.tsx b/src/components/Modal/Modal.tsx index e48e953..bb92274 100644 --- a/src/components/Modal/Modal.tsx +++ b/src/components/Modal/Modal.tsx @@ -8,10 +8,12 @@ * What it changes about `Dialog`: * * - **Portalled, not top layer.** The surface renders into a `document.body` - * portal instead of a native `<dialog>` promoted with `showModal()`. Nothing - * the app layers above the modal band (a notification stack) is covered or - * made inert, so notices stay visible and clickable over an open modal. - * Covered modal roots are inert instead; see modalStack.ts. + * portal instead of a native `<dialog>` promoted with `showModal()`. While + * it is open the page behind it is inert and the topmost surface is + * `aria-modal`, as `showModal()` would make them, but an element marked + * `MODAL_LIVE_ATTRIBUTE` (`NotificationStack` is) stays reachable, so + * notices stay visible and clickable over an open modal. Covered modal + * roots are inert too; see modalStack.ts. * - **Nesting.** A modal opened from inside another paints above it, and * only the topmost one traps focus and answers Escape. * - **Content lifecycle.** Children mount on first open and stay mounted @@ -478,8 +480,9 @@ export function Modal({ ? (role ?? (purpose === "required" ? "alertdialog" : "dialog")) : undefined } - // No `aria-modal`: it would claim everything outside is unavailable, - // and the layers above the modal band are not. + // True: modalStack makes everything outside inert, except what is + // marked MODAL_LIVE_ATTRIBUTE (a notification stack). + aria-modal={isOpen && isTopmost ? true : undefined} > <Dialog isInline diff --git a/src/components/Modal/index.ts b/src/components/Modal/index.ts index f1c95c7..d98888a 100644 --- a/src/components/Modal/index.ts +++ b/src/components/Modal/index.ts @@ -24,8 +24,10 @@ export type { export { MAX_MODAL_LEVEL, + MODAL_LIVE_ATTRIBUTE, MODAL_OPEN_ATTRIBUTE, configureModalZIndex, + refreshModalBackground, useModalLevel, } from "./modalStack"; export type { ModalZIndexBand } from "./modalStack"; diff --git a/src/components/Modal/modalStack.test.ts b/src/components/Modal/modalStack.test.ts index f9c52eb..fd05743 100644 --- a/src/components/Modal/modalStack.test.ts +++ b/src/components/Modal/modalStack.test.ts @@ -2,8 +2,10 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { MAX_MODAL_LEVEL, + MODAL_LIVE_ATTRIBUTE, claimModalLevel, configureModalZIndex, + refreshModalBackground, releaseModalLevel, type ModalLevelEntry, } from "./modalStack"; @@ -86,3 +88,100 @@ describe("modalStack", () => { expect(secondLast?.root).not.toHaveAttribute("inert"); }); }); + +describe("modalStack: the page behind an open modal", () => { + const added: Element[] = []; + /** Append `html` to document.body and return its first element. */ + function mount(html: string) { + const template = document.createElement("template"); + template.innerHTML = html.trim(); + const el = template.content.firstElementChild as HTMLElement; + document.body.append(el); + added.push(el); + return el; + } + /** Claim a level for a root portalled straight into document.body. */ + function open(parent: Element = document.body) { + const root = document.createElement("div"); + parent.append(root); + added.push(root); + const entry = claimModalLevel(root, vi.fn()); + claimed.push(entry); + return { root, entry }; + } + afterEach(() => { + for (const entry of claimed.splice(0)) releaseModalLevel(entry); + for (const el of added.splice(0)) el.remove(); + }); + + it("inerts every other child of document.body, and restores them on close", () => { + const app = mount(`<div id="app"><button>behind</button></div>`); + const toast = mount(`<div id="toast"></div>`); + const { root, entry } = open(); + expect(app).toHaveAttribute("inert"); + expect(toast).toHaveAttribute("inert"); + expect(root).not.toHaveAttribute("inert"); + releaseModalLevel(entry); + expect(app).not.toHaveAttribute("inert"); + expect(toast).not.toHaveAttribute("inert"); + }); + + it("keeps the background inert until the last modal closes", () => { + const app = mount(`<div id="app"></div>`); + const outer = open(); + const inner = open(); + expect(app).toHaveAttribute("inert"); + expect(outer.root).toHaveAttribute("inert"); + expect(inner.root).not.toHaveAttribute("inert"); + releaseModalLevel(inner.entry); + expect(app).toHaveAttribute("inert"); + expect(outer.root).not.toHaveAttribute("inert"); + releaseModalLevel(outer.entry); + expect(app).not.toHaveAttribute("inert"); + }); + + it("never clears an inert it did not set", () => { + const frozen = mount(`<div id="frozen" inert></div>`); + const { entry } = open(); + releaseModalLevel(entry); + expect(frozen).toHaveAttribute("inert"); + }); + + it(`leaves an element marked ${MODAL_LIVE_ATTRIBUTE} reachable, wherever it is`, () => { + const app = mount( + `<div id="app"><main id="page"></main><div id="notices" ${MODAL_LIVE_ATTRIBUTE}></div></div>`, + ); + const page = app.querySelector("#page"); + const notices = app.querySelector("#notices"); + const { entry } = open(); + expect(app).not.toHaveAttribute("inert"); + expect(notices).not.toHaveAttribute("inert"); + expect(page).toHaveAttribute("inert"); + releaseModalLevel(entry); + expect(page).not.toHaveAttribute("inert"); + }); + + it("treats a root that is not a direct child of body as a modal root", () => { + // A drawer portal, say, that claims its level through useModalLevel. + const host = mount(`<div id="host"><div id="sibling"></div></div>`); + const { root } = open(host); + expect(host).not.toHaveAttribute("inert"); + expect(root).not.toHaveAttribute("inert"); + expect(host.querySelector("#sibling")).toHaveAttribute("inert"); + }); + + it("picks up a live region that mounts while a modal is open", () => { + const app = mount(`<div id="app"><main id="page"></main></div>`); + open(); + expect(app).toHaveAttribute("inert"); + const notices = document.createElement("div"); + notices.setAttribute(MODAL_LIVE_ATTRIBUTE, ""); + app.append(notices); + refreshModalBackground(); + expect(app).not.toHaveAttribute("inert"); + expect(app.querySelector("#page")).toHaveAttribute("inert"); + notices.remove(); + refreshModalBackground(); + expect(app).toHaveAttribute("inert"); + }); +}); diff --git a/src/components/Modal/modalStack.ts b/src/components/Modal/modalStack.ts index d4961df..3741dce 100644 --- a/src/components/Modal/modalStack.ts +++ b/src/components/Modal/modalStack.ts @@ -4,7 +4,9 @@ * `Modal` renders into a `document.body` portal instead of the browser's top * layer (see Modal.tsx for why). Without the top layer, stacking and * inertness are this module's job: a surface opened from inside another - * paints above it, and only the topmost one is interactive. + * paints above it, only the topmost one is interactive, and while any is + * open the page behind them is inert, except what is marked + * `MODAL_LIVE_ATTRIBUTE`. * * Other portalled modal surfaces (a scrimmed drawer, say) claim a level with * `useModalLevel` so they stack on the same order as `Modal`. @@ -80,12 +82,93 @@ function resolveZIndex( return Math.min(Math.max(floor + band.step, override ?? 0), band.max); } +/** + * Marks an element that stays reachable while a modal is open: it, and the + * ancestors it needs, are never made inert. `NotificationStack` sets it on + * its root, so a notice raised over a modal can still be read and dismissed. + * The mark is read when a modal opens or closes and on + * `refreshModalBackground()`; call that after mounting a marked element while + * a modal is open. + */ +export const MODAL_LIVE_ATTRIBUTE = "data-uic-modal-live"; + +/** The background elements this module made inert, and only those. */ +const inertedBackground = new Set<Element>(); + +const NEVER_INERT = new Set(["SCRIPT", "STYLE", "LINK", "TEMPLATE", "NOSCRIPT"]); + +/** + * The elements to inert while `keep` stays reachable: every child of + * `document.body` that holds none of them, and, inside a body child that + * does, every sibling on the way down to them. + */ +function backgroundOf(keep: Element[]): Element[] { + const kept = new Set(keep); + const onPath = new Set<Element>(); + for (const el of keep) { + for (let node = el.parentElement; node && node !== document.body;) { + onPath.add(node); + node = node.parentElement; + } + } + const out: Element[] = []; + const visit = (parent: Element) => { + for (const child of Array.from(parent.children)) { + if (kept.has(child) || NEVER_INERT.has(child.tagName)) continue; + if (onPath.has(child)) visit(child); + else out.push(child); + } + }; + visit(document.body); + return out; +} + +/** + * While any modal is open the rest of the page is `inert`, which is what + * `aria-modal` promises. Modal roots (a drawer's too, when it claims a level + * through `useModalLevel`) and elements marked `MODAL_LIVE_ATTRIBUTE` are + * spared. An `inert` the page set itself is left alone, before and after. + */ +function syncBackground(): void { + if (typeof document === "undefined" || !document.body) return; + const roots = openModals + .map((entry) => entry.root) + .filter((root): root is HTMLElement => root != null && root.isConnected); + const target = new Set( + openModals.length === 0 + ? [] + : backgroundOf([ + ...roots, + ...Array.from(document.querySelectorAll(`[${MODAL_LIVE_ATTRIBUTE}]`)), + ]), + ); + for (const el of inertedBackground) { + if (target.has(el)) continue; + el.removeAttribute("inert"); + inertedBackground.delete(el); + } + for (const el of target) { + if (inertedBackground.has(el) || el.hasAttribute("inert")) continue; + el.setAttribute("inert", ""); + inertedBackground.add(el); + } +} + +/** + * Re-reads which elements are marked `MODAL_LIVE_ATTRIBUTE` (and which modal + * roots are in the document) while a modal is open. A no-op otherwise. + */ +export function refreshModalBackground(): void { + if (openModals.length > 0 || inertedBackground.size > 0) syncBackground(); +} + /** * Only the topmost surface stays interactive. A covered one drops its focus * trap and goes `inert`: the trap alone lets Tab escape to the parent, and * `inert` alone leaves the covered trap swallowing Tab with nothing to focus. */ function syncCovered(): void { + syncBackground(); openModals.forEach((entry, index) => { const isTopmost = index === openModals.length - 1; entry.root?.toggleAttribute("inert", !isTopmost); diff --git a/src/components/NotificationStack/NotificationStack.tsx b/src/components/NotificationStack/NotificationStack.tsx index 25b96fc..3d231c5 100644 --- a/src/components/NotificationStack/NotificationStack.tsx +++ b/src/components/NotificationStack/NotificationStack.tsx @@ -39,6 +39,7 @@ import { useEffect, useEffectEvent, + useLayoutEffect, useRef, useState, type Key, @@ -51,6 +52,7 @@ import { HStack, VStack } from "@astryxdesign/core/Stack"; import { Text } from "@astryxdesign/core/Text"; import { useUicTranslator } from "../../i18n/useUicTranslator"; +import { MODAL_LIVE_ATTRIBUTE, refreshModalBackground } from "../Modal/modalStack"; import "./NotificationStack.css"; /** Matches the exit animation's budget in NotificationStack.css. */ @@ -325,13 +327,22 @@ export function NotificationStack({ const visibleKeys = new Set(visible.map((n) => n.key)); const stillExiting = exiting.filter((n) => !visibleKeys.has(n.key)); - if (visible.length === 0 && stillExiting.length === 0) return null; + const isRendered = visible.length > 0 || stillExiting.length > 0; + // The stack is marked to stay reachable over an open Modal; the modal stack + // re-reads the mark whenever the stack appears or goes. + useLayoutEffect(() => { + refreshModalBackground(); + return () => queueMicrotask(refreshModalBackground); + }, [isRendered]); + + if (!isRendered) return null; return ( <div ref={stackRef} className={["uic-notification-stack", className].filter(Boolean).join(" ")} data-testid={testId} + {...{ [MODAL_LIVE_ATTRIBUTE]: "" }} // Each Banner announces itself; the container stays out of the tree. role="presentation" > diff --git a/src/index.ts b/src/index.ts index e7b2ad7..b79f64d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1368,10 +1368,12 @@ export type { ImageWithFallbackProps } from "./components/ImageWithFallback"; export { DialogHeader, MAX_MODAL_LEVEL, + MODAL_LIVE_ATTRIBUTE, MODAL_OPEN_ATTRIBUTE, Modal, ModalHeader, configureModalZIndex, + refreshModalBackground, useModalLevel, } from "./components/Modal"; export type { From 1df5baffd1dcb772613cb8d5b4f55fb55fdc4bb1 Mon Sep 17 00:00:00 2001 From: Jongeun <jongeun@lablup.com> Date: Sat, 26 Sep 2026 06:52:11 +0000 Subject: [PATCH 45/87] fix(cli): point the lab canary's core peer at ui-common's core @astryxdesign/lab 0.6.2-canary.c9fb1ad peers on exactly the core canary it was cut from. A consumer that installs lab gets that canary core beside ui-common's (pnpm) or nested under lab (npm), and @lablup/ui-common/lab then runs on the second copy. - README's install section gives the override for each package manager: pnpm-workspace.yaml `overrides: {"@astryxdesign/lab>@astryxdesign/core": <pin>}` (pnpm 10+ ignores package.json's pnpm.overrides), and package.json `overrides: {"@astryxdesign/lab": {"@astryxdesign/core": <pin>}}` for npm. - `ui-common upgrade` applies the one for the project's package manager whenever it adds lab: package.json for npm (when the project is the install root), the nearest pnpm-workspace.yaml for pnpm (created when missing), a report note with both recipes otherwise. The workspace file goes through the same diff, dry run and write checks as the sources. - `ui-common sync-astryx` moves README's recipes and this repository's own peer rule with the core pin. - A test fails when the installed lab's core peer differs from the core pin and README's recipes are missing or stale. --- README.md | 34 ++- cli/lab-peer.mjs | 226 ++++++++++++++++++ cli/sync-astryx.mjs | 32 ++- cli/upgrade.mjs | 37 ++- codemods/0.2/package-json.mjs | 38 ++- test/cli/lab-peer.test.ts | 142 +++++++++++ .../expected/ui-common-upgrade-report.md | 1 + .../expected/ui-common-upgrade-report.md | 1 + test/upgrade/upgrade.test.ts | 67 ++++++ 9 files changed, 573 insertions(+), 5 deletions(-) create mode 100644 cli/lab-peer.mjs create mode 100644 test/cli/lab-peer.test.ts diff --git a/README.md b/README.md index 2259ef2..659bbc7 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ Peer dependencies: and your own StyleX code share. - `@astryxdesign/lab`, optional. Install it only if you use `@lablup/ui-common/lab`. It is pinned to the exact canary ui-common is built - against. + against, and it needs the override below. Astryx itself (`@astryxdesign/core`, `@astryxdesign/theme-neutral`, `@astryxdesign/cli`) comes in as ui-common's own dependencies, pinned exactly. @@ -38,6 +38,38 @@ Do not add them to your project. ui-common owns the Astryx version. Two copies of Astryx means two copies of its React contexts, and components stop seeing the theme. +### With `@lablup/ui-common/lab` + +The lab canary declares an exact peer on the core canary it was cut from, not +on the core ui-common pins. Without an override, pnpm installs that canary +core beside ui-common's, npm nests it under lab, and `@lablup/ui-common/lab` +runs on the second copy. Add the override for your package manager, next to +`@astryxdesign/lab` itself (`ui-common upgrade` adds both when it moves a +Drawer to lab): + +pnpm, in `pnpm-workspace.yaml` (pnpm 10 and later read settings only from +there): + +```yaml +overrides: + "@astryxdesign/lab>@astryxdesign/core": "0.6.2" +``` + +npm, in the root `package.json`: + +```json +"overrides": { + "@astryxdesign/lab": { "@astryxdesign/core": "0.6.2" } +} +``` + +The version is the `@astryxdesign/core` that ui-common pins; it moves with +each ui-common release that moves Astryx. Then `pnpm why @astryxdesign/core` +(or `npm ls @astryxdesign/core`) lists one version. pnpm may still warn that +lab's peer is unmet; with the override that is expected. A project that already +lists `@astryxdesign/core` itself, at the same version, gets the same effect +from pnpm resolving the peer to its own copy. + ### The GitHub Packages mirror The same versions are also published to GitHub Packages for projects that diff --git a/cli/lab-peer.mjs b/cli/lab-peer.mjs new file mode 100644 index 0000000..5d01d18 --- /dev/null +++ b/cli/lab-peer.mjs @@ -0,0 +1,226 @@ +/** + * `@astryxdesign/lab` is a canary with an exact peer on the core canary it was + * cut from, not on the stable core ui-common pins. Left alone, pnpm installs + * that canary core beside ui-common's and npm nests one under lab, so + * `@lablup/ui-common/lab` runs on a second copy of Astryx (two React + * contexts: lab components stop seeing the theme). One override per package + * manager points lab's core at ui-common's: + * + * - pnpm (`pnpm-workspace.yaml`): `"@astryxdesign/lab>@astryxdesign/core": "<pin>"` + * - npm (`package.json`): `"overrides": {"@astryxdesign/lab": {"@astryxdesign/core": "<pin>"}}` + * + * README carries both recipes at the core pin; `sync-astryx` moves them with + * the pin, a test fails when they go stale, and `ui-common upgrade` applies + * the one for the project's package manager when it adds lab. + */ +import { existsSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; + +import { dependencyDir } from "./paths.mjs"; + +export const LAB = "@astryxdesign/lab"; +export const CORE = "@astryxdesign/core"; +export const PNPM_OVERRIDE_KEY = `${LAB}>${CORE}`; + +/** The installed lab's declared core peer, or null when lab is not installed. */ +export function labCorePeer() { + const dir = dependencyDir(LAB); + if (!dir) return null; + const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8")); + return pkg.peerDependencies?.[CORE] ?? null; +} + +/** @param {string} pin */ +export const pnpmOverrideLine = (pin) => `"${PNPM_OVERRIDE_KEY}": "${pin}"`; + +/** @param {string} pin */ +export const npmOverride = (pin) => ({ [LAB]: { [CORE]: pin } }); + +const esc = (/** @type {string} */ s) => s.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&"); + +// The value slot is group 2 in both. +const PNPM_LINE = new RegExp( + String.raw`^([ \t]*["']?${esc(PNPM_OVERRIDE_KEY)}["']?[ \t]*:[ \t]*)(["']?[^"'\s#]+["']?)`, + "m", +); +const NPM_ENTRY = new RegExp( + String.raw`("${esc(LAB)}"\s*:\s*\{\s*"${esc(CORE)}"\s*:\s*)("[^"]*")`, +); + +const unquote = (/** @type {string} */ s) => s.replace(/^["']|["']$/g, ""); + +/** + * What is wrong with the recipes in `text` (README) for core `pin`: each one + * missing or at another version. Empty when both are current. + * + * @param {string} text + * @param {string} pin + * @returns {string[]} + */ +export function labOverrideProblems(text, pin) { + const problems = []; + for (const [name, pattern] of /** @type {const} */ ([ + ["pnpm", PNPM_LINE], + ["npm", NPM_ENTRY], + ])) { + const match = pattern.exec(text); + if (!match) + problems.push(`the ${name} override for ${LAB}'s ${CORE} peer is missing`); + else if (unquote(match[2]) !== pin) + problems.push( + `the ${name} override pins ${CORE} ${unquote(match[2])}, not ${pin}`, + ); + } + return problems; +} + +/** + * `text` with both recipes moved to `pin`. Everything else is left as it is. + * + * @param {string} text + * @param {string} pin + */ +export function syncLabOverrideDocs(text, pin) { + return text + .replace(PNPM_LINE, (_m, head) => `${head}"${pin}"`) + .replace(NPM_ENTRY, (_m, head) => `${head}"${pin}"`); +} + +/** + * The package manager a project installs with, from `packageManager` and the + * nearest lockfile or pnpm workspace file at or above `projectDir`. + * + * @param {string} projectDir + * @param {any} pkg the project's package.json + * @returns {{manager: 'pnpm' | 'npm' | 'yarn' | 'bun' | null, root: string, workspaceYaml: string | null}} + */ +export function detectPackageManager(projectDir, pkg) { + const declared = /^(pnpm|npm|yarn|bun)@/.exec(pkg?.packageManager ?? "")?.[1] ?? null; + const markers = /** @type {const} */ ([ + ["pnpm-workspace.yaml", "pnpm"], + ["pnpm-lock.yaml", "pnpm"], + ["package-lock.json", "npm"], + ["npm-shrinkwrap.json", "npm"], + ["yarn.lock", "yarn"], + ["bun.lock", "bun"], + ["bun.lockb", "bun"], + ]); + for (let dir = projectDir; ;) { + const hit = markers.find(([file]) => existsSync(join(dir, file))); + if (hit) { + const manager = declared ?? hit[1]; + const workspaceYaml = + manager === "pnpm" + ? (nearestUp(dir, "pnpm-workspace.yaml") ?? join(dir, "pnpm-workspace.yaml")) + : null; + return { manager, root: dir, workspaceYaml }; + } + const parent = dirname(dir); + if (parent === dir || existsSync(join(dir, ".git"))) break; + dir = parent; + } + return { + manager: declared, + root: projectDir, + workspaceYaml: declared === "pnpm" ? join(projectDir, "pnpm-workspace.yaml") : null, + }; +} + +/** + * @param {string} start + * @param {string} file + */ +function nearestUp(start, file) { + for (let dir = start; ;) { + if (existsSync(join(dir, file))) return join(dir, file); + const parent = dirname(dir); + if (parent === dir || existsSync(join(dir, ".git"))) return null; + dir = parent; + } +} + +/** + * Both recipes, for a note where neither could be applied. + * + * @param {string} pin + */ +export function recipesNote(pin) { + return `pnpm: \`overrides: { ${pnpmOverrideLine(pin)} }\` in pnpm-workspace.yaml; npm: \`"overrides": ${JSON.stringify(npmOverride(pin))}\` in the root package.json`; +} + +/** + * Point lab's core peer at `pin` for one package manager. + * + * - npm: sets `pkg.overrides` in place (`pkg` is the parsed package.json). + * - pnpm: returns the edited `workspaceYaml` text (`null` in: a new file), or + * undefined when it already holds the pin or has a shape this cannot edit. + * - anything else: only a note, with both recipes. + * + * @param {{manager: string | null, pin: string, pkg?: any, workspaceYaml?: string | null}} options + * @returns {{note: string, workspaceYaml?: string}} + */ +export function applyLabOverride({ manager, pin, pkg, workspaceYaml }) { + const why = `${LAB} peers on a core canary, and without it the lab components run on a second copy of ${CORE}`; + if (manager === "npm" && pkg) { + const current = pkg.overrides?.[LAB]; + if (current !== undefined) { + const core = typeof current === "object" ? current?.[CORE] : undefined; + if (typeof core === "string" && core !== pin && !core.startsWith("$")) { + current[CORE] = pin; + return { note: `overrides["${LAB}"]["${CORE}"]: "${core}" → "${pin}".` }; + } + return { + note: `overrides["${LAB}"] was left as it is; it must resolve ${LAB}'s ${CORE} to ${pin} (${recipesNote(pin)}).`, + }; + } + pkg.overrides = { ...pkg.overrides, ...npmOverride(pin) }; + return { note: `added overrides["${LAB}"]["${CORE}"] = "${pin}": ${why}.` }; + } + if (manager === "pnpm") { + const line = pnpmOverrideLine(pin); + if (workspaceYaml == null) { + return { + workspaceYaml: `overrides:\n ${line}\n`, + note: `wrote pnpm-workspace.yaml with overrides ${line}: ${why}.`, + }; + } + // Only the top-level `overrides:` block counts: the same key under + // `peerDependencyRules.allowedVersions` silences a warning, nothing more. + const block = /^overrides:[ \t]*(\r?\n)/m.exec(workspaceYaml); + if (block) { + const at = block.index + block[0].length; + const after = workspaceYaml.slice(at); + const end = /^\S/m.exec(after)?.index ?? after.length; + const body = after.slice(0, end); + const existing = PNPM_LINE.exec(body); + if (existing) { + if (unquote(existing[2]) === pin) return { note: "" }; + return { + workspaceYaml: `${workspaceYaml.slice(0, at)}${body.replace( + PNPM_LINE, + (_m, head) => `${head}"${pin}"`, + )}${after.slice(end)}`, + note: `pnpm-workspace.yaml overrides: ${PNPM_OVERRIDE_KEY} ${unquote(existing[2])} → ${pin}.`, + }; + } + const indent = /^([ \t]+)\S/.exec(after)?.[1] ?? " "; + return { + workspaceYaml: `${workspaceYaml.slice(0, at)}${indent}${line}\n${after}`, + note: `added ${line} to the overrides in pnpm-workspace.yaml: ${why}.`, + }; + } + if (/^overrides\s*:/m.test(workspaceYaml)) { + return { + note: `pnpm-workspace.yaml has an overrides entry this cannot edit; add ${line} to it: ${why}.`, + }; + } + const base = workspaceYaml.replace(/\s*$/, ""); + return { + workspaceYaml: `${base}${base ? "\n\n" : ""}overrides:\n ${line}\n`, + note: `added overrides: ${line} to pnpm-workspace.yaml: ${why}.`, + }; + } + return { + note: `${manager ? `${manager} projects are not edited for this` : "no package manager was detected"}, so point ${LAB}'s ${CORE} peer at ui-common's by hand (${recipesNote(pin)}) and check that \`why ${CORE}\` lists one version.`, + }; +} diff --git a/cli/sync-astryx.mjs b/cli/sync-astryx.mjs index c882f28..9c2bf7e 100644 --- a/cli/sync-astryx.mjs +++ b/cli/sync-astryx.mjs @@ -2,7 +2,8 @@ * `ui-common sync-astryx <version>`: the maintainer's Astryx bump, inside the * ui-common repository. * - * 1. Move the exact pins (core, theme-neutral, cli; lab with --lab). + * 1. Move the exact pins (core, theme-neutral, cli; lab with --lab), and the + * core version README and pnpm-workspace.yaml override lab's core peer to. * 2. Install, regenerate the export mirror and the built Lablup theme. * 3. Run Astryx's codemods on ui-common's own src/ (dry run, then applied). * 4. Run the tests, which include the export and theme drift checks. @@ -22,6 +23,7 @@ import { execFileSync, spawnSync } from "node:child_process"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; +import { syncLabOverrideDocs } from "./lab-peer.mjs"; import { findProjectDir, readJson } from "./paths.mjs"; import { compare, nextBreaking, parse } from "./semver.mjs"; @@ -132,6 +134,27 @@ export function upstreamManifest(repo, version, astryx, listed) { return { file, manifest }; } +/** + * The files that name the core version lab's core peer is overridden to: + * README's consumer recipes and this repository's own peer rule. They move + * with the core pin (test/cli/lab-peer.test.ts fails when README lags). + * + * @param {string} repo + * @param {string} pin the new @astryxdesign/core pin + * @returns {Array<{name: string, file: string, after: string}>} + */ +export function labOverrideEdits(repo, pin) { + const edits = []; + for (const name of ["README.md", "pnpm-workspace.yaml"]) { + const file = join(repo, name); + if (!existsSync(file)) continue; + const before = readFileSync(file, "utf8"); + const after = syncLabOverrideDocs(before, pin); + if (after !== before) edits.push({ name, file, after }); + } + return edits; +} + /** * @param {string[]} argv arguments after `sync-astryx` */ @@ -223,6 +246,9 @@ export async function syncAstryxCommand(argv) { ? ` package.json: ${edits.join("; ")}` : " package.json: pins already at the target", ); + for (const edit of labOverrideEdits(repo, target)) { + out(` ${edit.name}: lab's core override → ${target}`); + } /** @param {string} title @param {string} cmd @param {string[]} args */ const step = (title, cmd, args) => { @@ -290,6 +316,10 @@ export async function syncAstryxCommand(argv) { } writeFileSync(pkgFile, `${JSON.stringify(pkg, null, 2)}\n`); out(" package.json written"); + for (const edit of labOverrideEdits(repo, target)) { + writeFileSync(edit.file, edit.after); + out(` ${edit.name}: lab's core override moved to ${target}`); + } if (!step("Install", "pnpm", ["install"])) return 1; if (!step("Regenerate the export mirror", "pnpm", ["run", "gen:exports"])) return 1; diff --git a/cli/upgrade.mjs b/cli/upgrade.mjs index 445e33d..d9d4b6b 100644 --- a/cli/upgrade.mjs +++ b/cli/upgrade.mjs @@ -185,7 +185,7 @@ export async function runUpgrade(options) { const { default: jscodeshift } = await import("jscodeshift"); - /** @type {Map<string, {original: string, current: string, transforms: string[], created: boolean}>} */ + /** @type {Map<string, {original: string, current: string, transforms: string[], created: boolean, project?: boolean}>} */ const state = new Map(); for (const file of files) { const source = readFileSync(file, "utf8"); @@ -209,7 +209,40 @@ export async function runUpgrade(options) { from, to, flags: { packages: new Map(), touched: new Set() }, + projectDir, note: (/** @type {string} */ message) => packageNotes.push(message), + /** + * Edit a project file outside the scanned sources (pnpm-workspace.yaml). + * `edit` gets its current text (null: absent) and returns the new text, + * or undefined to leave it. The write goes through the same checks, diff + * and dry run as the sources. + * + * @param {string} path + * @param {(current: string | null) => string | undefined} edit + */ + editFile: (path, edit) => { + const known = state.get(path); + const current = known + ? known.current + : existsSync(path) + ? readFileSync(path, "utf8") + : null; + const next = edit(current); + if (next == null || next === current) return; + if (known) { + known.current = next; + if (!known.transforms.includes("package-json")) + known.transforms.push("package-json"); + return; + } + state.set(path, { + original: current ?? "", + current: next, + transforms: ["package-json"], + created: current == null, + project: true, + }); + }, /** * Claim a new file and return the path it will be written to. A file the * project already has, scanned or not, is never overwritten: one holding @@ -315,7 +348,7 @@ export async function runUpgrade(options) { Object.assign(categories, step.categories ?? {}); if (!step.scan) continue; for (const [file, entry] of state) { - if (entry.created) continue; + if (entry.created || entry.project) continue; findings.push(...step.scan(rel(file), entry.current)); } } diff --git a/codemods/0.2/package-json.mjs b/codemods/0.2/package-json.mjs index b84ccfa..003cd05 100644 --- a/codemods/0.2/package-json.mjs +++ b/codemods/0.2/package-json.mjs @@ -3,8 +3,12 @@ * - bump @lablup/ui-common to the target version (keeping `^`/`~`); * - add the @stylexjs/stylex peer ui-common 0.2 needs, when missing; * - add @astryxdesign/lab, pinned to the canary ui-common is built against, - * when a Drawer import was moved to `@lablup/ui-common/lab`. + * when a Drawer import was moved to `@lablup/ui-common/lab`, and point its + * core peer at ui-common's core with the project's package manager's + * override (../../cli/lab-peer.mjs). */ +import { applyLabOverride, CORE, detectPackageManager } from "../../cli/lab-peer.mjs"; +import { ownPackageJson } from "../../cli/paths.mjs"; import { LAB_PACKAGE, stylexPeer, UIC } from "./map.mjs"; const FIELDS = /** @type {const} */ ([ @@ -48,6 +52,37 @@ function bumpSpec(spec, to) { }; } +/** + * Point the lab canary's core peer at ui-common's core: `overrides` in + * package.json for npm (the project's own, when it is the install root), + * `overrides` in pnpm-workspace.yaml for pnpm, a note otherwise. + * + * @param {any} pkg parsed package.json, edited in place + * @param {{projectDir?: string, note: (message: string) => void, editFile?: (path: string, edit: (current: string | null) => string | undefined) => void}} ctx + */ +function addLabOverride(pkg, ctx) { + const pin = ownPackageJson().dependencies?.[CORE]; + if (!pin || !ctx.projectDir || !ctx.editFile) return; + const { manager, root, workspaceYaml } = detectPackageManager(ctx.projectDir, pkg); + if (manager === "npm" && root !== ctx.projectDir) { + // npm reads overrides from the install root's package.json only. + const { note } = applyLabOverride({ manager: null, pin }); + ctx.note(`npm installs from ${root}, not this package: ${note}`); + return; + } + if (manager === "pnpm" && workspaceYaml) { + const yamlFile = workspaceYaml; + ctx.editFile(yamlFile, (current) => { + const edit = applyLabOverride({ manager, pin, workspaceYaml: current }); + if (edit.note) ctx.note(edit.note); + return edit.workspaceYaml; + }); + return; + } + const { note } = applyLabOverride({ manager, pin, pkg }); + ctx.note(note); +} + /** * @param {string} text package.json source * @param {{to: string, flags: {packages: Map<string, string>}, note: (message: string) => void}} ctx @@ -111,6 +146,7 @@ export function transformPackageJson(text, ctx) { ? `added ${lab} ${range} to ${field}: a Drawer moved to ${UIC}/lab, and ui-common pins the lab canary exactly.` : `added ${lab} ${range} to ${field}.`, ); + if (lab === LAB_PACKAGE) addLabOverride(pkg, ctx); } } diff --git a/test/cli/lab-peer.test.ts b/test/cli/lab-peer.test.ts new file mode 100644 index 0000000..9fbf458 --- /dev/null +++ b/test/cli/lab-peer.test.ts @@ -0,0 +1,142 @@ +/** + * The lab canary declares an exact peer on the core canary it was cut from, + * not on the stable core ui-common pins. Without an override a consumer gets + * a second core (pnpm, npm) and `@lablup/ui-common/lab` runs on it. These + * guard the recipe README gives consumers, the `sync-astryx` step that keeps + * it at the pin, and `upgrade`'s package-manager edits that apply it. + */ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; + +import { + applyLabOverride, + labCorePeer, + labOverrideProblems, + syncLabOverrideDocs, +} from "../../cli/lab-peer.mjs"; +import { ownPackageJson } from "../../cli/paths.mjs"; +import { labOverrideEdits } from "../../cli/sync-astryx.mjs"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); +const readme = readFileSync(join(root, "README.md"), "utf8"); +const corePin = ownPackageJson().dependencies["@astryxdesign/core"] as string; + +describe("the lab core override", () => { + it("is documented in README at the core pin whenever lab's core peer differs", () => { + const peer = labCorePeer(); + expect(peer, "@astryxdesign/lab is installed as a devDependency").toBeTruthy(); + if (peer === corePin) return; + expect(labOverrideProblems(readme, corePin)).toEqual([]); + }); + + it("reports a stale or missing recipe", () => { + expect(labOverrideProblems("# nothing here\n", corePin)).toHaveLength(2); + const stale = syncLabOverrideDocs(readme, "0.0.1"); + expect(labOverrideProblems(stale, corePin)).toHaveLength(2); + expect(labOverrideProblems(stale, "0.0.1")).toEqual([]); + }); + + it("moves both recipes to a new pin and nothing else", () => { + const moved = syncLabOverrideDocs(readme, "9.9.9"); + const changed = moved + .split("\n") + .filter((line, i) => line !== readme.split("\n")[i]); + expect(changed).toHaveLength(2); + for (const line of changed) expect(line).toContain('"9.9.9"'); + expect(syncLabOverrideDocs(moved, corePin)).toBe(readme); + }); +}); + +describe("sync-astryx", () => { + it("moves README's recipes and the repository's own peer rule with the core pin", () => { + const repo = mkdtempSync(join(tmpdir(), "uic-sync-lab-")); + try { + for (const name of ["README.md", "pnpm-workspace.yaml"]) + writeFileSync(join(repo, name), readFileSync(join(root, name), "utf8")); + expect(labOverrideEdits(repo, corePin)).toEqual([]); + const edits = labOverrideEdits(repo, "9.9.9"); + expect(edits.map((e) => e.name)).toEqual(["README.md", "pnpm-workspace.yaml"]); + expect(labOverrideProblems(edits[0]!.after, "9.9.9")).toEqual([]); + expect(edits[1]!.after).toContain( + '"@astryxdesign/lab>@astryxdesign/core": "9.9.9"', + ); + } finally { + rmSync(repo, { recursive: true, force: true }); + } + }); +}); + +describe("applyLabOverride", () => { + const pin = "0.6.2"; + + it("adds the npm override to package.json", () => { + const pkg = { name: "x", dependencies: { "@astryxdesign/lab": "canary" } }; + const edit = applyLabOverride({ manager: "npm", pkg, pin }); + expect(pkg).toMatchObject({ + overrides: { "@astryxdesign/lab": { "@astryxdesign/core": pin } }, + }); + expect(edit.note).toContain("overrides"); + }); + + it("keeps an npm override that already names a core for lab", () => { + const pkg = { + overrides: { + "@astryxdesign/lab": { "@astryxdesign/core": "$@astryxdesign/core" }, + }, + }; + const edit = applyLabOverride({ manager: "npm", pkg, pin }); + expect(pkg.overrides["@astryxdesign/lab"]["@astryxdesign/core"]).toBe( + "$@astryxdesign/core", + ); + expect(edit.note).toContain("left"); + }); + + it("adds the pnpm override to a workspace file, under an existing overrides block", () => { + const yaml = `packages:\n - app\noverrides:\n foo: 1.0.0\nallowBuilds:\n esbuild: true\n`; + const edit = applyLabOverride({ manager: "pnpm", workspaceYaml: yaml, pin }); + expect(edit.workspaceYaml).toBe( + `packages:\n - app\noverrides:\n "@astryxdesign/lab>@astryxdesign/core": "${pin}"\n foo: 1.0.0\nallowBuilds:\n esbuild: true\n`, + ); + }); + + it("appends an overrides block, or writes a new workspace file", () => { + expect( + applyLabOverride({ manager: "pnpm", workspaceYaml: "packages:\n - app", pin }) + .workspaceYaml, + ).toBe( + `packages:\n - app\n\noverrides:\n "@astryxdesign/lab>@astryxdesign/core": "${pin}"\n`, + ); + expect( + applyLabOverride({ manager: "pnpm", workspaceYaml: null, pin }).workspaceYaml, + ).toBe(`overrides:\n "@astryxdesign/lab>@astryxdesign/core": "${pin}"\n`); + }); + + it("moves a stale pnpm override to the pin and leaves a current one alone", () => { + const stale = `overrides:\n '@astryxdesign/lab>@astryxdesign/core': 0.6.1\n`; + expect( + applyLabOverride({ manager: "pnpm", workspaceYaml: stale, pin }).workspaceYaml, + ).toBe(`overrides:\n '@astryxdesign/lab>@astryxdesign/core': "${pin}"\n`); + const current = `overrides:\n "@astryxdesign/lab>@astryxdesign/core": "${pin}"\n`; + expect( + applyLabOverride({ manager: "pnpm", workspaceYaml: current, pin }).workspaceYaml, + ).toBeUndefined(); + }); + + it("does not mistake a peerDependencyRules entry for the override", () => { + const yaml = `peerDependencyRules:\n allowedVersions:\n "@astryxdesign/lab>@astryxdesign/core": "${pin}"\n`; + expect( + applyLabOverride({ manager: "pnpm", workspaceYaml: yaml, pin }).workspaceYaml, + ).toBe(`${yaml}\noverrides:\n "@astryxdesign/lab>@astryxdesign/core": "${pin}"\n`); + }); + + it("only notes the recipe for a package manager it cannot edit", () => { + const edit = applyLabOverride({ manager: null, pin }); + expect(edit.workspaceYaml).toBeUndefined(); + expect(edit.note).toContain('"@astryxdesign/lab>@astryxdesign/core"'); + expect(edit.note).toContain('"overrides"'); + }); +}); diff --git a/test/upgrade/fixtures/adapter/expected/ui-common-upgrade-report.md b/test/upgrade/fixtures/adapter/expected/ui-common-upgrade-report.md index 2cd9e16..5cf1f13 100644 --- a/test/upgrade/fixtures/adapter/expected/ui-common-upgrade-report.md +++ b/test/upgrade/fixtures/adapter/expected/ui-common-upgrade-report.md @@ -30,6 +30,7 @@ Scanned 4 files under `src`. - dependencies["@lablup/ui-common"]: "0.1.0-alpha.19" → "0.2.0-alpha.0". - added @stylexjs/stylex ^0.19.0 to dependencies. - added @astryxdesign/lab 0.6.2-canary.c9fb1ad to dependencies: a Drawer moved to @lablup/ui-common/lab, and ui-common pins the lab canary exactly. +- no package manager was detected, so point @astryxdesign/lab's @astryxdesign/core peer at ui-common's by hand (pnpm: `overrides: { "@astryxdesign/lab>@astryxdesign/core": "0.6.2" }` in pnpm-workspace.yaml; npm: `"overrides": {"@astryxdesign/lab":{"@astryxdesign/core":"0.6.2"}}` in the root package.json) and check that `why @astryxdesign/core` lists one version. ## Manual review diff --git a/test/upgrade/fixtures/subpath-barrel/expected/ui-common-upgrade-report.md b/test/upgrade/fixtures/subpath-barrel/expected/ui-common-upgrade-report.md index f11880c..8fa34b7 100644 --- a/test/upgrade/fixtures/subpath-barrel/expected/ui-common-upgrade-report.md +++ b/test/upgrade/fixtures/subpath-barrel/expected/ui-common-upgrade-report.md @@ -29,6 +29,7 @@ Scanned 6 files under `src`. - dependencies["@lablup/ui-common"]: "^0.1.0-alpha.19" → "^0.2.0-alpha.0". - added @stylexjs/stylex ^0.19.0 to dependencies. - added @astryxdesign/lab 0.6.2-canary.c9fb1ad to dependencies: a Drawer moved to @lablup/ui-common/lab, and ui-common pins the lab canary exactly. +- no package manager was detected, so point @astryxdesign/lab's @astryxdesign/core peer at ui-common's by hand (pnpm: `overrides: { "@astryxdesign/lab>@astryxdesign/core": "0.6.2" }` in pnpm-workspace.yaml; npm: `"overrides": {"@astryxdesign/lab":{"@astryxdesign/core":"0.6.2"}}` in the root package.json) and check that `why @astryxdesign/core` lists one version. ## Manual review diff --git a/test/upgrade/upgrade.test.ts b/test/upgrade/upgrade.test.ts index 11844a5..0534ba5 100644 --- a/test/upgrade/upgrade.test.ts +++ b/test/upgrade/upgrade.test.ts @@ -158,6 +158,73 @@ describe("ui-common upgrade 0.1 -> 0.2", () => { expect(readFileSync(join(dir, "reports/dry.md"), "utf8")).toContain("dry run"); }); + describe("points lab's core peer at ui-common's core when it adds lab", () => { + const own = JSON.parse(readFileSync(join(here, "../../package.json"), "utf8")) as { + dependencies: Record<string, string>; + }; + const pin = own.dependencies["@astryxdesign/core"]; + + it("npm: an overrides entry in package.json", async () => { + const dir = copyFixture("adapter"); + writeFileSync(join(dir, "package-lock.json"), "{}\n"); + const result = await runUpgrade({ cwd: dir, paths: ["src"], to: TO, ...quiet }); + expect(result.code, JSON.stringify(result.errors)).toBe(0); + const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8")); + expect(pkg.dependencies["@astryxdesign/lab"]).toBeTruthy(); + expect(pkg.overrides).toEqual({ + "@astryxdesign/lab": { "@astryxdesign/core": pin }, + }); + }); + + it("pnpm: an overrides entry in pnpm-workspace.yaml, created when missing", async () => { + const dir = copyFixture("adapter"); + writeFileSync(join(dir, "pnpm-lock.yaml"), "lockfileVersion: '9.0'\n"); + const result = await runUpgrade({ cwd: dir, paths: ["src"], to: TO, ...quiet }); + expect(result.code, JSON.stringify(result.errors)).toBe(0); + expect(readFileSync(join(dir, "pnpm-workspace.yaml"), "utf8")).toBe( + `overrides:\n "@astryxdesign/lab>@astryxdesign/core": "${pin}"\n`, + ); + expect( + JSON.parse(readFileSync(join(dir, "package.json"), "utf8")).overrides, + ).toBeUndefined(); + }); + + it("pnpm: the workspace root's pnpm-workspace.yaml, above the project", async () => { + const workspace = mkdtempSync(join(tmpdir(), "uic-upgrade-ws-")); + temps.push(workspace); + writeFileSync(join(workspace, "pnpm-workspace.yaml"), "packages:\n - app\n"); + cpSync(join(FIXTURES, "adapter", "input"), join(workspace, "app"), { + recursive: true, + }); + const dir = join(workspace, "app"); + const result = await runUpgrade({ cwd: dir, paths: ["src"], to: TO, ...quiet }); + expect(result.code, JSON.stringify(result.errors)).toBe(0); + expect(readFileSync(join(workspace, "pnpm-workspace.yaml"), "utf8")).toBe( + `packages:\n - app\n\noverrides:\n "@astryxdesign/lab>@astryxdesign/core": "${pin}"\n`, + ); + expect(existsSync(join(dir, "pnpm-workspace.yaml"))).toBe(false); + }); + + it("a dry run shows the edit and writes nothing", async () => { + const dir = copyFixture("adapter"); + writeFileSync(join(dir, "pnpm-lock.yaml"), "lockfileVersion: '9.0'\n"); + const lines: string[] = []; + await runUpgrade({ + cwd: dir, + paths: ["src"], + to: TO, + dryRun: true, + diff: true, + log: (l: string) => lines.push(l), + warn: () => {}, + }); + expect(existsSync(join(dir, "pnpm-workspace.yaml"))).toBe(false); + expect(lines.join("\n")).toContain( + `+ "@astryxdesign/lab>@astryxdesign/core": "${pin}"`, + ); + }); + }); + describe("never overwrites a file this run did not produce", () => { const own = "/* the project's own entry */\n@import './brand.css';\n"; From fcdecae008df22c97ceb33da4f7a23d9214c0a21 Mon Sep 17 00:00:00 2001 From: Jongeun <jongeun@lablup.com> Date: Sat, 26 Sep 2026 06:55:43 +0000 Subject: [PATCH 46/87] fix(codemods): swap only module specifiers around upstream Astryx codemods The wrapper that runs Astryx's codemods on a ui-common consumer swapped every occurrence of @lablup/ui-common and @astryxdesign/core in the file, so comments and plain strings naming either package were rewritten too. It now swaps the specifiers of imports, re-exports, import() and require() only; a file jscodeshift cannot parse is still swapped as a whole. --- codemods/upstream.mjs | 66 +++++++++++++++++++++++++++++++++++++++---- test/cli/cli.test.ts | 28 ++++++++++++++++++ 2 files changed, 88 insertions(+), 6 deletions(-) diff --git a/codemods/upstream.mjs b/codemods/upstream.mjs index 5d2efae..46ca038 100644 --- a/codemods/upstream.mjs +++ b/codemods/upstream.mjs @@ -3,9 +3,11 @@ * * Astryx codemods match `@astryxdesign/*` import specifiers; a ui-common * consumer imports the same modules as `@lablup/ui-common/*`. So each - * transform sees the file with the specifiers swapped to Astryx's, and its - * output is swapped back. Text the transform did not touch round-trips - * unchanged, and a file it does not change is left alone. + * transform sees the file with the module specifiers swapped to Astryx's, and + * its output's module specifiers are swapped back. Only specifiers are + * swapped (imports, re-exports, `import()`, `require()`): a comment or string + * that names either package is left as written. A file the transform does not + * change is left alone. * * `codemods/<ui-common version>/upstream.json` (written by * `ui-common sync-astryx`) names the Astryx version range and codemod ids. @@ -31,6 +33,57 @@ export function toAstryxSpecifiers(text) { .replace(/@lablup\/ui-common(?=[/"'`\s),;:]|$)/g, "@astryxdesign/core"); } +/** + * Apply `swap` to the module specifier strings of a script and nothing else. + * A source jscodeshift cannot parse (a stylesheet an Astryx codemod targets) + * is swapped as a whole. + * + * @param {any} j + * @param {string} source + * @param {(text: string) => string} swap + */ +export function swapModuleSpecifiers(j, source, swap) { + let root; + try { + root = j(source); + } catch { + return swap(source); + } + /** @type {Array<{start: number, end: number}>} */ + const ranges = []; + const add = (/** @type {any} */ node) => { + const isString = + node?.type === "StringLiteral" || + (node?.type === "Literal" && typeof node.value === "string"); + if (isString && typeof node.start === "number" && typeof node.end === "number") + ranges.push({ start: node.start, end: node.end }); + }; + root.find(j.ImportDeclaration).forEach((/** @type {any} */ p) => add(p.node.source)); + root + .find(j.ExportNamedDeclaration) + .forEach((/** @type {any} */ p) => add(p.node.source)); + root + .find(j.ExportAllDeclaration) + .forEach((/** @type {any} */ p) => add(p.node.source)); + root.find(j.CallExpression).forEach((/** @type {any} */ p) => { + const callee = p.node.callee; + const isImport = callee.type === "Import"; + const isRequire = callee.type === "Identifier" && callee.name === "require"; + if (isImport || isRequire) add(p.node.arguments[0]); + }); + if (j.ImportExpression) + root.find(j.ImportExpression).forEach((/** @type {any} */ p) => add(p.node.source)); + if (j.TSImportType) + root.find(j.TSImportType).forEach((/** @type {any} */ p) => { + add(p.node.argument?.literal ?? p.node.argument); + }); + let out = source; + for (const { start, end } of ranges.sort((a, b) => b.start - a.start)) { + out = `${out.slice(0, start)}${swap(out.slice(start, end))}${out.slice(end)}`; + } + return out; +} + /** * @typedef {{astryx: {from: string, to: string}, codemods: Array<{id: string, version: string, title?: string}>}} UpstreamManifest */ @@ -48,13 +101,14 @@ export function wrapAstryxTransform(entry, version) { extensions, parse: extensions.some((e) => DEFAULT_EXTENSIONS.includes(e)), run(file, api) { - const swapped = toAstryxSpecifiers(file.source); + const j = api.jscodeshift; + const swapped = swapModuleSpecifiers(j, file.source, toAstryxSpecifiers); const out = entry.transform( { path: file.path, source: swapped }, - { jscodeshift: api.jscodeshift, stats: () => {}, report: () => {} }, + { jscodeshift: j, stats: () => {}, report: () => {} }, ); if (out == null || out === swapped) return undefined; - return rewriteSpecifiers(out); + return swapModuleSpecifiers(j, out, rewriteSpecifiers); }, }; } diff --git a/test/cli/cli.test.ts b/test/cli/cli.test.ts index 8d2b5a3..86323b0 100644 --- a/test/cli/cli.test.ts +++ b/test/cli/cli.test.ts @@ -385,6 +385,34 @@ describe("upstream Astryx codemods", () => { ), ).toBeUndefined(); }); + + it("swaps module specifiers only, never comments or other strings", async () => { + const step = await upstreamStep({ + astryx: { from: "0.5.4", to: "0.6.0" }, + codemods: [{ id: "move-ime-helper-import", version: "0.6.0" }], + }); + const j = jscodeshift.withParser("tsx"); + const source = [ + 'import { isImeKeyEvent } from "@lablup/ui-common/hooks";', + "// Built on @lablup/ui-common, which wraps @astryxdesign/core.", + 'const lazy = () => import("@lablup/ui-common/Button");', + 'export const note = "see @astryxdesign/core/hooks and @lablup/ui-common/hooks";', + "", + ].join("\n"); + const out = step.transforms[0]!.run( + { path: "a.tsx", source }, + { jscodeshift: j }, + {}, + ) as string; + expect(out).toContain("@lablup/ui-common/utils"); + expect(out).toContain( + "// Built on @lablup/ui-common, which wraps @astryxdesign/core.", + ); + expect(out).toContain( + 'export const note = "see @astryxdesign/core/hooks and @lablup/ui-common/hooks";', + ); + expect(out).toContain('import("@lablup/ui-common/Button")'); + }); }); describe("sync-astryx", { timeout: 60_000 }, () => { From c507291f2f31322a8053ae7ec866dda27d15741e Mon Sep 17 00:00:00 2001 From: Jongeun <jongeun@lablup.com> Date: Sat, 26 Sep 2026 06:55:43 +0000 Subject: [PATCH 47/87] test(cli): cover upgrade reading --from from package.json The test named for it always passed --from. It now runs without one and checks the version package.json declares is the one the report starts from, and that a project already declaring the target has nothing to do. --- test/upgrade/upgrade.test.ts | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/test/upgrade/upgrade.test.ts b/test/upgrade/upgrade.test.ts index 0534ba5..ebf4fd7 100644 --- a/test/upgrade/upgrade.test.ts +++ b/test/upgrade/upgrade.test.ts @@ -288,7 +288,35 @@ describe("ui-common upgrade 0.1 -> 0.2", () => { }); }); - it("reads --from from package.json and refuses a non-upgrade", async () => { + it("reads --from from package.json", async () => { + // css-entry declares "@lablup/ui-common": "0.1.0-alpha.23". + const dir = copyFixture("css-entry"); + const result = await runUpgrade({ cwd: dir, paths: ["src"], to: TO, ...quiet }); + expect(result.code).toBe(0); + expect(result.report).toContain("`ui-common upgrade` 0.1.0-alpha.23 → " + TO); + expect(result.changed?.length).toBeGreaterThan(0); + + // Once package.json declares the target, there is nothing to do. + const upgraded = mkdtempSync(join(tmpdir(), "uic-upgrade-declared-")); + temps.push(upgraded); + cpSync(join(FIXTURES, "css-entry", "input"), upgraded, { recursive: true }); + const pkg = JSON.parse(readFileSync(join(upgraded, "package.json"), "utf8")); + pkg.dependencies["@lablup/ui-common"] = `^${TO}`; + writeFileSync(join(upgraded, "package.json"), JSON.stringify(pkg)); + const lines: string[] = []; + const none = await runUpgrade({ + cwd: upgraded, + paths: ["src"], + to: TO, + log: (l: string) => lines.push(l), + warn: () => {}, + }); + expect(none.code).toBe(0); + expect(none.changed).toBeUndefined(); + expect(lines.join("\n")).toContain(`${TO} → ${TO} is not an upgrade`); + }); + + it("refuses a non-upgrade and a --from that is not a version", async () => { const dir = copyFixture("css-entry"); const same = await runUpgrade({ cwd: dir, From 3e41217b6e3ae5718b7792b75af11e7813ec35bc Mon Sep 17 00:00:00 2001 From: Jongeun <jongeun@lablup.com> Date: Sat, 26 Sep 2026 06:58:11 +0000 Subject: [PATCH 48/87] chore: prepare 0.2.0-alpha.7 --- CHANGELOG.md | 58 ++++++++++++++++++++++++++++++++++++++++++++++++++++ package.json | 2 +- 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7123bdd..28065f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,64 @@ Versioning follows the policy in [CONTRIBUTING.md](CONTRIBUTING.md#versioning). ## [Unreleased] +## [0.2.0-alpha.7] + +Review fixes: `Modal` now makes the page behind it inert, and the upgrade +tool stops overwriting files, capturing names and hiding lab's second core. + +### Changed + +- **`Modal` makes the page behind it inert** while it is open, and the + topmost dialog is `aria-modal="true"`, as `showModal()` would make them. + Every other child of `document.body` goes `inert` (and, where a kept + element is nested, every sibling on the way down to it), except modal + roots claimed through `useModalLevel` (a drawer portal's too) and elements + marked `data-uic-modal-live`. Closing the last modal removes only the + `inert` it set. Nested modals behave as before: only the topmost is + interactive. **Breaking:** an overlay of the app's own that must stay + usable over a modal (a toaster, a chat widget) needs `data-uic-modal-live`, + and `refreshModalBackground()` if it mounts while a modal is open. +- `NotificationStack` marks its root `data-uic-modal-live`, so notices stay + readable and dismissible over a modal. +- New exports from the root and `@lablup/ui-common/Modal`: + `MODAL_LIVE_ATTRIBUTE` and `refreshModalBackground`. +- `ui-common upgrade --dry-run` writes nothing: it prints the report after + the summary, and writes it only to a path given with `--report`. +- `ui-common upgrade` points `@astryxdesign/lab`'s core peer at ui-common's + core whenever it adds lab: an `overrides` entry in the nearest + `pnpm-workspace.yaml` (created when missing) for pnpm, in package.json for + npm, and a report note with both recipes otherwise. README's install + section documents the same recipes, and `ui-common sync-astryx` moves them + with the core pin. + +### Fixed + +- The lab canary (`0.6.2-canary.c9fb1ad`) peers on exactly the core canary + it was cut from, so a consumer got a second `@astryxdesign/core` and + `@lablup/ui-common/lab` ran on it. The documented overrides resolve it to + ui-common's core (verified with pnpm 11 and 12, and npm 11); a test fails + when lab's peer differs from the core pin and README's recipes are missing + or stale. +- Codemods: the Drawer `onClose` → `onOpenChange` wrapper named its + parameter `isOpen`, capturing a handler's own `isOpen` + (`() => { if (isOpen) close(); }` never ran). The parameter now takes a + name the file does not use. +- Codemods: a component rename (`BaseCard` → `Card`, `Tabs` → `TabList`) + checked only module-level names, so a function-local `const Card` captured + the import, and locals or parameters shadowing a 0.1 name were migrated. + The import now takes a free `Uic`-prefixed alias when any scope uses the + new name, and only references that resolve to the import are rewritten. +- `ui-common upgrade` overwrote an existing `ui-common-entry.css` outside + the scanned paths. A file this run did not read is never written: an + identical entry is reused, otherwise the entry goes to + `ui-common-entry-2.css` and the report says so. The report path is + replaced only when it holds an earlier report. +- The SCSS rewrite put the `@layer` order above `@use`, which Sass rejects. + It now follows the leading `@use`/`@forward` rules. +- Upstream Astryx codemods rewrote every mention of `@lablup/ui-common` and + `@astryxdesign/core` in a file, comments and strings included; only module + specifiers are swapped now. + ## [0.2.0-alpha.6] The last component moves from backend.ai-ui that do not wait on its theme diff --git a/package.json b/package.json index 2864fdf..9913864 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@lablup/ui-common", - "version": "0.2.0-alpha.6", + "version": "0.2.0-alpha.7", "description": "Lablup's UI layer on Astryx: the Astryx surface re-exported 1:1, the Lablup theme, and shared components", "license": "Apache-2.0", "author": "Lablup Inc.", From ce77e4494de2c71ab8766bb0b4efccfccdd12c0e Mon Sep 17 00:00:00 2001 From: Jongeun <jongeun@lablup.com> Date: Sat, 26 Sep 2026 08:03:59 +0000 Subject: [PATCH 49/87] fix(Modal): return focus to the opener when content autofocuses on open Modal captured its trigger (document.activeElement) in a layout effect. Layout effects run children first, so when the content mounted in the same commit as the open and a descendant autofocused (DeleteConfirmModal's confirm field, any autoFocus input), the "trigger" was that descendant and focus fell to <body> on close. It hit the first open after load, and every open with unmountOnClose. The trigger is now captured in an insertion effect, which runs in the mutation phase before any layout-phase focus and before the level stack inerts a covered modal. A capture inside the modal's own root is dropped. The restore cleanup no longer clears the capture, so StrictMode's replayed cleanup on mount does not lose it. Tests cover close by Escape, Cancel, the action and the backdrop, StrictMode, unmountOnClose, a nested opener, and DeleteConfirmModal end to end. ConfirmPopover captures before it moves focus itself and was not affected; a regression test pins that. --- .../ConfirmPopover/ConfirmPopover.test.tsx | 22 ++++ .../DeleteConfirmModal.test.tsx | 47 ++++++- src/components/Modal/Modal.test.tsx | 115 +++++++++++++++++- src/components/Modal/Modal.tsx | 24 ++-- 4 files changed, 194 insertions(+), 14 deletions(-) diff --git a/src/components/ConfirmPopover/ConfirmPopover.test.tsx b/src/components/ConfirmPopover/ConfirmPopover.test.tsx index 82bf1a2..e4e8f15 100644 --- a/src/components/ConfirmPopover/ConfirmPopover.test.tsx +++ b/src/components/ConfirmPopover/ConfirmPopover.test.tsx @@ -66,6 +66,28 @@ describe("ConfirmPopover", () => { await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); }); + it.each([ + ["Escape", () => userEvent.keyboard("{Escape}")], + ["Cancel", () => userEvent.click(screen.getByRole("button", { name: "Cancel" }))], + [ + "the action", + () => userEvent.click(screen.getByRole("button", { name: "Confirm" })), + ], + ])("returns focus to its trigger when closed by %s", async (_, closeWith) => { + render( + <ConfirmPopover title="Leave?"> + <Button label="Leave" /> + </ConfirmPopover>, + ); + await openWith("Leave"); + await waitFor(() => + expect(screen.getByRole("button", { name: "Cancel" })).toHaveFocus(), + ); + await closeWith(); + await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); + expect(screen.getByRole("button", { name: "Leave" })).toHaveFocus(); + }); + it("styles and disables the action as asked", async () => { render( <ConfirmPopover title="Delete?" actionVariant="destructive" isActionDisabled> diff --git a/src/components/DeleteConfirmModal/DeleteConfirmModal.test.tsx b/src/components/DeleteConfirmModal/DeleteConfirmModal.test.tsx index 60170d9..723e690 100644 --- a/src/components/DeleteConfirmModal/DeleteConfirmModal.test.tsx +++ b/src/components/DeleteConfirmModal/DeleteConfirmModal.test.tsx @@ -1,6 +1,6 @@ -import type { ComponentProps } from "react"; +import { useState, type ComponentProps } from "react"; import { describe, expect, it, vi } from "vitest"; -import { render, screen, within } from "@testing-library/react"; +import { render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { InternationalizationProvider } from "@astryxdesign/core/i18n"; @@ -206,3 +206,46 @@ describe("DeleteConfirmModal", () => { expect(screen.getByRole("button", { name: "취소" })).toBeInTheDocument(); }); }); + +describe("DeleteConfirmModal focus return", () => { + function Opener() { + const [isOpen, setIsOpen] = useState(false); + return ( + <> + <button type="button" onClick={() => setIsOpen(true)}> + delete folder + </button> + <DeleteConfirmModal + isOpen={isOpen} + onOpenChange={setIsOpen} + onAction={() => setIsOpen(false)} + items={[{ key: "a", label: "my-folder" }]} + confirmText="my-folder" + isConfirmInputRequired + /> + </> + ); + } + + const opener = () => + screen.getByRole("button", { name: "delete folder", hidden: true }); + + it("returns focus to the opener after its autofocused field held it", async () => { + const user = userEvent.setup(); + render(<Opener />); + + await user.click(opener()); + expect(document.activeElement).toBe(screen.getByRole("textbox")); + await user.keyboard("{Escape}"); + expect(document.activeElement).toBe(opener()); + + await user.click(opener()); + await user.click(screen.getByRole("button", { name: "Cancel" })); + expect(document.activeElement).toBe(opener()); + + await user.click(opener()); + await user.type(screen.getByRole("textbox"), "my-folder"); + await user.click(deleteButton()); + await waitFor(() => expect(document.activeElement).toBe(opener())); + }); +}); diff --git a/src/components/Modal/Modal.test.tsx b/src/components/Modal/Modal.test.tsx index c379713..ab55f9d 100644 --- a/src/components/Modal/Modal.test.tsx +++ b/src/components/Modal/Modal.test.tsx @@ -6,8 +6,8 @@ * jsdom treats `inert` as markup only, and there is no layout, so these check * the attributes and custom properties the browser acts on. */ -import { useState, type ComponentProps } from "react"; -import { act, fireEvent, render, screen } from "@testing-library/react"; +import { StrictMode, useState, type ComponentProps } from "react"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, describe, expect, it, vi } from "vitest"; import { DialogHeader as CoreDialogHeader } from "@astryxdesign/core/Dialog"; @@ -391,6 +391,117 @@ describe("Modal content lifecycle", () => { }); }); +describe("Modal focus return with autofocusing content", () => { + // The content mounts in the same commit as the open, and its field takes + // focus during that commit, before any effect of Modal's own runs. + function Opener(props: ModalTestProps) { + const [isOpen, setIsOpen] = useState(false); + return ( + <> + <button type="button" onClick={() => setIsOpen(true)}> + opener + </button> + <Modal + isOpen={isOpen} + onOpenChange={setIsOpen} + title="Autofocus" + onAction={() => setIsOpen(false)} + {...props} + > + <input aria-label="field" autoFocus /> + </Modal> + </> + ); + } + + type User = ReturnType<typeof userEvent.setup>; + const opener = () => screen.getByRole("button", { name: "opener", hidden: true }); + + const closers: Array<[string, (user: User) => Promise<void>]> = [ + ["Escape", (user) => user.keyboard("{Escape}")], + ["Cancel", (user) => user.click(screen.getByRole("button", { name: "Cancel" }))], + ["the action", (user) => user.click(screen.getByRole("button", { name: "OK" }))], + [ + "the backdrop", + async () => { + act(() => { + fireEvent.mouseDown(getMask()); + fireEvent.click(getMask()); + }); + }, + ], + ]; + + it.each(closers)( + "returns focus to the opener on the first open, closed by %s", + async (_, closeWith) => { + const user = userEvent.setup(); + render(<Opener />); + await user.click(opener()); + expect(screen.getByRole("dialog")).toContainElement( + document.activeElement as HTMLElement, + ); + + await closeWith(user); + // The action closes from an awaited clickAction, a tick later. + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + expect(document.activeElement).toBe(opener()); + }, + ); + + it("returns focus to the opener under StrictMode", async () => { + const user = userEvent.setup(); + render( + <StrictMode> + <Opener /> + </StrictMode>, + ); + await user.click(opener()); + await user.keyboard("{Escape}"); + expect(screen.queryByRole("dialog")).toBeNull(); + expect(document.activeElement).toBe(opener()); + }); + + it("returns focus on every open with unmountOnClose", async () => { + const user = userEvent.setup(); + render(<Opener unmountOnClose />); + for (let round = 0; round < 2; round += 1) { + await user.click(opener()); + expect(screen.getByRole("dialog")).toContainElement( + document.activeElement as HTMLElement, + ); + await user.keyboard("{Escape}"); + expect(document.querySelector(".uic-modal")).toBeNull(); + expect(document.activeElement).toBe(opener()); + } + }); + + it("returns focus to the opener inside a covered modal", async () => { + function NestedOpener() { + const [isInnerOpen, setIsInnerOpen] = useState(false); + return ( + <Modal isOpen onOpenChange={vi.fn()} aria-label="outer"> + <button type="button" onClick={() => setIsInnerOpen(true)}> + open inner + </button> + <Modal isOpen={isInnerOpen} onOpenChange={setIsInnerOpen} aria-label="inner"> + <input aria-label="inner field" autoFocus /> + </Modal> + </Modal> + ); + } + const user = userEvent.setup(); + render(<NestedOpener />); + const openInner = screen.getByRole("button", { name: "open inner" }); + await user.click(openInner); + expect(document.activeElement).toBe(screen.getByLabelText("inner field")); + + await user.keyboard("{Escape}"); + expect(screen.queryByRole("dialog", { name: "inner" })).toBeNull(); + expect(document.activeElement).toBe(openInner); + }); +}); + describe("Modal structured mode", () => { it("puts headerClassName and footerClassName on the generated chrome", () => { render( diff --git a/src/components/Modal/Modal.tsx b/src/components/Modal/Modal.tsx index bb92274..a8248e3 100644 --- a/src/components/Modal/Modal.tsx +++ b/src/components/Modal/Modal.tsx @@ -42,6 +42,7 @@ import { useEffect, useEffectEvent, useId, + useInsertionEffect, useLayoutEffect, useRef, useState, @@ -255,13 +256,17 @@ export function Modal({ const close = () => onOpenChange(false); const rootRef = useRef<HTMLDivElement>(null); - // Captured before the level stack inerts the covering root, which blurs - // whatever that subtree held. + // An insertion effect runs before any layout effect of this commit: before + // content mounting with the open autofocuses (a layout-phase focus), and + // before the level stack inerts the covering root, which blurs its focus. const triggerRef = useRef<HTMLElement | null>(null); - useLayoutEffect(() => { - if (isOpen && !isInline) { - triggerRef.current = document.activeElement as HTMLElement | null; - } + useInsertionEffect(() => { + if (!isOpen || isInline) return; + const active = document.activeElement; + triggerRef.current = + active instanceof HTMLElement && !rootRef.current?.contains(active) + ? active + : null; }, [isOpen, isInline]); const isActive = isOpen && !isInline; @@ -285,10 +290,9 @@ export function Modal({ useEffect(() => { if (!isActive) return; const root = rootRef.current; - return () => { - restoreTriggerFocus(triggerRef.current, root); - triggerRef.current = null; - }; + // triggerRef is not cleared: StrictMode replays this cleanup on mount, and + // the insertion effect that captured it does not run again. + return () => restoreTriggerFocus(triggerRef.current, root); }, [isActive]); useScrollLock(isActive); From 161c31c83bbf5dd8b8ca06e167be0ccbed7b27d2 Mon Sep 17 00:00:00 2001 From: Jongeun <jongeun@lablup.com> Date: Sat, 26 Sep 2026 08:07:21 +0000 Subject: [PATCH 50/87] chore: prepare 0.2.0-alpha.8 --- CHANGELOG.md | 14 ++++++++++++++ package.json | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 28065f5..7f0b836 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ Versioning follows the policy in [CONTRIBUTING.md](CONTRIBUTING.md#versioning). ## [Unreleased] +## [0.2.0-alpha.8] + +One `Modal` focus fix. + +### Fixed + +- `Modal` returned focus to `<body>` instead of the opener when its content + took focus as it mounted: the first open of a `DeleteConfirmModal` with a + confirm field (or any autofocusing input), and every open with + `unmountOnClose`. The opener was read after the content's autofocus had + already run, so the modal recorded its own field as the opener. It is now + read before the content commits, and focus returns to it on close by + Escape, Cancel, the action or the backdrop, nested modals included. + ## [0.2.0-alpha.7] Review fixes: `Modal` now makes the page behind it inert, and the upgrade diff --git a/package.json b/package.json index 9913864..70c7846 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@lablup/ui-common", - "version": "0.2.0-alpha.7", + "version": "0.2.0-alpha.8", "description": "Lablup's UI layer on Astryx: the Astryx surface re-exported 1:1, the Lablup theme, and shared components", "license": "Apache-2.0", "author": "Lablup Inc.", From 02d5af69285a78842c312d79f38ef8c4fd0c8f42 Mon Sep 17 00:00:00 2001 From: Jongeun <jongeun@lablup.com> Date: Mon, 28 Sep 2026 22:58:01 +0000 Subject: [PATCH 51/87] feat: add the Form engine and BulkEditFormItem from backend.ai-ui backend.ai-ui's self-hosted form engine moves here, with its API unchanged, so other Lablup products can use it. It is a form-state API with antd's names and shapes (Form, Form.Item, Form.List, Form.ErrorList, Form.Provider, Form.useForm, Form.useWatch, Form.useFormInstance, Form.Item.useStatus, the rule set, FormConfigProvider), not a component prop vocabulary, so it keeps them: code written against antd's form moves over with an import rewrite. It is at @lablup/ui-common/Form and the root; no name clashes with core or lab. The origin's BAIFormItemVisual is FormItemVisual here. - State half: FormStore, Field, List, validate and namePath, a behavioural port of rc-field-form and async-validator, moved as is. lodash isEqual is replaced by a local isEqual with lodash's semantics for the shapes a form holds. The engine's any-typed store keeps no-explicit-any off under src/components/Form. - Item shell: the stylesheet is in @layer ui-common and styles uic-form-item BEM classes with Astryx tokens only. Colours, sizes and the label height moved from inline styles into it. The DOM markers are the classes (.uic-form-item, __label, __label--required, __control, __control-input, __explain, __explain-error, __explain-warning, __extra) and data-uic-field-id / data-uic-field-item, which scrollToField reads. --uic-form-item-margin-bottom, -gap, -description-color and -line-height let a product keep its own metrics; their defaults are Astryx tokens. - Strings: the 21 validation templates and the "(optional)" suffix are uic.Form.* catalog keys, ICU, English and 20 locales carried over from backend.ai-ui. Every <Form> resolves them in the active Astryx locale, so FormConfigProvider no longer needs a product i18n runtime; the engine still interpolates ${label}-style templates, and buildValidateMessages formats each ICU message with its placeholders standing for themselves. Fixes carried in: four Malay templates regain the field name, and a Mongolian one its {min}, which had been translated into a name that never resolved. defaultValidateMessages is now the catalog's English, and a template's {label} falls back to the field's name. - Glyphs: the feedback icons keep Ant Design Icons' path data (MIT, added to NOTICE); the tooltip trigger is lucide-react's CircleHelp, now a dependency at the range theme-neutral already requires. - Tests travel: the acceptance suite (31), scrollToFirstError (11), the tooltip trigger (4), inferred-type interpolation (3) and the locale tests (8, now against Astryx's provider, plus 2 for the optional suffix), and a new isEqual suite (7). BulkEditFormItem moves with it: a Form.Item that edits one field across many records (keep as is, edit, and with hasClear a Clear that sets null). showClear becomes hasClear; clearLabel and undoLabel join keepValueLabel and clearValueLabel as string props; the three strings are uic.BulkEditFormItem.* keys with 20 locales. Layout is Astryx HStack in place of BAIFlex. Its 21 tests came with it. --- CHANGELOG.md | 32 + CONTRIBUTING.md | 25 + NOTICE | 5 + README.md | 48 + astryx/components/BulkEditFormItem.doc.mjs | 68 + astryx/components/BulkEditFormItem.tsx | 3 + astryx/components/Form.doc.mjs | 113 ++ astryx/components/Form.tsx | 3 + astryx/docs/ui-common.doc.mjs | 3 +- eslint.config.js | 7 + exports.customs.json | 2 + package.json | 5 + pnpm-lock.yaml | 3 + .../BulkEditFormItem.messages.ts | 18 + .../BulkEditFormItem.test.tsx | 577 ++++++ .../BulkEditFormItem/BulkEditFormItem.tsx | 223 +++ src/components/BulkEditFormItem/index.ts | 2 + src/components/Form/ErrorList.tsx | 45 + src/components/Form/Field.tsx | 568 ++++++ src/components/Form/Form.acceptance.test.tsx | 1548 +++++++++++++++++ src/components/Form/Form.messages.ts | 103 ++ .../Form/Form.scrollToFirstError.test.tsx | 272 +++ src/components/Form/Form.tsx | 352 ++++ .../Form/FormConfigProvider.test.tsx | 127 ++ src/components/Form/FormConfigProvider.tsx | 47 + src/components/Form/FormItem.tsx | 531 ++++++ src/components/Form/FormItemVisual.css | 283 +++ .../Form/FormItemVisual.tooltip.test.tsx | 64 + src/components/Form/FormItemVisual.tsx | 314 ++++ src/components/Form/FormStore.ts | 1042 +++++++++++ src/components/Form/List.tsx | 180 ++ src/components/Form/context.ts | 172 ++ src/components/Form/feedbackIcons.tsx | 59 + src/components/Form/index.ts | 93 + src/components/Form/interface.ts | 254 +++ src/components/Form/isEqual.test.ts | 65 + src/components/Form/isEqual.ts | 140 ++ src/components/Form/messages.ts | 89 + src/components/Form/namePath.ts | 370 ++++ src/components/Form/useWatch.ts | 120 ++ src/components/Form/validate.test.ts | 37 + src/components/Form/validate.ts | 521 ++++++ src/i18n/catalog.ts | 4 + src/i18n/locales/de-DE.json | 100 ++ src/i18n/locales/el-GR.json | 100 ++ src/i18n/locales/es-ES.json | 100 ++ src/i18n/locales/fi-FI.json | 100 ++ src/i18n/locales/fr-FR.json | 100 ++ src/i18n/locales/id-ID.json | 100 ++ src/i18n/locales/it-IT.json | 100 ++ src/i18n/locales/ja-JP.json | 100 ++ src/i18n/locales/ko-KR.json | 100 ++ src/i18n/locales/mn-MN.json | 100 ++ src/i18n/locales/ms-MY.json | 100 ++ src/i18n/locales/pl-PL.json | 100 ++ src/i18n/locales/pt-BR.json | 100 ++ src/i18n/locales/pt-PT.json | 100 ++ src/i18n/locales/ru-RU.json | 100 ++ src/i18n/locales/th-TH.json | 100 ++ src/i18n/locales/tr-TR.json | 100 ++ src/i18n/locales/vi-VN.json | 100 ++ src/i18n/locales/zh-CN.json | 100 ++ src/i18n/locales/zh-TW.json | 100 ++ src/index.ts | 53 + 64 files changed, 10589 insertions(+), 1 deletion(-) create mode 100644 astryx/components/BulkEditFormItem.doc.mjs create mode 100644 astryx/components/BulkEditFormItem.tsx create mode 100644 astryx/components/Form.doc.mjs create mode 100644 astryx/components/Form.tsx create mode 100644 src/components/BulkEditFormItem/BulkEditFormItem.messages.ts create mode 100644 src/components/BulkEditFormItem/BulkEditFormItem.test.tsx create mode 100644 src/components/BulkEditFormItem/BulkEditFormItem.tsx create mode 100644 src/components/BulkEditFormItem/index.ts create mode 100644 src/components/Form/ErrorList.tsx create mode 100644 src/components/Form/Field.tsx create mode 100644 src/components/Form/Form.acceptance.test.tsx create mode 100644 src/components/Form/Form.messages.ts create mode 100644 src/components/Form/Form.scrollToFirstError.test.tsx create mode 100644 src/components/Form/Form.tsx create mode 100644 src/components/Form/FormConfigProvider.test.tsx create mode 100644 src/components/Form/FormConfigProvider.tsx create mode 100644 src/components/Form/FormItem.tsx create mode 100644 src/components/Form/FormItemVisual.css create mode 100644 src/components/Form/FormItemVisual.tooltip.test.tsx create mode 100644 src/components/Form/FormItemVisual.tsx create mode 100644 src/components/Form/FormStore.ts create mode 100644 src/components/Form/List.tsx create mode 100644 src/components/Form/context.ts create mode 100644 src/components/Form/feedbackIcons.tsx create mode 100644 src/components/Form/index.ts create mode 100644 src/components/Form/interface.ts create mode 100644 src/components/Form/isEqual.test.ts create mode 100644 src/components/Form/isEqual.ts create mode 100644 src/components/Form/messages.ts create mode 100644 src/components/Form/namePath.ts create mode 100644 src/components/Form/useWatch.ts create mode 100644 src/components/Form/validate.test.ts create mode 100644 src/components/Form/validate.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f0b836..52dce77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,38 @@ Versioning follows the policy in [CONTRIBUTING.md](CONTRIBUTING.md#versioning). ## [Unreleased] +### Added + +- `@lablup/ui-common/Form` (also at the root): `Form` with `Form.Item`, + `Form.List`, `Form.ErrorList`, `Form.Provider`, `Form.useForm`, + `Form.useWatch`, `Form.useFormInstance` and `Form.Item.useStatus`; + `FormItem`, `FormList`, `ErrorList`, `FormProvider`, `useForm`, + `useWatch`, `useFormInstance`; `FormItemVisual` (the item shell); + `FormConfigProvider`, `FormConfigContext`, `useFormValidateMessages`; + `FormItemInputContext`, `NoStyleItemContext`; `FormStore`, + `defaultValidateMessages`; and the types `FormInstance`, `FormProps`, + `FormRef`, `FormItemProps`, `FormListProps`, `ListField`, + `ListOperations`, `ErrorListProps`, `WatchOptions`, `FieldData`, + `FieldError`, `Meta`, `NamePath`, `InternalNamePath`, `Rule`, + `RuleObject`, `RuleRender`, `RuleType`, `Store`, `StoreValue`, + `ValidateErrorEntity`, `ValidateMessages`, `ValidatorRule`, + `FormConfig`, `FormItemStatusContextValue`, `RequiredMark` and + `FormItemVisualProps`. It keeps antd's form API on purpose (a form-state + API, not a component vocabulary); see README, "Form". Its tests, + including the acceptance suite, came with it. +- `BulkEditFormItem`: a `Form.Item` that edits one field across many + records: "Keep as is", edit, and with `hasClear` a "Clear" that sets + `null`. `keepValueLabel`, `clearValueLabel`, `clearLabel` and + `undoLabel` override its strings. +- Catalog keys `uic.Form.*` (the 21 validation message templates and the + `(optional)` suffix) and `uic.BulkEditFormItem.*` (3), translated for all + 20 locales from backend.ai-ui. Four Malay templates (`stringMin`, + `stringMax`, `numberMin`, `numberMax`) gained the field name they had + dropped, and a Mongolian one (`arrayMin`) its `{min}` placeholder, which + had been translated into a name that never resolved. +- `lucide-react` (^1.18.0, the range `@astryxdesign/theme-neutral` already + requires) is a dependency, for the form item's help glyph. + ## [0.2.0-alpha.8] One `Modal` focus fix. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0c3aac2..0637c0c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -106,6 +106,8 @@ Pick a different name, or use the Astryx component. | `NotificationItem` | `src/components/NotificationItem/` | `Stack`, `Text` | | `UnitGrid`, `UnitGridSkeleton` | `src/components/UnitGrid/` | `Stack`, `Text`, `VisuallyHidden`, `Skeleton` | | `ColorPicker` | `src/components/ColorPicker/` | `Popover`, `TextInput`, `Button` | +| `Form` (engine, `Form.Item` shell, hooks) | `src/components/Form/` | `Tooltip`, plain CSS | +| `BulkEditFormItem` | `src/components/BulkEditFormItem/` | `Form.Item`, `TextInput`, `Link`, `HStack` | Each has tests beside it. `src/components/componentStyles.test.ts` holds every stylesheet to the styling rules below. @@ -147,6 +149,29 @@ A custom component is admitted when all of these hold: Failing one of these is a normal outcome. Say so in the pull request and leave the component with its product. +### The form engine + +`Form` is the one custom that keeps a non-Astryx vocabulary. It is a +form-state API (`Form.useForm`, `rules`, `valuePropName`, `FormInstance`), +not a component prop surface, and it keeps antd's names and shapes so code +written against antd's form moves over with an import rewrite. Its +`Store` and values are `any` by design, which is why ESLint's +`no-explicit-any` is off under `src/components/Form/`. + +- The state half (`FormStore`, `Field`, `List`, `validate`, `namePath`) is + a behavioural port of rc-field-form and async-validator. Match upstream + when fixing it: code depends on its quirks, and `Form.acceptance.test.tsx` + pins them. +- The item shell (`FormItemVisual`) follows the Styling rules below. Its + classes (`uic-form-item__*`) and the `data-uic-field-id` attribute are + public: consumers' tests and CSS select on them. +- Validation messages are catalog keys (`uic.Form.*`). The engine + interpolates rule values itself with `${label}`-style templates, so + `buildValidateMessages` formats each ICU message with every placeholder + standing for itself. +- The feedback glyphs are Ant Design Icons path data (MIT, see NOTICE); the + tooltip glyph is `lucide-react`'s, a dependency. + ### Things that are never admitted API clients, endpoints, authentication, application state, stores, routing, diff --git a/NOTICE b/NOTICE index 1d0fa73..d3a6508 100644 --- a/NOTICE +++ b/NOTICE @@ -8,3 +8,8 @@ Portions of this package were extracted from an existing Lablup product frontend. The extraction is a clean import: no upstream git history is published here, and the migration record is kept in that product's own repository. + +The form item's validation feedback glyphs +(src/components/Form/feedbackIcons.tsx) use path data from Ant Design Icons +(https://github.com/ant-design/ant-design-icons), Copyright (c) 2015-present +Ant UED, licensed under the MIT License. diff --git a/README.md b/README.md index 659bbc7..2ad4a14 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,8 @@ Peer dependencies: Astryx itself (`@astryxdesign/core`, `@astryxdesign/theme-neutral`, `@astryxdesign/cli`) comes in as ui-common's own dependencies, pinned exactly. +`lucide-react` (the icon set Astryx's neutral theme already depends on) and +`intl-messageformat` come in the same way. Do not add them to your project. ui-common owns the Astryx version. Two copies of Astryx means two copies of its React contexts, and components stop seeing the theme. @@ -204,6 +206,8 @@ import { Modal } from "@lablup/ui-common/Modal"; | `NotificationItem` | The title, description, actions and footer of one notice | `/components/NotificationItem` | | `UnitGrid`, `UnitGridSkeleton` | Groups of unit squares on one lattice, with a hover card | `/components/UnitGrid` | | `ColorPicker` | A hex colour field on the platform colour input | `/components/ColorPicker` | +| `Form` and its hooks | A form engine with antd's form API. See below. | `/Form` | +| `BulkEditFormItem` | A form item that edits one field across many records | `/components/BulkEditFormItem` | | `usePrefersReducedMotion` | The `prefers-reduced-motion` media query, as a hook | root only | Their styles live in `@layer ui-common`, under `uic-` class names. @@ -257,6 +261,50 @@ What it adds: </Modal> ``` +### Form + +`@lablup/ui-common/Form` is a form engine with antd's form API: `Form`, +`Form.Item`, `Form.List`, `Form.ErrorList`, `Form.Provider`, +`Form.useForm`, `Form.useWatch`, `Form.useFormInstance` and +`Form.Item.useStatus`, with antd's rules (`required`, `message`, +`validator`, `type`, `min`, `max`, `pattern`, `whitespace`, +`warningOnly`). It keeps antd's names on purpose: it is a form-state API, +not a component, so code written against antd's form moves over by changing +the import. The item shell renders on Astryx tokens. + +```tsx +import { Form } from "@lablup/ui-common/Form"; + +const [form] = Form.useForm(); + +<Form form={form} layout="vertical" onFinish={save}> + <Form.Item name="name" label="Name" rules={[{ required: true }]}> + <TextInput label="Name" isLabelHidden /> + </Form.Item> +</Form>; +``` + +- Validation messages come from ui-common's catalog in the locale of the + nearest `InternationalizationProvider` (see Strings). `FormConfigProvider` + sets `validateMessages`, `requiredMark` and `optionalLabel` app-wide; a + form's own `validateMessages` wins over both. +- A control shows its item's validation state by reading + `Form.Item.useStatus()` or `FormItemInputContext`. +- `form.scrollToField` and `scrollToFirstError` find the control by + `data-uic-field-id`, which `Form.Item` puts on its child; Astryx inputs + keep `data-*` attributes. +- The DOM is `.uic-form` (with `data-layout`) and `.uic-form-item` with + `__label`, `__label--required`, `__control`, `__control-input`, + `__explain`, `__explain-error`, `__explain-warning` and `__extra`. + The item carries `data-layout`, `data-size` and `data-status`. These + class names are the public hooks for tests and product CSS. +- Four custom properties adjust it: `--uic-form-item-margin-bottom` + (default `--spacing-6`), `--uic-form-item-gap` (label to control in a + vertical item, default `--spacing-2`) and + `--uic-form-item-description-color` (help, extra, the tooltip glyph and + the optional suffix, default `--color-text-secondary`) and + `--uic-form-item-line-height` (default `--text-body-leading`). + ### What is hidden A few Astryx subpaths are deliberately not re-exported. They are listed, with diff --git a/astryx/components/BulkEditFormItem.doc.mjs b/astryx/components/BulkEditFormItem.doc.mjs new file mode 100644 index 0000000..c55ad32 --- /dev/null +++ b/astryx/components/BulkEditFormItem.doc.mjs @@ -0,0 +1,68 @@ +/** + * `astryx component BulkEditFormItem` (and `ui-common component BulkEditFormItem`). + * + * @type {import('@astryxdesign/cli/authoring').ComponentDoc} + */ +export default { + type: "component", + name: "BulkEditFormItem", + displayName: "BulkEditFormItem", + import: "@lablup/ui-common", + category: "Inputs", + keywords: ["bulk edit", "batch edit", "keep as is", "multiple records", "form item"], + description: + 'A Form.Item for editing one field across many records. It starts as a read-only "Keep as is" placeholder (the value stays undefined, so a submit leaves every record alone); clicking or focusing it swaps in the wrapped control; hasClear adds "Clear", which sets the value to null; "Undo changes" returns to keep mode. Takes every Form.Item prop but required, and rules without required.', + props: [ + { + name: "name", + type: "NamePath", + description: "The field the item edits.", + required: true, + }, + { + name: "children", + type: "ReactElement", + description: + "The control. It is cloned with value/onChange, a ref (focused when editing starts) and open/onOpenChange (opened when editing starts).", + }, + { + name: "hasClear", + type: "boolean", + description: "Offers Clear, which sets the value to null.", + }, + { + name: "keepValueLabel", + type: "string", + description: "The placeholder in keep mode.", + default: 'the catalog\'s uic.BulkEditFormItem.keepAsIs ("Keep as is")', + }, + { + name: "clearValueLabel", + type: "string", + description: "The placeholder once cleared.", + default: 'the catalog\'s uic.BulkEditFormItem.clear ("Clear")', + }, + { + name: "clearLabel", + type: "string", + description: "The clear action's label.", + default: 'the catalog\'s uic.BulkEditFormItem.clear ("Clear")', + }, + { + name: "undoLabel", + type: "string", + description: "The label of the action that returns to keep mode.", + default: 'the catalog\'s uic.BulkEditFormItem.undoChanges ("Undo changes")', + }, + ], + usage: { + description: + "A form that edits several selected records at once, where an untouched field must not overwrite their differing values. Render it inside a Form from @lablup/ui-common/Form.", + }, + examples: [ + { + label: "An optional field that can be cleared on every record", + code: '<BulkEditFormItem name="priority" label="Priority" hasClear clearValueLabel="No priority">\n <NumberInput label="Priority" isLabelHidden />\n</BulkEditFormItem>', + }, + ], +}; diff --git a/astryx/components/BulkEditFormItem.tsx b/astryx/components/BulkEditFormItem.tsx new file mode 100644 index 0000000..86929be --- /dev/null +++ b/astryx/components/BulkEditFormItem.tsx @@ -0,0 +1,3 @@ +// The Astryx CLI pairs each component doc with a same-stem source file. +// ui-common ships no source, so this names the export instead. +export { BulkEditFormItem } from "@lablup/ui-common"; diff --git a/astryx/components/Form.doc.mjs b/astryx/components/Form.doc.mjs new file mode 100644 index 0000000..e386400 --- /dev/null +++ b/astryx/components/Form.doc.mjs @@ -0,0 +1,113 @@ +/** + * `astryx component Form` (and `ui-common component Form`). + * + * @type {import('@astryxdesign/cli/authoring').ComponentDoc} + */ +export default { + type: "component", + name: "Form", + displayName: "Form", + import: "@lablup/ui-common/Form", + category: "Inputs", + keywords: [ + "form", + "form item", + "validation", + "rules", + "useForm", + "useWatch", + "form list", + "required", + "antd", + ], + description: + "A form engine with antd's form API: Form, Form.Item, Form.List, Form.ErrorList, Form.Provider, Form.useForm, Form.useWatch, Form.useFormInstance and Form.Item.useStatus, with antd's rules (required, message, validator, type, min, max, pattern, whitespace, warningOnly). Form.Item clones its child with value/onChange and renders a label, the control, the error or help line and extra text on Astryx tokens. Validation messages come from ui-common's catalog in the active Astryx locale. It keeps antd's names on purpose: it is a form-state API, so code written against antd's form moves over with an import rewrite.", + props: [ + { + name: "form", + type: "FormInstance", + description: "The instance from Form.useForm(); one is created when omitted.", + }, + { + name: "initialValues", + type: "Store", + description: + "Seeds the store once; a later change is what resetFields() restores.", + }, + { + name: "layout", + type: "'horizontal' | 'vertical' | 'inline'", + description: "Label placement for every item.", + default: "'horizontal'", + }, + { + name: "onFinish", + type: "(values) => void", + description: "Called with the values when a submit validates.", + }, + { + name: "onFinishFailed", + type: "({ values, errorFields, outOfDate }) => void", + description: "Called when a submit fails validation.", + }, + { + name: "onValuesChange", + type: "(changedValues, allValues) => void", + description: "Called on user edits (not on setFieldsValue).", + }, + { + name: "requiredMark", + type: "boolean | 'optional' | (label, { required }) => ReactNode", + description: + "The required marker. 'optional' and a function replace the asterisk with a hint in the label.", + }, + { + name: "labelCol", + type: "{ span?, offset?, flex?, className?, style? }", + description: "Label column in 24ths, for horizontal forms.", + }, + { + name: "wrapperCol", + type: "{ span?, offset?, flex?, className?, style? }", + description: "Control column in 24ths, for horizontal forms.", + }, + { + name: "size", + type: "'small' | 'middle' | 'large'", + description: "Label and control row height: --size-element-sm/md/lg.", + }, + { + name: "disabled", + type: "boolean", + description: "Passes disabled to every control that does not set it.", + }, + { + name: "scrollToFirstError", + type: "boolean | ScrollIntoViewOptions", + description: + "On a failed submit, scrolls to the first invalid item and focuses it.", + }, + { + name: "validateMessages", + type: "ValidateMessages", + description: + "Message templates (${label}, ${min}, ...) that win over the catalog. FormConfigProvider sets them app-wide.", + }, + { + name: "component", + type: "ElementType | false", + description: "The root element; false renders none.", + default: "'form'", + }, + ], + usage: { + description: + "Data-entry screens whose state, validation and submit need more than a few controlled fields. Style hooks: .uic-form-item and its __label, __control, __explain-error, __extra elements, and --uic-form-item-margin-bottom, --uic-form-item-gap, --uic-form-item-description-color and --uic-form-item-line-height. A control reaches its item's status through Form.Item.useStatus() or FormItemInputContext.", + }, + examples: [ + { + label: "A validated form", + code: 'import { Form } from "@lablup/ui-common/Form";\n\nconst [form] = Form.useForm();\n\n<Form form={form} layout="vertical" onFinish={save}>\n <Form.Item name="name" label="Name" rules={[{ required: true }]}>\n <TextInput label="Name" isLabelHidden />\n </Form.Item>\n</Form>', + }, + ], +}; diff --git a/astryx/components/Form.tsx b/astryx/components/Form.tsx new file mode 100644 index 0000000..1860269 --- /dev/null +++ b/astryx/components/Form.tsx @@ -0,0 +1,3 @@ +// The Astryx CLI pairs each component doc with a same-stem source file. +// ui-common ships no source, so this names the export instead. +export { Form } from "@lablup/ui-common/Form"; diff --git a/astryx/docs/ui-common.doc.mjs b/astryx/docs/ui-common.doc.mjs index f602111..0ed9b44 100644 --- a/astryx/docs/ui-common.doc.mjs +++ b/astryx/docs/ui-common.doc.mjs @@ -42,7 +42,7 @@ export const docs = { content: [ { type: "prose", - text: "ui-common's own components are built on Astryx primitives and never share a name with an Astryx export. Each is exported from the package root; Modal and AlertModal also have their own subpaths.", + text: "ui-common's own components are built on Astryx primitives and never share a name with an Astryx export. Each is exported from the package root; Modal, AlertModal and Form also have their own subpaths.", }, { type: "list", @@ -70,6 +70,7 @@ export const docs = { "`NotificationItem`: the title, description, actions and footer of one notice.", "`UnitGrid`: groups of unit squares packed on one lattice, each group a tinted plate with a hover card and an optional palette picker; `UnitGridSkeleton` is its loading stand-in.", "`ColorPicker`: a hex colour field on the platform colour input, reporting the settled colour.", + "`Form` (`@lablup/ui-common/Form`): a form engine with antd's form API (Form.Item, Form.List, Form.useForm, Form.useWatch, rules), its messages from the catalog, its item shell on Astryx tokens. `BulkEditFormItem`: a Form.Item that edits one field across many records (keep / edit / clear).", ], }, { diff --git a/eslint.config.js b/eslint.config.js index a3c4fbb..f748fa6 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -61,6 +61,13 @@ export default tseslint.config( ], }, }, + { + // The form engine keeps antd's form types, whose store and values are + // `any` by design (`Store = any`, `StoreValue = any`); narrowing them + // would break code written against antd's form. + files: ["src/components/Form/**"], + rules: { "@typescript-eslint/no-explicit-any": "off" }, + }, { files: ["**/*.test.{ts,tsx}", "src/test/**"], languageOptions: { globals: { ...globals.browser, ...globals.node } }, diff --git a/exports.customs.json b/exports.customs.json index d5e97c2..7018dc6 100644 --- a/exports.customs.json +++ b/exports.customs.json @@ -6,6 +6,7 @@ }, { "name": "BoardItemTitle", "source": "components/BoardItemTitle/index.ts" }, { "name": "BooleanToken", "source": "components/BooleanToken/index.ts" }, + { "name": "BulkEditFormItem", "source": "components/BulkEditFormItem/index.ts" }, { "name": "ColorPicker", "source": "components/ColorPicker/index.ts" }, { "name": "ConfirmPopover", "source": "components/ConfirmPopover/index.ts" }, { "name": "CountBadge", "source": "components/CountBadge/index.ts" }, @@ -14,6 +15,7 @@ { "name": "DividedRow", "source": "components/DividedRow/index.ts" }, { "name": "DoubleBadge", "source": "components/DoubleBadge/index.ts" }, { "name": "ErrorState", "source": "components/ErrorState/index.ts" }, + { "name": "Form", "source": "components/Form/index.ts", "subpath": "Form" }, { "name": "IconWithTooltip", "source": "components/IconWithTooltip/index.ts" }, { "name": "ImageWithFallback", "source": "components/ImageWithFallback/index.ts" }, { "name": "Modal", "source": "components/Modal/index.ts", "subpath": "Modal" }, diff --git a/package.json b/package.json index 70c7846..a49a43c 100644 --- a/package.json +++ b/package.json @@ -561,6 +561,10 @@ "types": "./dist/components/AlertModal/index.d.ts", "import": "./dist/components/AlertModal/index.js" }, + "./Form": { + "types": "./dist/components/Form/index.d.ts", + "import": "./dist/components/Form/index.js" + }, "./Modal": { "types": "./dist/components/Modal/index.d.ts", "import": "./dist/components/Modal/index.js" @@ -589,6 +593,7 @@ "@astryxdesign/theme-neutral": "0.6.2", "intl-messageformat": "^11.2.9", "jscodeshift": "^17.4.0", + "lucide-react": "^1.18.0", "postcss": "^8.5.25", "postcss-selector-parser": "^7.1.6" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 24ff003..cfc215d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: jscodeshift: specifier: ^17.4.0 version: 17.4.0(supports-color@8.1.1) + lucide-react: + specifier: ^1.18.0 + version: 1.48.0(react@19.2.8) postcss: specifier: ^8.5.25 version: 8.5.25 diff --git a/src/components/BulkEditFormItem/BulkEditFormItem.messages.ts b/src/components/BulkEditFormItem/BulkEditFormItem.messages.ts new file mode 100644 index 0000000..0922c74 --- /dev/null +++ b/src/components/BulkEditFormItem/BulkEditFormItem.messages.ts @@ -0,0 +1,18 @@ +import { defineMessages } from "../../i18n/catalog"; + +export const bulkEditFormItemMessages = defineMessages({ + "uic.BulkEditFormItem.keepAsIs": { + defaultMessage: "Keep as is", + description: + "Placeholder of a bulk-edit field that leaves every selected item's current value unchanged", + }, + "uic.BulkEditFormItem.clear": { + defaultMessage: "Clear", + description: + "Link that clears the field on every selected item, and the field's placeholder once cleared", + }, + "uic.BulkEditFormItem.undoChanges": { + defaultMessage: "Undo changes", + description: "Link that returns a bulk-edit field to keeping the current values", + }, +}); diff --git a/src/components/BulkEditFormItem/BulkEditFormItem.test.tsx b/src/components/BulkEditFormItem/BulkEditFormItem.test.tsx new file mode 100644 index 0000000..15354b7 --- /dev/null +++ b/src/components/BulkEditFormItem/BulkEditFormItem.test.tsx @@ -0,0 +1,577 @@ +import { render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import React, { useEffect } from "react"; +import { describe, expect, it } from "vitest"; + +import { Form, type FormInstance } from "../Form"; +import { BulkEditFormItem } from "./BulkEditFormItem"; + +// Helper component wrapper with Form context +const FormWrapper: React.FC<{ + children: React.ReactNode; + onValuesChange?: (changedValues: unknown, allValues: unknown) => void; + formRef?: React.MutableRefObject<FormInstance | null>; +}> = ({ children, onValuesChange, formRef }) => { + const [form] = Form.useForm(); + + useEffect(() => { + if (formRef) { + formRef.current = form; + } + }, [form, formRef]); + + return ( + <Form form={form} onValuesChange={onValuesChange}> + {children} + </Form> + ); +}; + +/** + * The control the suite wraps: a double on the platform `<select>`. The + * subject is `BulkEditFormItem`'s three-mode state machine (keep / edit / + * clear) and the blur-driven revert that drives it; the wrapped control is a + * fixture. It reproduces the contract the item relies on: a `value` / + * `onChange` pair whose change commits synchronously, a focusable element, a + * rendered placeholder, and tolerance for the `open` / `onOpenChange` props + * the item clones in. + */ +const Select = React.forwardRef< + HTMLSelectElement, + { + placeholder?: string; + options: Array<{ value: string; label: string }>; + value?: string; + onChange?: (event: React.ChangeEvent<HTMLSelectElement>) => void; + onBlur?: () => void; + // Cloned in by `ControlWrapper`; the double has no popup to open. + open?: boolean; + onOpenChange?: (open: boolean) => void; + "aria-label"?: string; + } +>(({ placeholder, options, value, onChange, onBlur, ...rest }, ref) => ( + <select + ref={ref} + aria-label={rest["aria-label"] ?? placeholder} + value={value ?? ""} + onChange={onChange} + onBlur={onBlur} + > + <option value="">{placeholder ?? ""}</option> + {options.map((option) => ( + <option key={option.value} value={option.value}> + {option.label} + </option> + ))} + </select> +)); +Select.displayName = "Select"; + +/** + * Pick an option by its visible LABEL. + * + * Two accommodations, both for how the call sites locate the control: they + * pass `screen.getByText(placeholder)`, which resolves to the placeholder + * `<option>` rather than the `<select>`, so the listbox is walked up to + * first. And `selectOptions` matches a bare string against an option's + * `value`, while labels and values differ throughout this suite, so the + * option element is looked up by role rather than passed as a string. + */ +const selectOption = async ( + user: ReturnType<typeof userEvent.setup>, + selectElement: HTMLElement, + optionText: string, +) => { + const listbox = + selectElement instanceof HTMLSelectElement + ? selectElement + : (selectElement.closest("select") as HTMLSelectElement); + const option = within(listbox).getByRole("option", { name: optionText }); + await user.selectOptions(listbox, option); +}; + +describe("BulkEditFormItem", () => { + // Keeps the historical setup: the suite clicks through elements the form + // item toggles mid-interaction, where pointer-events can lag a frame. + const setupUser = () => userEvent.setup({ pointerEventsCheck: 0 }); + + describe("Basic Rendering", () => { + it('should render with "Keep as is" placeholder by default', () => { + render( + <FormWrapper> + <BulkEditFormItem name="testField" label="Test Field"> + <Select options={[{ value: "test", label: "Test" }]} /> + </BulkEditFormItem> + </FormWrapper>, + ); + + // "Keep as is" is now displayed as input value + expect(screen.getByDisplayValue("Keep as is")).toBeInTheDocument(); + // Use getAllByText since label may appear in multiple form items (outer and inner) + const labels = screen.getAllByText("Test Field"); + expect(labels.length).toBeGreaterThan(0); + }); + + it("should render with label from form item props", () => { + render( + <FormWrapper> + <BulkEditFormItem name="domain" label="Domain Name"> + <Select options={[{ value: "test", label: "Test" }]} /> + </BulkEditFormItem> + </FormWrapper>, + ); + + // Use getAllByText since label may appear in multiple form items (outer and inner) + // and verify at least one label is visible + const labels = screen.getAllByText("Domain Name"); + expect(labels.length).toBeGreaterThan(0); + expect(labels[0]).toBeInTheDocument(); + }); + + it("should not show Clear link for non-optional fields", () => { + render( + <FormWrapper> + <BulkEditFormItem name="testField" label="Required Field"> + <Select options={[{ value: "test", label: "Test" }]} /> + </BulkEditFormItem> + </FormWrapper>, + ); + + expect(screen.queryByText("Clear")).not.toBeInTheDocument(); + }); + + it("should show Clear link for optional fields", () => { + render( + <FormWrapper> + <BulkEditFormItem name="testField" label="Optional Field" hasClear> + <Select options={[{ value: "test", label: "Test" }]} /> + </BulkEditFormItem> + </FormWrapper>, + ); + + expect(screen.getByText("Clear")).toBeInTheDocument(); + }); + }); + + describe("Mode Transitions", () => { + it("should switch to edit mode when clicking Keep as is placeholder", async () => { + const user = setupUser(); + render( + <FormWrapper> + <BulkEditFormItem name="testField" label="Test"> + <Select + placeholder="Select value" + options={[{ value: "test", label: "Test" }]} + /> + </BulkEditFormItem> + </FormWrapper>, + ); + + // Initially in keep mode - "Keep as is" shown as input value + const keepAsIsInput = screen.getByDisplayValue("Keep as is"); + expect(keepAsIsInput).toBeInTheDocument(); + + // Click to switch to edit mode + await user.click(keepAsIsInput); + + // Should now show the Select component (keep as is input should be gone) + expect(screen.queryByDisplayValue("Keep as is")).not.toBeInTheDocument(); + // Select is now visible + expect(screen.getByText("Select value")).toBeInTheDocument(); + }); + + it("should show Undo changes link in edit mode after entering a value", async () => { + const user = setupUser(); + render( + <FormWrapper> + <BulkEditFormItem name="testField" label="Test"> + <Select + placeholder="Select value" + options={[{ value: "test", label: "Test Option" }]} + /> + </BulkEditFormItem> + </FormWrapper>, + ); + + // Click to switch to edit mode + await user.click(screen.getByDisplayValue("Keep as is")); + + // Select a value + const selectEl = screen.getByText("Select value"); + await selectOption(user, selectEl, "Test Option"); + + // Should show Undo changes link after value is set + await waitFor(() => { + expect(screen.getByText("Undo changes")).toBeInTheDocument(); + }); + }); + + it("should return to keep mode when clicking Undo changes", async () => { + const user = setupUser(); + render( + <FormWrapper> + <BulkEditFormItem name="testField" label="Test"> + <Select + placeholder="Select value" + options={[{ value: "test", label: "Test Option" }]} + /> + </BulkEditFormItem> + </FormWrapper>, + ); + + // Switch to edit mode and select a value + await user.click(screen.getByDisplayValue("Keep as is")); + const selectEl = screen.getByText("Select value"); + await selectOption(user, selectEl, "Test Option"); + + // Wait for Undo changes to appear + await waitFor(() => { + expect(screen.getByText("Undo changes")).toBeInTheDocument(); + }); + + // Click Undo changes + await user.click(screen.getByText("Undo changes")); + + // Should be back to keep mode + expect(screen.getByDisplayValue("Keep as is")).toBeInTheDocument(); + expect(screen.queryByText("Undo changes")).not.toBeInTheDocument(); + }); + + it("should switch to clear mode when clicking Clear link", async () => { + const user = setupUser(); + render( + <FormWrapper> + <BulkEditFormItem name="testField" label="Test" hasClear> + <Select + placeholder="Select value" + options={[{ value: "test", label: "Test" }]} + /> + </BulkEditFormItem> + </FormWrapper>, + ); + + // Click Clear + await user.click(screen.getByText("Clear")); + + // Clear mode shows "Clear" label as input value + expect(screen.getByDisplayValue("Clear")).toBeInTheDocument(); + // Clear link should be replaced with Undo changes + expect(screen.queryByRole("link", { name: "Clear" })).not.toBeInTheDocument(); + expect(screen.getByText("Undo changes")).toBeInTheDocument(); + }); + + it("should return from clear mode to keep mode when clicking Undo changes", async () => { + const user = setupUser(); + render( + <FormWrapper> + <BulkEditFormItem name="testField" label="Test" hasClear> + <Select + placeholder="Select value" + options={[{ value: "test", label: "Test" }]} + /> + </BulkEditFormItem> + </FormWrapper>, + ); + + // Switch to clear mode + await user.click(screen.getByText("Clear")); + expect(screen.getByDisplayValue("Clear")).toBeInTheDocument(); + + // Click Undo changes + await user.click(screen.getByText("Undo changes")); + + // Should be back to keep mode + expect(screen.getByDisplayValue("Keep as is")).toBeInTheDocument(); + expect(screen.getByText("Clear")).toBeInTheDocument(); + }); + + it("should switch from clear mode to edit mode when clicking placeholder", async () => { + const user = setupUser(); + render( + <FormWrapper> + <BulkEditFormItem name="testField" label="Test" hasClear> + <Select + placeholder="Select value" + options={[{ value: "test", label: "Test" }]} + /> + </BulkEditFormItem> + </FormWrapper>, + ); + + // Switch to clear mode + await user.click(screen.getByText("Clear")); + expect(screen.getByDisplayValue("Clear")).toBeInTheDocument(); + + // Click placeholder to switch to edit mode + await user.click(screen.getByDisplayValue("Clear")); + + // Should now be in edit mode + expect(screen.getByText("Select value")).toBeInTheDocument(); + expect(screen.queryByDisplayValue("Clear")).not.toBeInTheDocument(); + }); + }); + + describe("Form Value Handling", () => { + it("should set form value to undefined in keep mode", async () => { + const formRef = { current: null as FormInstance | null }; + const user = setupUser(); + render( + <FormWrapper formRef={formRef}> + <BulkEditFormItem name="testField" label="Test"> + <Select + placeholder="Select value" + options={[{ value: "test", label: "Test Option" }]} + /> + </BulkEditFormItem> + </FormWrapper>, + ); + + // Switch to edit mode and select a value + await user.click(screen.getByDisplayValue("Keep as is")); + const selectEl = screen.getByText("Select value"); + await selectOption(user, selectEl, "Test Option"); + + // Verify value is set + await waitFor(() => { + expect(formRef.current?.getFieldValue("testField")).toBe("test"); + }); + + // Click Undo to go back to keep mode + await user.click(screen.getByText("Undo changes")); + + // After undo, the value should be undefined (excluded from submission) + await waitFor(() => { + expect(formRef.current?.getFieldValue("testField")).toBeUndefined(); + }); + }); + + it("should set form value to null in clear mode", async () => { + const formRef = { current: null as FormInstance | null }; + const user = setupUser(); + render( + <FormWrapper formRef={formRef}> + <BulkEditFormItem name="testField" label="Test" hasClear> + <Select + placeholder="Select value" + options={[{ value: "test", label: "Test" }]} + /> + </BulkEditFormItem> + </FormWrapper>, + ); + + // Click Clear + await user.click(screen.getByText("Clear")); + + // Should have set the value to null (explicit clear) + await waitFor(() => { + expect(formRef.current?.getFieldValue("testField")).toBeNull(); + }); + }); + + it("should allow user selection in edit mode", async () => { + const onValuesChange = vi.fn(); + const user = setupUser(); + render( + <FormWrapper onValuesChange={onValuesChange}> + <BulkEditFormItem name="testField" label="Test"> + <Select + placeholder="Select value" + options={[{ value: "selected", label: "Selected Option" }]} + /> + </BulkEditFormItem> + </FormWrapper>, + ); + + // Switch to edit mode + await user.click(screen.getByDisplayValue("Keep as is")); + + // Select a value + const selectEl = screen.getByText("Select value"); + await selectOption(user, selectEl, "Selected Option"); + + // Should have recorded the value change + await waitFor(() => { + expect(onValuesChange).toHaveBeenCalled(); + const calls = onValuesChange.mock.calls; + const hasSelected = calls.some((call) => call[0]?.testField === "selected"); + expect(hasSelected).toBe(true); + }); + }); + }); + + describe("With Select Component", () => { + it("should work with Select component in edit mode", async () => { + const user = setupUser(); + render( + <FormWrapper> + <BulkEditFormItem name="status" label="Status"> + <Select + placeholder="Select status" + options={[ + { value: "active", label: "Active" }, + { value: "inactive", label: "Inactive" }, + ]} + /> + </BulkEditFormItem> + </FormWrapper>, + ); + + // Switch to edit mode + await user.click(screen.getByDisplayValue("Keep as is")); + + // Should show the Select + expect(screen.getByText("Select status")).toBeInTheDocument(); + }); + }); + + describe("Edge Cases", () => { + it("should handle multiple fields independently", async () => { + const user = setupUser(); + render( + <FormWrapper> + <BulkEditFormItem name="field1" label="Field 1"> + <Select + placeholder="Field 1 select" + options={[{ value: "test1", label: "Test 1" }]} + /> + </BulkEditFormItem> + <BulkEditFormItem name="field2" label="Field 2" hasClear> + <Select + placeholder="Field 2 select" + options={[{ value: "test2", label: "Test 2" }]} + /> + </BulkEditFormItem> + </FormWrapper>, + ); + + // Both should start in keep mode + const keepAsIsElements = screen.getAllByDisplayValue("Keep as is"); + expect(keepAsIsElements).toHaveLength(2); + + // Switch first field to edit mode + await user.click(keepAsIsElements[0]!); + + // First field should be in edit mode + expect(screen.getByText("Field 1 select")).toBeInTheDocument(); + + // Second field should still be in keep mode + expect(screen.getByDisplayValue("Keep as is")).toBeInTheDocument(); + expect(screen.getByText("Clear")).toBeInTheDocument(); + }); + + it("should pass through additional Form.Item props", () => { + render( + <FormWrapper> + <BulkEditFormItem name="testField" label="Test" tooltip="This is a tooltip"> + <Select options={[{ value: "test", label: "Test" }]} /> + </BulkEditFormItem> + </FormWrapper>, + ); + + // Required marker should be present (BulkEditFormItem always sets + // required=true internally). The anchor is `.uic-form-item__label--required`. + expect( + document.querySelector(".uic-form-item__label--required"), + ).toBeInTheDocument(); + }); + + it("should use custom clearValueLabel when provided", async () => { + const user = setupUser(); + render( + <FormWrapper> + <BulkEditFormItem + name="testField" + label="Test" + hasClear + clearValueLabel="No value selected" + > + <Select + placeholder="Select value" + options={[{ value: "test", label: "Test" }]} + /> + </BulkEditFormItem> + </FormWrapper>, + ); + + // Switch to clear mode + await user.click(screen.getByText("Clear")); + + // Should show custom clearValueLabel as input value + expect(screen.getByDisplayValue("No value selected")).toBeInTheDocument(); + expect(screen.queryByDisplayValue("Clear")).not.toBeInTheDocument(); + }); + + it("should use custom keepValueLabel when provided", () => { + render( + <FormWrapper> + <BulkEditFormItem + name="testField" + label="Test" + keepValueLabel="No changes to this field" + > + <Select + placeholder="Select value" + options={[{ value: "test", label: "Test" }]} + /> + </BulkEditFormItem> + </FormWrapper>, + ); + + // Should show custom keepValueLabel as input value instead of default "Keep as is" + expect(screen.getByDisplayValue("No changes to this field")).toBeInTheDocument(); + expect(screen.queryByDisplayValue("Keep as is")).not.toBeInTheDocument(); + }); + }); + + describe("Accessibility", () => { + it("should have clickable placeholder for keyboard navigation", async () => { + const user = setupUser(); + render( + <FormWrapper> + <BulkEditFormItem name="testField" label="Test"> + <Select + placeholder="Select value" + options={[{ value: "test", label: "Test" }]} + /> + </BulkEditFormItem> + </FormWrapper>, + ); + + const placeholder = screen.getByDisplayValue("Keep as is"); + + // Should be clickable + await user.click(placeholder); + expect(screen.getByText("Select value")).toBeInTheDocument(); + }); + + it("should have accessible link for Clear action", () => { + render( + <FormWrapper> + <BulkEditFormItem name="testField" label="Test" hasClear> + <Select options={[{ value: "test", label: "Test" }]} /> + </BulkEditFormItem> + </FormWrapper>, + ); + + // "Clear" is an Astryx `Link` with no `href`, which renders a + // link-styled button: the right semantics for a pure `onClick` action. + expect(screen.getByRole("button", { name: "Clear" })).toBeInTheDocument(); + }); + + it("should have accessible link for Undo changes action", async () => { + const user = setupUser(); + render( + <FormWrapper> + <BulkEditFormItem name="testField" label="Test" hasClear> + <Select options={[{ value: "test", label: "Test" }]} /> + </BulkEditFormItem> + </FormWrapper>, + ); + + // Switch to clear mode (which sets value to null, triggering Undo changes link) + await user.click(screen.getByText("Clear")); + + expect(screen.getByRole("button", { name: "Undo changes" })).toBeInTheDocument(); + }); + }); +}); diff --git a/src/components/BulkEditFormItem/BulkEditFormItem.tsx b/src/components/BulkEditFormItem/BulkEditFormItem.tsx new file mode 100644 index 0000000..3e594ae --- /dev/null +++ b/src/components/BulkEditFormItem/BulkEditFormItem.tsx @@ -0,0 +1,223 @@ +/** + * BulkEditFormItem + * + * A `Form.Item` for editing one field across many records at once. It starts + * in "keep as is" (the field's value stays `undefined`, so a submit leaves + * every record's current value alone); clicking or focusing the placeholder + * switches to editing the wrapped control; `hasClear` adds a "Clear" action + * that sets the value to `null`; "Undo changes" returns to keep mode. + * + * The control is unmounted in keep mode, so its field registers only when the + * user starts editing, and an `initialValue` applies at that moment rather + * than marking every field edited on mount. + * + * @example + * <BulkEditFormItem name="domain" label="Domain" hasClear clearValueLabel="No domain"> + * <Selector options={domains} /> + * </BulkEditFormItem> + */ +import { + cloneElement, + forwardRef, + useImperativeHandle, + useRef, + useState, + type MouseEvent, + type FocusEvent, + type ReactElement, +} from "react"; +import { HStack } from "@astryxdesign/core/HStack"; +import { Link } from "@astryxdesign/core/Link"; +import { TextInput } from "@astryxdesign/core/TextInput"; + +import { useUicTranslator } from "../../i18n/useUicTranslator"; +import { Form, type FormItemProps, type RuleObject, type RuleRender } from "../Form"; + +type BulkEditMode = "keep" | "edit" | "clear"; + +/** A rule without `required`: the item always shows the required marker itself. */ +type RuleWithoutRequired = Omit<RuleObject, "required"> | RuleRender; + +export interface BulkEditFormItemProps extends Omit< + FormItemProps, + "required" | "rules" +> { + /** Offers "Clear", which sets the value to `null` for every record. */ + hasClear?: boolean; + /** The placeholder in keep mode. @default "Keep as is" */ + keepValueLabel?: string; + /** The placeholder once cleared. @default "Clear" */ + clearValueLabel?: string; + /** The label of the clear action. @default "Clear" */ + clearLabel?: string; + /** The label of the action that returns to keep mode. @default "Undo changes" */ + undoLabel?: string; + /** The control that edits the value. */ + children?: ReactElement; + rules?: RuleWithoutRequired[]; +} + +const noop = () => {}; + +export function BulkEditFormItem({ + name, + hasClear = false, + keepValueLabel, + clearValueLabel, + clearLabel, + undoLabel, + children, + ...formItemProps +}: BulkEditFormItemProps) { + const t = useUicTranslator(); + const form = Form.useFormInstance(); + const [mode, setMode] = useState<BulkEditMode>("keep"); + const controlRef = useRef<ControlWrapperRef>(null); + + const focusControl = () => { + setTimeout(() => { + controlRef.current?.focus(); + controlRef.current?.open(); + }, 0); + }; + + const handlePlaceholderClick = (e: MouseEvent | FocusEvent) => { + e.preventDefault(); + setMode("edit"); + focusControl(); + }; + + const handleControlBlur = () => { + const currentValue = form.getFieldValue(name); + if (currentValue === undefined) { + setMode("keep"); + } + if (currentValue === null && hasClear) { + setMode("clear"); + } + }; + + const handleClear = () => { + setMode("clear"); + form.setFieldValue(name, null); + }; + + const handleUndo = () => { + setMode("keep"); + form.setFieldValue(name, undefined); + }; + + const resolvedKeepValueLabel = keepValueLabel ?? t("uic.BulkEditFormItem.keepAsIs"); + const resolvedClearValueLabel = clearValueLabel ?? t("uic.BulkEditFormItem.clear"); + + const actions = ( + <HStack align="center"> + {mode === "keep" && hasClear && ( + <Link onClick={handleClear}> + {clearLabel ?? t("uic.BulkEditFormItem.clear")} + </Link> + )} + {/* A dependency-only item re-renders when the field's value changes. */} + <Form.Item noStyle dependencies={[name]}> + {({ getFieldValue }) => + mode !== "keep" && + getFieldValue(name) !== undefined && ( + <Link onClick={handleUndo}> + {undoLabel ?? t("uic.BulkEditFormItem.undoChanges")} + </Link> + ) + } + </Form.Item> + </HStack> + ); + + const placeholder = (label: string) => ( + <TextInput + label={label} + isLabelHidden + value={label} + onChange={noop} + onMouseDown={handlePlaceholderClick} + onFocus={handlePlaceholderClick} + width="100%" + /> + ); + + return ( + <Form.Item + {...formItemProps} + style={{ marginBottom: 0, ...formItemProps.style }} + // The marker is shown for every bulk-edit field: the item has no rules + // of its own to derive it from. + required + extra={ + <HStack + justify={formItemProps.extra ? "between" : "end"} + align="center" + gap={2} + > + {formItemProps.extra} + {actions} + </HStack> + } + > + {mode === "keep" + ? placeholder(resolvedKeepValueLabel) + : mode === "clear" + ? placeholder(resolvedClearValueLabel) + : null} + {/* Clear mode stays mounted (hidden) so its `null` is still collected + by validateFields / getFieldsValue. */} + {mode !== "keep" && ( + <Form.Item name={name} {...formItemProps} noStyle hidden={mode !== "edit"}> + {children && ( + <ControlWrapper ref={controlRef} onBlur={handleControlBlur}> + {children} + </ControlWrapper> + )} + </Form.Item> + )} + </Form.Item> + ); +} + +BulkEditFormItem.displayName = "BulkEditFormItem"; + +interface ControlWrapperRef { + open: () => void; + focus: () => void; +} + +interface ControlWrapperProps { + children: ReactElement; + onBlur?: () => void; +} + +/** + * Clones the control with a ref and a controlled `open` / `onOpenChange` + * pair, so the item can focus it and open its popup when editing starts. + * Any other props the item injects pass through. + */ +const ControlWrapper = forwardRef<ControlWrapperRef, ControlWrapperProps>( + ({ children, ...props }, ref) => { + const innerRef = useRef<{ focus?: () => void } | null>(null); + const [open, setOpen] = useState(false); + + useImperativeHandle(ref, () => ({ + focus: () => innerRef.current?.focus?.(), + open: () => setOpen(true), + })); + + const childProps = children.props as { onOpenChange?: (isOpen: boolean) => void }; + return cloneElement(children, { + ...props, + ref: innerRef, + open, + onOpenChange: (isOpen: boolean) => { + setOpen(isOpen); + childProps.onOpenChange?.(isOpen); + }, + } as Record<string, unknown>); + }, +); +ControlWrapper.displayName = "ControlWrapper"; diff --git a/src/components/BulkEditFormItem/index.ts b/src/components/BulkEditFormItem/index.ts new file mode 100644 index 0000000..faeed45 --- /dev/null +++ b/src/components/BulkEditFormItem/index.ts @@ -0,0 +1,2 @@ +export { BulkEditFormItem } from "./BulkEditFormItem"; +export type { BulkEditFormItemProps } from "./BulkEditFormItem"; diff --git a/src/components/Form/ErrorList.tsx b/src/components/Form/ErrorList.tsx new file mode 100644 index 0000000..724058c --- /dev/null +++ b/src/components/Form/ErrorList.tsx @@ -0,0 +1,45 @@ +/** + * `Form.ErrorList`: the errors of a list-level rule, which belong to the + * collection rather than to any row. Uses the item shell's explain classes. + */ +import * as React from "react"; + +import "./FormItemVisual.css"; + +export interface ErrorListProps { + errors?: React.ReactNode[]; + warnings?: React.ReactNode[]; + className?: string; + style?: React.CSSProperties; +} + +const ErrorList: React.FC<ErrorListProps> = ({ + errors = [], + warnings = [], + className, + style, +}) => { + if (!errors.length && !warnings.length) { + return null; + } + return ( + <div + className={["uic-form-item__explain", className].filter(Boolean).join(" ")} + role="alert" + style={style} + > + {errors.map((error, index) => ( + <div key={`e-${index}`} className="uic-form-item__explain-error"> + {error} + </div> + ))} + {warnings.map((warning, index) => ( + <div key={`w-${index}`} className="uic-form-item__explain-warning"> + {warning} + </div> + ))} + </div> + ); +}; + +export default ErrorList; diff --git a/src/components/Form/Field.tsx b/src/components/Form/Field.tsx new file mode 100644 index 0000000..5c19a96 --- /dev/null +++ b/src/components/Form/Field.tsx @@ -0,0 +1,568 @@ +/** + * `Field`: the state half of a form item. It renders no DOM of its own: it + * clones its single child with `value` / `onChange` (or the declared + * `valuePropName` / `trigger`), owns that field's meta, and decides when to + * re-render. Everything visual lives in `FormItem`. + * + * A class component because the store holds a reference to each mounted field + * and calls imperative methods on it (`validateRules`, `getMeta`, + * `onStoreChange`, `isFieldDirty`). + * + * The re-render decision in `onStoreChange` matches upstream on purpose: + * making it more precise changes when a component that reads values during + * render without subscribing to them updates. + */ +import { delayFrame } from "./FormStore"; +import { FieldContext, ListContext, HOOK_MARK, type ListContextValue } from "./context"; +import type { + FieldEntity, + InternalFormInstance, + InternalValidateOptions, + Meta, + NotifyInfo, + Rule, + RuleError, + RuleObject, + StoreValue, +} from "./interface"; +import { + containsNamePath, + defaultGetValueFromEvent, + getNamePath, + getValue, + toArray, + type InternalNamePath, + type NamePath, + type Store, +} from "./namePath"; +import { validateRules } from "./validate"; +import { isEqual } from "./isEqual"; +import * as React from "react"; + +const EMPTY_ERRORS: string[] = []; + +export interface FieldProps { + name?: NamePath; + children?: + | React.ReactNode + | (( + control: Record<string, any>, + meta: Meta, + form: InternalFormInstance, + ) => React.ReactNode); + rules?: Rule[]; + dependencies?: NamePath[]; + shouldUpdate?: + boolean | ((prev: Store, next: Store, info: { source?: string }) => boolean); + initialValue?: any; + preserve?: boolean; + trigger?: string; + validateTrigger?: string | string[] | false; + validateFirst?: boolean | "parallel"; + valuePropName?: string; + getValueProps?: (value: StoreValue) => Record<string, unknown>; + getValueFromEvent?: (...args: any[]) => StoreValue; + messageVariables?: Record<string, string>; + isListField?: boolean; + isList?: boolean; + onReset?: () => void; + onMetaChange?: (meta: Meta & { destroy?: boolean }) => void; +} + +interface InternalFieldProps extends Omit<FieldProps, "name"> { + name?: InternalNamePath; + fieldContext: InternalFormInstance; +} + +function requireUpdate( + shouldUpdate: FieldProps["shouldUpdate"], + prev: Store, + next: Store, + prevValue: any, + nextValue: any, + info: NotifyInfo, +): boolean { + if (typeof shouldUpdate === "function") { + // Only a value update carries a `source`; upstream passes `{}` otherwise, + // and comparators destructure it. + return shouldUpdate(prev, next, "source" in info ? { source: info.source } : {}); + } + return prevValue !== nextValue; +} + +class Field + extends React.PureComponent<InternalFieldProps, { resetCount: number }> + implements FieldEntity +{ + static override contextType = FieldContext; + + override state = { resetCount: 0 }; + + private cancelRegisterFunc: + | (( + isListField?: boolean, + preserve?: boolean, + subNamePath?: InternalNamePath, + ) => void) + | null = null; + + private mounted = false; + + /** + * Kept off React state on purpose: these must be readable synchronously + * during the same tick they change, before React has re-rendered. + */ + private touched = false; + + /** Touched OR validated. Only `dependencies` re-validation consults it. */ + private dirty = false; + + private validatePromise: Promise<any> | null | undefined; + + private errors: string[] = EMPTY_ERRORS; + + private warnings: string[] = EMPTY_ERRORS; + + private metaCache: (Meta & { destroy?: boolean }) | null = null; + + constructor(props: InternalFieldProps) { + super(props); + // Write `initialValue` into the store during construction, before the + // first render reads it — otherwise the control paints empty once. + if (props.fieldContext) { + props.fieldContext.getInternalHooks(HOOK_MARK)?.initEntityValue(this); + } + } + + override componentDidMount() { + const { shouldUpdate, fieldContext } = this.props; + this.mounted = true; + if (fieldContext) { + const hooks = fieldContext.getInternalHooks(HOOK_MARK); + this.cancelRegisterFunc = hooks?.registerField(this) ?? null; + } + // `shouldUpdate === true` means "render me again on anything"; the extra + // pass makes sure the first paint sees sibling fields that mounted after. + if (shouldUpdate === true) { + this.reRender(); + } + } + + override componentWillUnmount() { + this.cancelRegister(); + this.triggerMetaEvent(true); + this.mounted = false; + } + + private cancelRegister = () => { + const { preserve, isListField, name } = this.props; + this.cancelRegisterFunc?.(isListField, preserve, getNamePath(name)); + this.cancelRegisterFunc = null; + }; + + // ================================ Utils ================================= + + getNamePath = (): InternalNamePath => { + const { name, fieldContext } = this.props; + const { prefixName = [] } = fieldContext; + // `prefixName` is what makes a Form.List child's `name` RELATIVE while + // its `dependencies` stay absolute. + return name !== undefined ? [...prefixName, ...name] : []; + }; + + private getRules = (): RuleObject[] => { + const { rules = [], fieldContext } = this.props; + return rules.map((rule) => + typeof rule === "function" ? rule(fieldContext) : rule, + ); + }; + + private reRender() { + if (!this.mounted) return; + this.forceUpdate(); + } + + /** Remount the child subtree (used on reset so uncontrolled inputs clear). */ + private refresh = () => { + if (!this.mounted) return; + this.setState(({ resetCount }) => ({ resetCount: resetCount + 1 })); + }; + + private triggerMetaEvent = (destroy?: boolean) => { + const { onMetaChange } = this.props; + if (onMetaChange) { + const meta = { ...this.getMeta(), destroy }; + if (!isEqual(this.metaCache, meta)) { + onMetaChange(meta); + } + this.metaCache = meta; + } else { + this.metaCache = null; + } + }; + + // ========================= Field entity API ============================= + + onStoreChange = ( + prevStore: Store, + namePathList: InternalNamePath[] | null, + info: NotifyInfo, + ) => { + const { shouldUpdate, dependencies = [], onReset } = this.props; + const { store } = info; + const namePath = this.getNamePath(); + const prevValue = this.getValue(prevStore); + const curValue = this.getValue(store); + const namePathMatch = namePathList && containsNamePath(namePathList, namePath); + + // `setFieldsValue` is treated as user input: it marks the field touched + // and clears any error the previous value had produced. + if ( + info.type === "valueUpdate" && + info.source === "external" && + !isEqual(prevValue, curValue) + ) { + this.touched = true; + this.dirty = true; + this.validatePromise = null; + this.errors = EMPTY_ERRORS; + this.warnings = EMPTY_ERRORS; + this.triggerMetaEvent(); + } + + switch (info.type) { + case "reset": + if (!namePathList || namePathMatch) { + this.touched = false; + this.dirty = false; + this.validatePromise = undefined; + this.errors = EMPTY_ERRORS; + this.warnings = EMPTY_ERRORS; + this.triggerMetaEvent(); + onReset?.(); + this.refresh(); + return; + } + break; + + case "remove": { + // A `preserve: false` field disappearing can cascade: A hides B hides C. + if ( + shouldUpdate && + requireUpdate(shouldUpdate, prevStore, store, prevValue, curValue, info) + ) { + this.reRender(); + return; + } + break; + } + + case "setField": { + const { data } = info; + if (namePathMatch) { + if ("touched" in data) { + this.touched = data.touched as boolean; + } + if ("validating" in data && !("originRCField" in data)) { + this.validatePromise = data.validating ? Promise.resolve([]) : null; + } + if ("errors" in data) { + this.errors = data.errors || EMPTY_ERRORS; + } + if ("warnings" in data) { + this.warnings = data.warnings || EMPTY_ERRORS; + } + this.dirty = true; + this.triggerMetaEvent(); + this.reRender(); + return; + } + if ("value" in data && containsNamePath(namePathList, namePath, true)) { + this.reRender(); + return; + } + if ( + shouldUpdate && + !namePath.length && + requireUpdate(shouldUpdate, prevStore, store, prevValue, curValue, info) + ) { + this.reRender(); + return; + } + break; + } + + case "dependenciesUpdate": { + const dependencyList = dependencies.map(getNamePath); + if ( + dependencyList.some((dependency) => + containsNamePath(info.relatedFields, dependency), + ) + ) { + this.reRender(); + return; + } + break; + } + + default: + // A field re-renders when its own path changed, or — for a field with + // no `dependencies` shortcut — when `shouldUpdate` says so. + if ( + namePathMatch || + ((!dependencies.length || namePath.length || shouldUpdate) && + requireUpdate(shouldUpdate, prevStore, store, prevValue, curValue, info)) + ) { + this.reRender(); + return; + } + break; + } + + if (shouldUpdate === true) { + this.reRender(); + } + }; + + validateRules = (options?: InternalValidateOptions): Promise<RuleError[]> => { + // Snapshot both, so a `form.setFieldValue` mid-validation cannot swap the + // value out from under the rules. + const namePath = this.getNamePath(); + const currentValue = this.getValue(); + const { + triggerName, + validateOnly = false, + delayFrame: showDelayFrame, + } = options || {}; + + const rootPromise = Promise.resolve().then(async () => { + if (!this.mounted) { + return [] as RuleError[]; + } + const { validateFirst = false, messageVariables } = this.props; + + if (showDelayFrame) { + await delayFrame(); + } + + let filteredRules = this.getRules(); + if (triggerName) { + filteredRules = filteredRules + .filter((rule) => rule) + .filter((rule) => { + const { validateTrigger } = rule; + if (!validateTrigger) { + return true; + } + return toArray(validateTrigger).includes(triggerName); + }); + } + + const promise = validateRules( + namePath, + currentValue, + filteredRules, + options?.validateMessages ?? {}, + validateFirst, + messageVariables, + ); + + promise + .catch((e) => e) + .then((ruleErrors: RuleError[] = []) => { + // Ignore a stale run: only the latest validation may publish. + if (this.validatePromise === rootPromise) { + this.validatePromise = null; + const nextErrors: string[] = []; + const nextWarnings: string[] = []; + ruleErrors.forEach?.(({ rule: { warningOnly }, errors = [] }) => { + if (warningOnly) { + nextWarnings.push(...errors); + } else { + nextErrors.push(...errors); + } + }); + this.errors = nextErrors; + this.warnings = nextWarnings; + this.triggerMetaEvent(); + this.reRender(); + } + }); + + return promise; + }); + + if (validateOnly) { + return rootPromise as Promise<RuleError[]>; + } + + this.validatePromise = rootPromise; + this.dirty = true; + this.errors = EMPTY_ERRORS; + this.warnings = EMPTY_ERRORS; + this.triggerMetaEvent(); + this.reRender(); + + return rootPromise as Promise<RuleError[]>; + }; + + isFieldValidating = () => !!this.validatePromise; + + isFieldTouched = () => this.touched; + + isFieldDirty = () => { + if (this.dirty || this.props.initialValue !== undefined) { + return true; + } + const hooks = this.props.fieldContext.getInternalHooks(HOOK_MARK); + return hooks?.getInitialValue(this.getNamePath()) !== undefined; + }; + + getErrors = () => this.errors; + + getWarnings = () => this.warnings; + + isListField = () => this.props.isListField; + + isList = () => this.props.isList; + + isPreserve = () => this.props.preserve; + + getMeta = (): Meta => ({ + touched: this.isFieldTouched(), + validating: this.isFieldValidating(), + errors: this.errors, + warnings: this.warnings, + name: this.getNamePath(), + validated: this.validatePromise === null, + }); + + // ============================ Field control ============================= + + private getValue = (store?: Store) => { + const { getFieldsValue } = this.props.fieldContext; + return getValue(store || getFieldsValue(true), this.getNamePath()); + }; + + private getOnlyChild = ( + children: InternalFieldProps["children"], + ): { child: React.ReactNode; isFunction: boolean } => { + if (typeof children === "function") { + const meta = this.getMeta(); + return { + ...this.getOnlyChild( + children(this.getControlled(), meta, this.props.fieldContext), + ), + isFunction: true, + }; + } + const childList = React.Children.toArray(children); + if (childList.length !== 1 || !React.isValidElement(childList[0])) { + return { child: childList, isFunction: false }; + } + return { child: childList[0], isFunction: false }; + }; + + getControlled = (childProps: Record<string, any> = {}) => { + const { + name, + trigger = "onChange", + validateTrigger, + getValueFromEvent, + valuePropName = "value", + getValueProps, + fieldContext, + } = this.props; + const mergedValidateTrigger = + validateTrigger !== undefined ? validateTrigger : fieldContext.validateTrigger; + const namePath = this.getNamePath(); + const dispatch = fieldContext.getInternalHooks(HOOK_MARK)?.dispatch; + const value = this.getValue(); + const mergedGetValueProps = + getValueProps || ((val: StoreValue) => ({ [valuePropName]: val })); + const originTriggerFunc = childProps[trigger]; + // A layout-only Field (no `name`) must not inject `value` into its child. + const valueProps = name !== undefined ? mergedGetValueProps(value) : {}; + + const control: Record<string, any> = { ...childProps, ...valueProps }; + + control[trigger] = (...args: any[]) => { + this.touched = true; + this.dirty = true; + this.triggerMetaEvent(); + const newValue = getValueFromEvent + ? getValueFromEvent(...args) + : defaultGetValueFromEvent(valuePropName, ...args); + if (newValue !== value) { + dispatch?.({ type: "updateValue", namePath, value: newValue }); + } + originTriggerFunc?.(...args); + }; + + toArray(mergedValidateTrigger || []).forEach((triggerName) => { + const originTrigger = control[triggerName]; + control[triggerName] = (...args: any[]) => { + originTrigger?.(...args); + // Read `rules` at call time — a `dependencies`-driven rule swap must + // take effect immediately, not on the next render. + const { rules } = this.props; + if (rules && rules.length) { + dispatch?.({ type: "validateField", namePath, triggerName }); + } + }; + }); + + return control; + }; + + override render() { + const { resetCount } = this.state; + const { children } = this.props; + const { child, isFunction } = this.getOnlyChild(children); + + let returnChildNode: React.ReactNode; + if (isFunction) { + returnChildNode = child; + } else if (React.isValidElement(child)) { + returnChildNode = React.cloneElement( + child, + this.getControlled((child as React.ReactElement<any>).props), + ); + } else { + returnChildNode = child; + } + + return <React.Fragment key={resetCount}>{returnChildNode}</React.Fragment>; + } +} + +/** + * Normalises `name` and resolves the list/field contexts before handing off + * to the class. The `key` trick matters: a NON-list field is keyed by its + * path, so renaming it remounts a clean field instead of carrying the old + * one's touched/error state onto a different name. + */ +const WrapperField: React.FC<FieldProps & { fieldContext?: InternalFormInstance }> = ({ + name, + ...restProps +}) => { + const fieldContext = React.useContext(FieldContext); + const listContext = React.useContext<ListContextValue | null>(ListContext); + const namePath = name !== undefined ? getNamePath(name) : undefined; + const isMergedListField = restProps.isListField ?? !!listContext; + + const key = isMergedListField ? "keep" : `_${(namePath || []).join("_")}`; + + return ( + <Field + key={key} + {...restProps} + name={namePath} + isListField={isMergedListField} + fieldContext={restProps.fieldContext ?? fieldContext} + /> + ); +}; + +export default WrapperField; +export { Field as InternalField }; diff --git a/src/components/Form/Form.acceptance.test.tsx b/src/components/Form/Form.acceptance.test.tsx new file mode 100644 index 0000000..10d90e3 --- /dev/null +++ b/src/components/Form/Form.acceptance.test.tsx @@ -0,0 +1,1548 @@ +/** + * The form engine's acceptance suite: the semantics code written against + * antd's form depends on, one numbered contract per case. These cases ran + * against antd itself as an oracle until antd was dropped; the assertions are + * unchanged. + * + * Rules for editing this file: + * - Assert through the FormInstance API and rendered text only, not class + * names or data attributes: the suite is about form semantics. + * - When a test needs specific message text, pass `message` explicitly. + * Generated English would make it a locale test. + */ +import { Form as EngineForm, type FormInstance } from "./index"; +import { act, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import * as React from "react"; +import { describe, expect, it, vi } from "vitest"; + +/** + * `any` on purpose, carried over from the two-row setup: the assertions below + * reach for `Form.List` / `Form.Item` render-prop shapes that the engine's + * precise prop types express differently per overload, and tightening them + * here would rewrite 29 tests for no additional coverage. + */ +type AnyForm = any; + +const IMPLEMENTATIONS: [name: string, Form: AnyForm][] = [["engine", EngineForm]]; + +/** Plain controlled input, so no Astryx control is in the test path. */ +const Input: React.FC<any> = ({ value = "", onChange, ...rest }) => ( + <input value={value} onChange={(e) => onChange?.(e)} {...rest} /> +); + +/** Checkbox-shaped control for `valuePropName="checked"` coverage. */ +const Check: React.FC<any> = ({ checked = false, onChange, ...rest }) => ( + <input type="checkbox" checked={checked} onChange={(e) => onChange?.(e)} {...rest} /> +); + +/** Flush the engine's macrotask-batched watcher + validation queues. */ +async function settle() { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 20)); + }); +} + +describe.each(IMPLEMENTATIONS)("form engine acceptance [%s]", (_name, Form) => { + /** + * `Form.useForm()` plus a post-mount handoff of the instance to the test body. + * + * The obvious `form = instance;` inside the component would be a render-time + * write to a binding the component does not own — which the React Compiler + * lint rejects, correctly. Here the write lives in the TEST's own closure and + * runs from an effect, so the component stays pure and the instance is in + * hand before the first assertion (effects flush inside `render`). + */ + function useTestForm(onReady: (form: FormInstance) => void) { + const [instance] = Form.useForm(); + React.useEffect(() => { + onReady(instance); + }, [instance, onReady]); + return instance; + } + + // ========================================================================== + // A. dependencies re-validation + // ========================================================================== + + // 1. the accelerator field's + // ENTIRE rules array is swapped based on a dependency (6 rules <-> none). + // Two halves, both measured against antd: + // a) once the rules are gone, validation stops producing the error; + // b) the error ALREADY on the field is not swept away by re-validating + // (a field with no rules is skipped entirely, so nothing clears it) + // — it is cleared by the `setFieldValue` the page issues next. + // Getting (b) "more correct" would silently change what the page shows. + it("1. swapping the rules array via `dependencies` retires the old rule set", async () => { + let form!: FormInstance; + const captureForm = (instance: FormInstance) => { + form = instance; + }; + const Demo = () => { + const instance = useTestForm(captureForm); + return ( + <Form + form={instance} + initialValues={{ acceleratorType: "cuda", accelerator: 0 }} + > + <Form.Item name="acceleratorType"> + <Input /> + </Form.Item> + <Form.Item + noStyle + dependencies={["acceleratorType"]} + shouldUpdate={() => true} + > + {({ getFieldValue }: FormInstance) => ( + <Form.Item + name="accelerator" + label="Accelerator" + rules={ + getFieldValue("acceleratorType") === "cuda" + ? [{ required: true, message: "accelerator required" }] + : [] + } + > + <Input /> + </Form.Item> + )} + </Form.Item> + </Form> + ); + }; + render(<Demo />); + + form.setFieldValue("accelerator", undefined); + await expect(form.validateFields()).rejects.toMatchObject({ + errorFields: [{ name: ["accelerator"], errors: ["accelerator required"] }], + }); + await settle(); + expect(form.getFieldError("accelerator")).toEqual(["accelerator required"]); + + // Swap the dependency: the rule set becomes empty. + await act(async () => { + form.setFieldValue("acceleratorType", "none"); + }); + await settle(); + + // (a) The retired rule no longer fires. + await expect(form.validateFields()).resolves.toBeTruthy(); + await settle(); + // (b) A rule-less field is skipped by validation, so the previous error + // survives until the value is written again. + expect(form.getFieldError("accelerator")).toEqual(["accelerator required"]); + + await act(async () => { + form.setFieldValue("accelerator", 0); + }); + await settle(); + expect(form.getFieldError("accelerator")).toEqual([]); + }); + + // 2. cluster_size depends on + // four fields and carries a `warningOnly` capacity rule. + it("2. a multi-dependency field re-validates and reports warnings separately", async () => { + let form!: FormInstance; + const captureForm = (instance: FormInstance) => { + form = instance; + }; + const Demo = () => { + const instance = useTestForm(captureForm); + return ( + <Form + form={instance} + initialValues={{ + cpu: 1, + mem: 1, + gpu: 0, + mode: "single", + cluster_size: 1, + }} + > + {["cpu", "mem", "gpu", "mode"].map((field) => ( + <Form.Item key={field} name={field}> + <Input /> + </Form.Item> + ))} + <Form.Item + name="cluster_size" + label="Cluster size" + dependencies={["cpu", "mem", "gpu", "mode"]} + rules={[ + ({ getFieldValue }: FormInstance) => ({ + warningOnly: true, + validator: (_: unknown, value: number) => + Number(value) * Number(getFieldValue("cpu")) > 8 + ? Promise.reject(new Error("over capacity")) + : Promise.resolve(), + }), + ]} + > + <Input /> + </Form.Item> + </Form> + ); + }; + render(<Demo />); + + await act(async () => { + form.setFieldValue("cluster_size", 10); + form.setFieldValue("cpu", 4); + }); + await settle(); + // A warning never blocks: validateFields resolves. + await expect(form.validateFields()).resolves.toBeTruthy(); + await settle(); + expect(form.getFieldWarning("cluster_size")).toEqual(["over capacity"]); + expect(form.getFieldError("cluster_size")).toEqual([]); + }); + + // 3. one function rule reading two + // dependencies, rejecting WITHOUT a message: error state, no text. + it("3. a function rule may reject without a message (error state, no text)", async () => { + let form!: FormInstance; + const captureForm = (instance: FormInstance) => { + form = instance; + }; + const Demo = () => { + const instance = useTestForm(captureForm); + return ( + <Form + form={instance} + initialValues={{ enabled: true, timeout: 5, timeoutUnit: "s" }} + > + <Form.Item name="enabled" valuePropName="checked"> + <Check /> + </Form.Item> + <Form.Item name="timeout"> + <Input /> + </Form.Item> + <Form.Item + name="timeoutUnit" + dependencies={["enabled", "timeout"]} + rules={[ + ({ getFieldValue }: FormInstance) => ({ + validator: () => + getFieldValue("enabled") && !getFieldValue("timeout") + ? Promise.reject() + : Promise.resolve(), + }), + ]} + > + <Input /> + </Form.Item> + </Form> + ); + }; + render(<Demo />); + + form.setFieldValue("timeout", ""); + await expect(form.validateFields()).rejects.toBeTruthy(); + await settle(); + + const errors = form.getFieldError("timeoutUnit"); + expect(errors).toHaveLength(1); + // Whatever placeholder the implementation uses, it must be blank to a user. + expect(String(errors[0]).trim()).toBe(""); + }); + + // 4. `dependencies` inside a Form.List is an + // ABSOLUTE path including the numeric row index. + it("4. `dependencies` inside Form.List uses absolute, index-bearing paths", async () => { + let form!: FormInstance; + const captureForm = (instance: FormInstance) => { + form = instance; + }; + const Demo = () => { + const instance = useTestForm(captureForm); + return ( + <Form form={instance} initialValues={{ envs: [{ key: "", value: "" }] }}> + <Form.List name="envs"> + {(fields: any[]) => + fields.map((field) => ( + <React.Fragment key={field.key}> + <Form.Item {...field} name={[field.name, "key"]}> + <Input /> + </Form.Item> + <Form.Item + {...field} + name={[field.name, "value"]} + dependencies={[["envs", field.name, "key"]]} + rules={[ + ({ getFieldValue }: FormInstance) => ({ + validator: (_: unknown, value: string) => + getFieldValue(["envs", field.name, "key"]) && !value + ? Promise.reject(new Error("value required")) + : Promise.resolve(), + }), + ]} + > + <Input /> + </Form.Item> + </React.Fragment> + )) + } + </Form.List> + </Form> + ); + }; + render(<Demo />); + + await act(async () => { + form.setFieldValue(["envs", 0, "key"], "HOME"); + }); + await settle(); + + await expect(form.validateFields()).rejects.toMatchObject({ + errorFields: [{ name: ["envs", 0, "value"], errors: ["value required"] }], + }); + }); + + // 5. a validator reads a field + // that is NOT in its `dependencies`. "Not re-validated" is the CURRENT, + // intended behaviour; becoming more correct here would change the UI. + it("5. an undeclared cross-reference does NOT trigger re-validation", async () => { + let form!: FormInstance; + const captureForm = (instance: FormInstance) => { + form = instance; + }; + const validator = vi.fn(() => Promise.resolve()); + const Demo = () => { + const instance = useTestForm(captureForm); + return ( + <Form form={instance} initialValues={{ mode: "single", size: 1 }}> + <Form.Item name="mode"> + <Input /> + </Form.Item> + <Form.Item name="size" rules={[{ validator }]}> + <Input /> + </Form.Item> + </Form> + ); + }; + render(<Demo />); + + // Make `size` dirty so it WOULD be eligible for dependency re-validation. + await act(async () => { + form.setFieldValue("size", 2); + }); + await settle(); + validator.mockClear(); + + await act(async () => { + form.setFieldValue("mode", "multi"); + }); + await settle(); + + expect(validator).not.toHaveBeenCalled(); + }); + + // ========================================================================== + // B. shouldUpdate + // ========================================================================== + + // 6. a comparator watching ONE + // field gates a very large subtree. + it("6. a single-field `shouldUpdate` comparator gates its subtree", async () => { + let form!: FormInstance; + const captureForm = (instance: FormInstance) => { + form = instance; + }; + const renderSpy = vi.fn(); + const Demo = () => { + const instance = useTestForm(captureForm); + return ( + <Form form={instance} initialValues={{ preset: "a", unrelated: 1 }}> + <Form.Item + noStyle + shouldUpdate={(prev: any, next: any) => prev.preset !== next.preset} + > + {({ getFieldValue }: FormInstance) => { + renderSpy(); + return <div data-testid="preset">{getFieldValue("preset")}</div>; + }} + </Form.Item> + </Form> + ); + }; + render(<Demo />); + await settle(); + renderSpy.mockClear(); + + await act(async () => { + form.setFieldValue("unrelated", 99); + }); + await settle(); + expect(renderSpy).not.toHaveBeenCalled(); + + await act(async () => { + form.setFieldValue("preset", "b"); + }); + await settle(); + expect(renderSpy).toHaveBeenCalled(); + expect(screen.getByTestId("preset")).toHaveTextContent("b"); + }); + + // 7. a comparator over six values. + it("7. a six-value `shouldUpdate` comparator fires on any of them", async () => { + const watched = ["a", "b", "c", "d", "e", "f"]; + let form!: FormInstance; + const captureForm = (instance: FormInstance) => { + form = instance; + }; + const renderSpy = vi.fn(); + const Demo = () => { + const instance = useTestForm(captureForm); + return ( + <Form + form={instance} + initialValues={Object.fromEntries(watched.map((k) => [k, 0]))} + > + <Form.Item + noStyle + shouldUpdate={(prev: any, next: any) => + watched.some((key) => prev[key] !== next[key]) + } + > + {() => { + renderSpy(); + return <div />; + }} + </Form.Item> + </Form> + ); + }; + render(<Demo />); + await settle(); + + for (const key of watched) { + renderSpy.mockClear(); + await act(async () => { + form.setFieldValue(key, 1); + }); + await settle(); + expect(renderSpy, `changing ${key} should re-render`).toHaveBeenCalled(); + } + }); + + // 8. the repo's only + // boolean `shouldUpdate`, whose consumer reads UNREGISTERED fields via + // `getFieldsValue(true)`. + it("8. boolean `shouldUpdate` re-renders on any change and sees unregistered fields", async () => { + let form!: FormInstance; + const captureForm = (instance: FormInstance) => { + form = instance; + }; + const Demo = () => { + const instance = useTestForm(captureForm); + return ( + <Form form={instance} initialValues={{ registered: 1, ghost: "from-initial" }}> + <Form.Item name="registered"> + <Input /> + </Form.Item> + <Form.Item noStyle shouldUpdate> + {({ getFieldsValue }: FormInstance) => ( + <div data-testid="all">{JSON.stringify(getFieldsValue(true))}</div> + )} + </Form.Item> + </Form> + ); + }; + render(<Demo />); + await settle(); + + // `ghost` has no Form.Item, so only getFieldsValue(true) can see it. + expect(screen.getByTestId("all")).toHaveTextContent("from-initial"); + expect(form.getFieldsValue()).not.toHaveProperty("ghost"); + + await act(async () => { + form.setFieldValue("registered", 7); + }); + await settle(); + expect(screen.getByTestId("all")).toHaveTextContent('"registered":7'); + }); + + // ========================================================================== + // C. nested paths + // ========================================================================== + + // 9. seven segments, two of + // them numeric indices. + it("9. a seven-segment path with two numeric indices round-trips", async () => { + const path = [ + "modelDefinition", + "models", + 0, + "service", + "preStartActions", + 1, + "action", + ]; + let form!: FormInstance; + const captureForm = (instance: FormInstance) => { + form = instance; + }; + const Demo = () => { + const instance = useTestForm(captureForm); + return ( + <Form form={instance}> + <Form.Item name={path} label="Deep"> + <Input /> + </Form.Item> + </Form> + ); + }; + render(<Demo />); + + await act(async () => { + form.setFieldValue(path, "echo hi"); + }); + await settle(); + + expect(form.getFieldValue(path)).toBe("echo hi"); + const values = form.getFieldsValue(); + // Numeric segments must have produced ARRAYS, not objects with '0' keys. + expect(Array.isArray(values.modelDefinition.models)).toBe(true); + expect( + Array.isArray(values.modelDefinition.models[0].service.preStartActions), + ).toBe(true); + expect(values.modelDefinition.models[0].service.preStartActions[1].action).toBe( + "echo hi", + ); + }); + + // 10. inside a + // Form.List, `name` is RELATIVE while `dependencies` is ABSOLUTE. + // If the engine loses this asymmetry, nested cross-validation dies + // silently. + it("10. inside Form.List, `name` is relative but `dependencies` is absolute", async () => { + let form!: FormInstance; + const captureForm = (instance: FormInstance) => { + form = instance; + }; + const Demo = () => { + const instance = useTestForm(captureForm); + return ( + <Form + form={instance} + initialValues={{ + models: [{ enableHealthCheck: false, health: { path: "" } }], + }} + > + <Form.List name="models"> + {(fields: any[]) => + fields.map((field) => ( + <React.Fragment key={field.key}> + <Form.Item + {...field} + name={[field.name, "enableHealthCheck"]} + valuePropName="checked" + > + <Check /> + </Form.Item> + <Form.Item + {...field} + // RELATIVE: resolved against the list's prefix. + name={[field.name, "health", "path"]} + // ABSOLUTE: the full path from the form root. + dependencies={[["models", field.name, "enableHealthCheck"]]} + rules={[ + ({ getFieldValue }: FormInstance) => ({ + required: !!getFieldValue([ + "models", + field.name, + "enableHealthCheck", + ]), + message: "health path required", + }), + ]} + > + <Input /> + </Form.Item> + </React.Fragment> + )) + } + </Form.List> + </Form> + ); + }; + render(<Demo />); + + // Health check off: the relative field is optional. + await expect(form.validateFields()).resolves.toBeTruthy(); + + await act(async () => { + form.setFieldValue(["models", 0, "enableHealthCheck"], true); + }); + await settle(); + + await expect(form.validateFields()).rejects.toMatchObject({ + errorFields: [ + { + name: ["models", 0, "health", "path"], + errors: ["health path required"], + }, + ], + }); + }); + + // 11. `useWatch` on an + // absolute, index-bearing path, resolving the form from CONTEXT. + it("11. `useWatch` resolves the form from context and takes absolute paths", async () => { + let form!: FormInstance; + const captureForm = (instance: FormInstance) => { + form = instance; + }; + const Watcher = () => { + const value = Form.useWatch(["rows", 0, "slot"]); + return <div data-testid="watched">{String(value)}</div>; + }; + const Demo = () => { + const instance = useTestForm(captureForm); + return ( + <Form form={instance} initialValues={{ rows: [{ slot: "cpu" }] }}> + <Form.List name="rows"> + {(fields: any[]) => + fields.map((field) => ( + <Form.Item key={field.key} {...field} name={[field.name, "slot"]}> + <Input /> + </Form.Item> + )) + } + </Form.List> + <Watcher /> + </Form> + ); + }; + render(<Demo />); + await settle(); + expect(screen.getByTestId("watched")).toHaveTextContent("cpu"); + + await act(async () => { + form.setFieldValue(["rows", 0, "slot"], "cuda"); + }); + await settle(); + expect(screen.getByTestId("watched")).toHaveTextContent("cuda"); + }); + + // 12. + // `setFieldsValue` MERGES nested objects; it does not replace them. + it("12. `setFieldsValue` merges nested objects instead of replacing them", async () => { + let form!: FormInstance; + const captureForm = (instance: FormInstance) => { + form = instance; + }; + const Demo = () => { + const instance = useTestForm(captureForm); + return ( + <Form + form={instance} + initialValues={{ + resource: { cpu: 1, mem: "4g", shmem: "1g" }, + name: "x", + }} + > + <Form.Item name={["resource", "cpu"]}> + <Input /> + </Form.Item> + <Form.Item name={["resource", "mem"]}> + <Input /> + </Form.Item> + <Form.Item name={["resource", "shmem"]}> + <Input /> + </Form.Item> + <Form.Item name="name"> + <Input /> + </Form.Item> + </Form> + ); + }; + render(<Demo />); + + await act(async () => { + form.setFieldsValue({ resource: { cpu: 8 } }); + }); + await settle(); + + expect(form.getFieldsValue()).toEqual({ + resource: { cpu: 8, mem: "4g", shmem: "1g" }, + name: "x", + }); + }); + + // ========================================================================== + // D. Form.List + // ========================================================================== + + // 13. a NESTED list whose + // `add()` carries an initial row value. + it("13. a nested Form.List adds rows with an initial value", async () => { + let form!: FormInstance; + const captureForm = (instance: FormInstance) => { + form = instance; + }; + let outerAdd!: (v?: any) => void; + let innerAdd!: (v?: any) => void; + const captureOuter = (ops: any) => { + outerAdd = ops.add; + }; + const captureInner = (ops: any) => { + innerAdd = ops.add; + }; + const Demo = () => { + const instance = useTestForm(captureForm); + return ( + <Form form={instance} initialValues={{ models: [{ preStartActions: [] }] }}> + <Form.List name="models"> + {(models: any[], ops: any) => { + captureOuter(ops); + return models.map((model) => ( + <Form.List key={model.key} name={[model.name, "preStartActions"]}> + {(actions: any[], innerOps: any) => { + captureInner(innerOps); + return actions.map((action) => ( + <Form.Item + key={action.key} + {...action} + name={[action.name, "action"]} + > + <Input /> + </Form.Item> + )); + }} + </Form.List> + )); + }} + </Form.List> + </Form> + ); + }; + render(<Demo />); + + await act(async () => { + innerAdd({ action: "", args: "{}" }); + }); + await settle(); + + expect(form.getFieldValue(["models", 0, "preStartActions"])).toEqual([ + { action: "", args: "{}" }, + ]); + + await act(async () => { + outerAdd({ preStartActions: [] }); + }); + await settle(); + expect(form.getFieldValue("models")).toHaveLength(2); + }); + + // 14. list-level `rules` surfaced through + // `Form.ErrorList`. + it("14. list-level rules render through Form.ErrorList", async () => { + let form!: FormInstance; + const captureForm = (instance: FormInstance) => { + form = instance; + }; + const Demo = () => { + const instance = useTestForm(captureForm); + return ( + <Form form={instance} initialValues={{ envs: [] }}> + <Form.List + name="envs" + rules={[ + { + validator: async (_: unknown, value: unknown[]) => { + if (!value || value.length < 1) { + return Promise.reject(new Error("at least one env")); + } + return Promise.resolve(); + }, + }, + ]} + > + {(fields: any[], _ops: any, { errors }: any) => ( + <> + {fields.map((field) => ( + <Form.Item key={field.key} {...field} name={[field.name, "key"]}> + <Input /> + </Form.Item> + ))} + <Form.ErrorList errors={errors} /> + </> + )} + </Form.List> + </Form> + ); + }; + render(<Demo />); + + await expect(form.validateFields()).rejects.toBeTruthy(); + await waitFor(() => { + expect(screen.getByText("at least one env")).toBeInTheDocument(); + }); + }); + + // 15. a list-level validator that rejects the + // empty array, and stops rejecting once a row exists. + it("15. a list-level validator rejecting an empty array clears once a row is added", async () => { + let form!: FormInstance; + const captureForm = (instance: FormInstance) => { + form = instance; + }; + let add!: (v?: any) => void; + const captureOps = (ops: any) => { + add = ops.add; + }; + const Demo = () => { + const instance = useTestForm(captureForm); + return ( + <Form form={instance} initialValues={{ scopes: [] }}> + <Form.List + name="scopes" + rules={[ + { + validator: async (_: unknown, value: unknown[]) => + value && value.length + ? Promise.resolve() + : Promise.reject(new Error("scope required")), + }, + ]} + > + {(fields: any[], ops: any) => { + captureOps(ops); + return fields.map((field) => ( + <Form.Item key={field.key} {...field} name={[field.name, "id"]}> + <Input /> + </Form.Item> + )); + }} + </Form.List> + </Form> + ); + }; + render(<Demo />); + + await expect(form.validateFields()).rejects.toMatchObject({ + errorFields: [{ name: ["scopes"], errors: ["scope required"] }], + }); + + await act(async () => { + add({}); + }); + await settle(); + await expect(form.validateFields()).resolves.toBeTruthy(); + }); + + // 16. the row `add()` created must be mounted + // by the next macrotask, which is when the call site focuses its ref. + it("16. a row added by `add()` is mounted on the next macrotask", async () => { + let add!: (v?: any) => void; + const captureOps = (ops: any) => { + add = ops.add; + }; + const Demo = () => { + const [instance] = Form.useForm(); + return ( + <Form form={instance} initialValues={{ envs: [] }}> + <Form.List name="envs"> + {(fields: any[], ops: any) => { + captureOps(ops); + return fields.map((field) => ( + <Form.Item key={field.key} {...field} name={[field.name, "key"]}> + <Input data-testid={`row-${field.name}`} /> + </Form.Item> + )); + }} + </Form.List> + </Form> + ); + }; + render(<Demo />); + + const mounted = await new Promise<boolean>((resolve) => { + act(() => { + add(); + }); + setTimeout(() => resolve(!!screen.queryByTestId("row-0")), 0); + }); + expect(mounted).toBe(true); + }); + + // 17. `remove(index)` must shift the SURVIVING rows' errors, not leave them + // pinned to the old index. This is the classic Form.List failure. + it("17. `remove(index)` shifts remaining rows and their errors correctly", async () => { + let form!: FormInstance; + const captureForm = (instance: FormInstance) => { + form = instance; + }; + let remove!: (index: number) => void; + const captureOps = (ops: any) => { + remove = ops.remove; + }; + const Demo = () => { + const instance = useTestForm(captureForm); + return ( + <Form + form={instance} + initialValues={{ + rows: [{ key: "ok-1" }, { key: "" }, { key: "ok-3" }], + }} + > + <Form.List name="rows"> + {(fields: any[], ops: any) => { + captureOps(ops); + return fields.map((field) => ( + <Form.Item + key={field.key} + {...field} + name={[field.name, "key"]} + rules={[{ required: true, message: "key required" }]} + > + <Input /> + </Form.Item> + )); + }} + </Form.List> + </Form> + ); + }; + render(<Demo />); + + await expect(form.validateFields()).rejects.toMatchObject({ + errorFields: [{ name: ["rows", 1, "key"], errors: ["key required"] }], + }); + await settle(); + + // Drop the FIRST row. The invalid row slides from index 1 to index 0. + await act(async () => { + remove(0); + }); + await settle(); + + expect(form.getFieldValue("rows")).toEqual([{ key: "" }, { key: "ok-3" }]); + await expect(form.validateFields()).rejects.toMatchObject({ + errorFields: [{ name: ["rows", 0, "key"], errors: ["key required"] }], + }); + await settle(); + expect(form.getFieldError(["rows", 1, "key"])).toEqual([]); + }); + + // ========================================================================== + // E. preserve + // ========================================================================== + + // 18. a dependency + // UNMOUNTS a field group, and the default `preserve: true` keeps its + // value alive so the modal can still read and re-show it. + // Note the boundary: the value stays in the STORE, but a field that is + // not mounted is not part of `getFieldsValue()` (nor of what + // `validateFields()` resolves) — those project the REGISTERED fields. + // Reads of unmounted values go through `getFieldValue` / + // `getFieldsValue(true)`, which is exactly what the call site does. + it("18. with default preserve, an unmounted field keeps its value", async () => { + let form!: FormInstance; + const captureForm = (instance: FormInstance) => { + form = instance; + }; + const Demo = () => { + const instance = useTestForm(captureForm); + const show = Form.useWatch("show", instance); + return ( + <Form form={instance} initialValues={{ show: true, secret: "kept" }}> + <Form.Item name="show" valuePropName="checked"> + <Check /> + </Form.Item> + {show ? ( + <Form.Item name="secret"> + <Input /> + </Form.Item> + ) : null} + </Form> + ); + }; + render(<Demo />); + await settle(); + + await act(async () => { + form.setFieldValue("show", false); + }); + await settle(); + + expect(form.getFieldValue("secret")).toBe("kept"); + expect(form.getFieldsValue(true)).toMatchObject({ secret: "kept" }); + expect(form.getFieldsValue()).not.toHaveProperty("secret"); + + // Re-showing the group brings the value back into the registered set. + await act(async () => { + form.setFieldValue("show", true); + }); + await settle(); + expect(form.getFieldsValue()).toMatchObject({ secret: "kept" }); + await expect(form.validateFields()).resolves.toMatchObject({ + secret: "kept", + }); + }); + + // 19. The 28 modal forms that pass `preserve={false}`: an unmounted field's + // value disappears from `getFieldsValue()` KEYS, not just its value. + it("19. `preserve={false}` drops the key of an unmounted field", async () => { + let form!: FormInstance; + const captureForm = (instance: FormInstance) => { + form = instance; + }; + const Demo = () => { + const instance = useTestForm(captureForm); + const show = Form.useWatch("show", instance); + return ( + <Form form={instance} preserve={false} initialValues={{ show: true }}> + <Form.Item name="show" valuePropName="checked"> + <Check /> + </Form.Item> + {show ? ( + <Form.Item name="secret"> + <Input /> + </Form.Item> + ) : null} + </Form> + ); + }; + render(<Demo />); + await settle(); + + await act(async () => { + form.setFieldValue("secret", "typed"); + }); + await settle(); + expect(form.getFieldsValue()).toHaveProperty("secret"); + + await act(async () => { + form.setFieldValue("show", false); + }); + await settle(); + + expect(form.getFieldsValue()).not.toHaveProperty("secret"); + }); + + // 20. the call site's comment + // states that `setFieldsValue` for an UNREGISTERED field is ignored. + // It is written into the raw store but never surfaces through + // `getFieldsValue()`, which is the read the code performs. + it("20. `setFieldsValue` on an unregistered field does not surface in getFieldsValue()", async () => { + let form!: FormInstance; + const captureForm = (instance: FormInstance) => { + form = instance; + }; + const Demo = () => { + const instance = useTestForm(captureForm); + return ( + <Form form={instance}> + <Form.Item name="known"> + <Input /> + </Form.Item> + </Form> + ); + }; + render(<Demo />); + + await act(async () => { + form.setFieldsValue({ known: "a", unknown: "b" } as any); + }); + await settle(); + + expect(form.getFieldsValue()).toEqual({ known: "a" }); + expect(form.getFieldsValue(true)).toMatchObject({ unknown: "b" }); + }); + + // ========================================================================== + // F. timing / triggers + // ========================================================================== + + // 21. the + // form-level `validateTrigger` reaches nested items. + it("21. a form-level `validateTrigger` propagates to nested items", async () => { + const user = userEvent.setup(); + const Demo = () => { + const [instance] = Form.useForm(); + return ( + <Form form={instance} validateTrigger={["onChange", "onBlur"]}> + <Form.Item + name="email" + rules={[{ required: true, message: "email required" }]} + > + <Input data-testid="email" /> + </Form.Item> + </Form> + ); + }; + render(<Demo />); + + const input = screen.getByTestId("email"); + await user.type(input, "a"); + await user.clear(input); + await waitFor(() => { + expect(screen.getByText("email required")).toBeInTheDocument(); + }); + }); + + // 22. the guard against an infinite + // validate loop: re-validate only while the field has no error. + it("22. re-validating only while the error list is empty terminates", async () => { + let form!: FormInstance; + const captureForm = (instance: FormInstance) => { + form = instance; + }; + const runs = { count: 0 }; + const Demo = () => { + const instance = useTestForm(captureForm); + return ( + <Form form={instance} initialValues={{ a: "" }}> + <Form.Item name="a" rules={[{ required: true, message: "a required" }]}> + <Input /> + </Form.Item> + </Form> + ); + }; + render(<Demo />); + + // Simulate the call site's loop: validate while no error is recorded. + for (let i = 0; i < 5; i += 1) { + if (form.getFieldError("a").length) break; + runs.count += 1; + await form.validateFields(["a"]).catch(() => undefined); + await settle(); + } + expect(runs.count).toBe(1); + expect(form.getFieldError("a")).toEqual(["a required"]); + }); + + // 23. `recursive: true` + // validates everything nested under the requested prefix. + it("23. `validateFields([prefix], {recursive: true})` reaches nested fields", async () => { + let form!: FormInstance; + const captureForm = (instance: FormInstance) => { + form = instance; + }; + const Demo = () => { + const instance = useTestForm(captureForm); + return ( + <Form form={instance} initialValues={{ resource: { cpu: "" }, other: "" }}> + <Form.Item + name={["resource", "cpu"]} + rules={[{ required: true, message: "cpu required" }]} + > + <Input /> + </Form.Item> + <Form.Item + name="other" + rules={[{ required: true, message: "other required" }]} + > + <Input /> + </Form.Item> + </Form> + ); + }; + render(<Demo />); + + // Without `recursive`, the prefix matches no field exactly. + await expect(form.validateFields(["resource"])).resolves.toBeTruthy(); + + await expect( + form.validateFields(["resource"], { recursive: true }), + ).rejects.toMatchObject({ + errorFields: [{ name: ["resource", "cpu"], errors: ["cpu required"] }], + }); + }); + + // 24. `resetFields()` + // restores the `initialValues` AS OF THE CALL, not as of mount. + it("24. `resetFields()` applies the initialValues in effect at call time", async () => { + let form!: FormInstance; + const captureForm = (instance: FormInstance) => { + form = instance; + }; + const Demo = ({ initial }: { initial: string }) => { + const instance = useTestForm(captureForm); + return ( + <Form form={instance} initialValues={{ title: initial }}> + <Form.Item name="title"> + <Input /> + </Form.Item> + </Form> + ); + }; + const { rerender } = render(<Demo initial="first" />); + expect(form.getFieldValue("title")).toBe("first"); + + await act(async () => { + form.setFieldValue("title", "edited"); + }); + // A new `initialValues` prop must not clobber the user's edit... + rerender(<Demo initial="second" />); + await settle(); + expect(form.getFieldValue("title")).toBe("edited"); + + // ...but it IS what a later reset restores. + await act(async () => { + form.resetFields(); + }); + await settle(); + expect(form.getFieldValue("title")).toBe("second"); + }); + + // 25. the page uses `Form.Provider + // onFormChange` INSTEAD of `onValuesChange`, with the comment + // "onValuesChange will not be triggered when form is changed + // programmatically". The distinction is the channel each is wired to: + // `onValuesChange` fires only from a control's own trigger, while + // `onFormChange` rides `onFieldsChange`, which also fires from + // validation — the path a programmatic edit reaches. + it("25. Form.Provider onFormChange is the broader channel; onValuesChange is user input only", async () => { + const user = userEvent.setup(); + let form!: FormInstance; + const captureForm = (instance: FormInstance) => { + form = instance; + }; + const onFormChange = vi.fn(); + const onValuesChange = vi.fn(); + const Demo = () => { + const instance = useTestForm(captureForm); + return ( + <Form.Provider onFormChange={onFormChange}> + <Form + form={instance} + name="launcher" + onValuesChange={onValuesChange} + initialValues={{ a: "1" }} + > + <Form.Item name="a" rules={[{ required: true, message: "a required" }]}> + <Input data-testid="a" /> + </Form.Item> + </Form> + </Form.Provider> + ); + }; + render(<Demo />); + await settle(); + onFormChange.mockClear(); + onValuesChange.mockClear(); + + // User input reaches BOTH. + await user.type(screen.getByTestId("a"), "2"); + await settle(); + expect(onValuesChange).toHaveBeenCalled(); + expect(onFormChange).toHaveBeenCalled(); + expect(onFormChange.mock.calls[0]![0]).toBe("launcher"); + + onFormChange.mockClear(); + onValuesChange.mockClear(); + + // A programmatic edit reaches ONLY onFormChange. + await act(async () => { + form.setFieldValue("a", "programmatic"); + }); + await form.validateFields().catch(() => undefined); + await settle(); + + expect(onFormChange).toHaveBeenCalled(); + expect(onValuesChange).not.toHaveBeenCalled(); + }); + + // 25b. A `<Form>` with NO `name` still reaches `Form.Provider onFormChange`: + // rc-field-form fires the callback for unnamed forms too (`name` + // undefined), and code that syncs form values elsewhere relies on it. + it("25b. Form.Provider onFormChange fires for unnamed forms, with name undefined", async () => { + const user = userEvent.setup(); + let form!: FormInstance; + const captureForm = (instance: FormInstance) => { + form = instance; + }; + const onFormChange = vi.fn(); + const Demo = () => { + const instance = useTestForm(captureForm); + return ( + <Form.Provider onFormChange={onFormChange}> + <Form form={instance} initialValues={{ a: "1" }}> + {/* Rule required: the programmatic leg reaches onFieldsChange via + validation, and a rule-less field is skipped there (test 1). */} + <Form.Item name="a" rules={[{ required: true, message: "a required" }]}> + <Input data-testid="a" /> + </Form.Item> + </Form> + </Form.Provider> + ); + }; + render(<Demo />); + await settle(); + onFormChange.mockClear(); + + // User input fires the channel even without a form name. + await user.type(screen.getByTestId("a"), "2"); + await settle(); + expect(onFormChange).toHaveBeenCalled(); + expect(onFormChange.mock.calls[0]![0]).toBeUndefined(); + + onFormChange.mockClear(); + + // So does a programmatic edit. + await act(async () => { + form.setFieldValue("a", "programmatic"); + }); + await form.validateFields().catch(() => undefined); + await settle(); + expect(onFormChange).toHaveBeenCalled(); + }); + + // ========================================================================== + // G. error channels + // ========================================================================== + + // 26. the Launch button is disabled from + // `getFieldsError()`. A `warningOnly` rule leaking into `errors` would + // disable it permanently. + it("26. `warningOnly` populates warnings and leaves getFieldsError() errors empty", async () => { + let form!: FormInstance; + const captureForm = (instance: FormInstance) => { + form = instance; + }; + const Demo = () => { + const instance = useTestForm(captureForm); + return ( + <Form form={instance} initialValues={{ mem: 999 }}> + <Form.Item + name="mem" + rules={[ + { + warningOnly: true, + max: 8, + type: "number", + message: "too much memory", + }, + ]} + > + <Input /> + </Form.Item> + </Form> + ); + }; + render(<Demo />); + + await expect(form.validateFields()).resolves.toBeTruthy(); + await settle(); + + const [entry] = form.getFieldsError() as [ + ReturnType<FormInstance["getFieldsError"]>[number], + ]; + expect(entry.name).toEqual(["mem"]); + expect(entry.errors).toEqual([]); + expect(entry.warnings).toEqual(["too much memory"]); + // The Launch-button predicate the page actually runs. + expect(form.getFieldsError().some(({ errors }) => errors.length)).toBe(false); + }); + + // 27. The nine server-error injection sites: `setFields` writes errors that + // survive until `resetFields()` clears them. + it("27. `setFields` injects server errors and `resetFields()` clears them", async () => { + let form!: FormInstance; + const captureForm = (instance: FormInstance) => { + form = instance; + }; + const Demo = () => { + const instance = useTestForm(captureForm); + return ( + <Form form={instance} initialValues={{ name: "taken" }}> + <Form.Item name="name" label="Name"> + <Input /> + </Form.Item> + </Form> + ); + }; + render(<Demo />); + + await act(async () => { + form.setFields([{ name: "name", errors: ["already exists"] }]); + }); + await settle(); + + expect(form.getFieldError("name")).toEqual(["already exists"]); + expect(screen.getByText("already exists")).toBeInTheDocument(); + + await act(async () => { + form.resetFields(); + }); + await settle(); + expect(form.getFieldError("name")).toEqual([]); + }); + + // 28. + // `{ required: true, message: '' }` means "error state, no text", which + // is NOT the same as omitting `message`. + it("28. an empty `message` differs from an omitted one", async () => { + let form!: FormInstance; + const captureForm = (instance: FormInstance) => { + form = instance; + }; + const Demo = () => { + const instance = useTestForm(captureForm); + return ( + <Form form={instance} initialValues={{ silent: "", spoken: "" }}> + <Form.Item + name="silent" + label="Silent" + rules={[{ required: true, message: "" }]} + > + <Input /> + </Form.Item> + <Form.Item name="spoken" label="Spoken" rules={[{ required: true }]}> + <Input /> + </Form.Item> + </Form> + ); + }; + render(<Demo />); + + await expect(form.validateFields()).rejects.toBeTruthy(); + await settle(); + + expect(form.getFieldError("silent")).toEqual([""]); + const spoken = form.getFieldError("spoken"); + expect(spoken).toHaveLength(1); + expect(spoken[0]!.length).toBeGreaterThan(0); + }); + + // 29. the exact reject object 74 `.catch()` sites read. + it("29. validateFields rejects with {message, values, errorFields, outOfDate}", async () => { + let form!: FormInstance; + const captureForm = (instance: FormInstance) => { + form = instance; + }; + const Demo = () => { + const instance = useTestForm(captureForm); + return ( + <Form form={instance} initialValues={{ tags: [{ key: "" }], max: "" }}> + <Form.Item + name="max" + rules={[{ required: true, message: "Max is required" }]} + > + <Input /> + </Form.Item> + <Form.List name="tags"> + {(fields: any[]) => + fields.map((field) => ( + <Form.Item + key={field.key} + {...field} + name={[field.name, "key"]} + rules={[{ required: true, message: "Key is required" }]} + > + <Input /> + </Form.Item> + )) + } + </Form.List> + </Form> + ); + }; + render(<Demo />); + + const info = await form.validateFields().then( + () => { + throw new Error("should have rejected"); + }, + (e: any) => e, + ); + + expect(Object.keys(info).sort()).toEqual([ + "errorFields", + "message", + "outOfDate", + "values", + ]); + // Not an Error instance — code that expects `instanceof Error` would break. + expect(info instanceof Error).toBe(false); + // `message` is the first error of the first failing field, in REGISTRATION + // order (`max` registers before the list rows). + expect(info.message).toBe("Max is required"); + expect(info.outOfDate).toBe(false); + expect(info.values).toMatchObject({ max: "", tags: [{ key: "" }] }); + expect(info.errorFields).toEqual([ + { name: ["max"], errors: ["Max is required"], warnings: [] }, + { name: ["tags", 0, "key"], errors: ["Key is required"], warnings: [] }, + ]); + }); + + // 30. a trap that cost real + // debugging time: a Suspense hide counts as an unmount, so a boundary + // ABOVE the form resets every `preserve={false}` field to its initial + // value on the hide/show cycle. Keep suspending children behind a + // boundary INSIDE the form. + it("30. a Suspense hide/show cycle above a `preserve={false}` form resets its fields", async () => { + let settled = false; + let resolveChild!: () => void; + const childPromise = new Promise<void>((res) => { + resolveChild = () => { + settled = true; + res(); + }; + }); + const SuspendingChild: React.FC<any> = (props) => { + if (!settled) throw childPromise; + return <Input data-testid="lazy-child" {...props} />; + }; + let form!: FormInstance; + const captureForm = (instance: FormInstance) => { + form = instance; + }; + const Demo = () => { + const instance = useTestForm(captureForm); + const show = Form.useWatch("show", instance); + return ( + <Form + form={instance} + preserve={false} + initialValues={{ show: false, keep: "initial" }} + > + <Form.Item name="show" valuePropName="checked"> + <Check /> + </Form.Item> + <Form.Item name="keep"> + <Input /> + </Form.Item> + {show ? ( + <Form.Item name="lazy"> + <SuspendingChild /> + </Form.Item> + ) : null} + </Form> + ); + }; + render( + <React.Suspense fallback={null}> + <Demo /> + </React.Suspense>, + ); + await settle(); + + await act(async () => { + form.setFieldValue("keep", "typed"); + }); + await settle(); + expect(form.getFieldValue("keep")).toBe("typed"); + + // Mounting the suspending child hides the whole form behind the outer + // boundary until the promise resolves. + await act(async () => { + form.setFieldValue("show", true); + }); + await settle(); + await act(async () => { + resolveChild(); + await childPromise; + }); + await settle(); + + // EVERY field reset to initial — including `show`, so the lazy item is + // gone again and the child never re-renders. + expect(form.getFieldValue("keep")).toBe("initial"); + expect(form.getFieldValue("show")).toBe(false); + expect(screen.queryByTestId("lazy-child")).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/Form/Form.messages.ts b/src/components/Form/Form.messages.ts new file mode 100644 index 0000000..6a87108 --- /dev/null +++ b/src/components/Form/Form.messages.ts @@ -0,0 +1,103 @@ +import { defineMessages } from "../../i18n/catalog"; + +/** + * The form engine's built-in strings: the validation message templates and + * the `requiredMark="optional"` suffix. Placeholders are the rule's values: + * `{label}` (the item's label, or its name), `{type}`, `{len}`, `{min}`, + * `{max}` and `{pattern}`. + */ +export const formMessages = defineMessages({ + "uic.Form.optional": { + defaultMessage: "(optional)", + description: + 'Suffix after the label of an optional field when requiredMark="optional"', + }, + "uic.Form.default": { + defaultMessage: "Field validation error for {label}", + description: "Validation error when a rule failed without a more specific message", + }, + "uic.Form.required": { + defaultMessage: "Please enter {label}", + description: "Validation error for an empty required field", + }, + "uic.Form.whitespace": { + defaultMessage: "{label} cannot be a blank character", + description: "Validation error for a required field that holds only whitespace", + }, + "uic.Form.typeString": { + defaultMessage: "{label} is not a valid {type}", + description: + "Validation error for a value that is not a string; {type} is the type name", + }, + "uic.Form.typeNumber": { + defaultMessage: "{label} is not a valid {type}", + description: + "Validation error for a value that is not a number; {type} is the type name", + }, + "uic.Form.typeObject": { + defaultMessage: "{label} is not a valid {type}", + description: + "Validation error for a value that is not an object; {type} is the type name", + }, + "uic.Form.typeEmail": { + defaultMessage: "{label} is not a valid {type}", + description: + "Validation error for an invalid email address; {type} is the type name", + }, + "uic.Form.typeUrl": { + defaultMessage: "{label} is not a valid {type}", + description: "Validation error for an invalid URL; {type} is the type name", + }, + "uic.Form.stringLen": { + defaultMessage: "{label} must be {len} characters", + description: "Validation error for text of the wrong exact length", + }, + "uic.Form.stringMin": { + defaultMessage: "{label} must be at least {min} characters", + description: "Validation error for text shorter than the minimum", + }, + "uic.Form.stringMax": { + defaultMessage: "{label} must be up to {max} characters", + description: "Validation error for text longer than the maximum", + }, + "uic.Form.stringRange": { + defaultMessage: "{label} must be between {min}-{max} characters", + description: "Validation error for text outside a length range", + }, + "uic.Form.numberLen": { + defaultMessage: "{label} must be equal to {len}", + description: "Validation error for a number that must equal a value", + }, + "uic.Form.numberMin": { + defaultMessage: "{label} must be minimum {min}", + description: "Validation error for a number below the minimum", + }, + "uic.Form.numberMax": { + defaultMessage: "{label} must be maximum {max}", + description: "Validation error for a number above the maximum", + }, + "uic.Form.numberRange": { + defaultMessage: "{label} must be between {min}-{max}", + description: "Validation error for a number outside a range", + }, + "uic.Form.arrayLen": { + defaultMessage: "Must be {len} {label}", + description: "Validation error for a list with the wrong number of items", + }, + "uic.Form.arrayMin": { + defaultMessage: "At least {min} {label}", + description: "Validation error for a list with too few items", + }, + "uic.Form.arrayMax": { + defaultMessage: "At most {max} {label}", + description: "Validation error for a list with too many items", + }, + "uic.Form.arrayRange": { + defaultMessage: "The amount of {label} must be between {min}-{max}", + description: "Validation error for a list whose item count is outside a range", + }, + "uic.Form.patternMismatch": { + defaultMessage: "{label} does not match the pattern {pattern}", + description: "Validation error for text that does not match a pattern", + }, +}); diff --git a/src/components/Form/Form.scrollToFirstError.test.tsx b/src/components/Form/Form.scrollToFirstError.test.tsx new file mode 100644 index 0000000..71a7122 --- /dev/null +++ b/src/components/Form/Form.scrollToFirstError.test.tsx @@ -0,0 +1,272 @@ +/** + * A failed submit lands the user on the first invalid field. + * + * - `form.submit()` scrolls and focuses; a bare `validateFields()` never does; + * - the field is found through `data-uic-field-id`, not the control's `id` + * (the test control drops its `id`, as Astryx inputs do); + * - a child that forwards nothing to the DOM is reached through its item's + * `data-uic-field-item`; + * - a `noStyle` field lands on the parent item that shows its error; + * - the switch is off until `<Form scrollToFirstError>` says otherwise; + * - the store's own `scrollToField` works on such controls. + */ +import { Form } from "./index"; +import type { FormInstance } from "./interface"; +import { act, render } from "@testing-library/react"; +import * as React from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +/** Like an Astryx input: whatever `id` comes in is replaced by its own. */ +const Input: React.FC<any> = ({ value = "", onChange, id: _ignored, ...rest }) => { + const ownId = React.useId(); + return <input id={ownId} value={value} onChange={onChange} {...rest} />; +}; + +const scrollIntoView = vi.fn(); + +/** The element that was scrolled. */ +function scrolled(): HTMLElement | undefined { + return scrollIntoView.mock.instances[0] as HTMLElement | undefined; +} + +/** The item the engine scrolled, named by the first field it wraps. */ +function scrolledField(): string | null | undefined { + return scrolled() + ?.querySelector("[data-uic-field-id]") + ?.getAttribute("data-uic-field-id"); +} + +function controlOf(fieldId: string): HTMLInputElement { + return document.querySelector<HTMLInputElement>(`[data-uic-field-id="${fieldId}"]`)!; +} + +beforeEach(() => { + scrollIntoView.mockClear(); + // jsdom implements no scrolling at all. + Element.prototype.scrollIntoView = scrollIntoView; +}); + +interface Props { + formRef: (form: FormInstance) => void; + scrollToFirstError?: boolean; +} + +const TestForm: React.FC<Props> = ({ formRef, scrollToFirstError }) => { + const [form] = Form.useForm(); + formRef(form); + + return ( + <Form form={form} scrollToFirstError={scrollToFirstError}> + <Form.Item name="early" label="Early" rules={[{ required: true }]}> + <Input /> + </Form.Item> + <Form.Item name="late" label="Late" rules={[{ required: true }]}> + <Input /> + </Form.Item> + </Form> + ); +}; + +/** A `noStyle` field inside a layout item — the shape that has no wrapper. */ +const FormWithNoStyle: React.FC<Pick<Props, "formRef">> = ({ formRef }) => { + const [form] = Form.useForm(); + formRef(form); + + return ( + <Form form={form} scrollToFirstError> + <Form.Item label="Group"> + <Form.Item noStyle name="inner" rules={[{ required: true }]}> + <Input /> + </Form.Item> + </Form.Item> + </Form> + ); +}; + +/** Like Astryx Switch / SegmentedControl: `rest` lands on a wrapper div. */ +const WrapperInput: React.FC<any> = ({ + value = "", + onChange, + id: _ignored, + ...rest +}) => ( + <div {...rest}> + <input value={value} onChange={onChange} /> + </div> +); + +const FormWithWrapperControl: React.FC<Pick<Props, "formRef">> = ({ formRef }) => { + const [form] = Form.useForm(); + formRef(form); + + return ( + <Form form={form} scrollToFirstError> + <Form.Item name="early" label="Early" rules={[{ required: true }]}> + <WrapperInput /> + </Form.Item> + </Form> + ); +}; + +/** A named form: the DOM id gets the `name` prefix, the handle must not. */ +const NamedForm: React.FC<Pick<Props, "formRef">> = ({ formRef }) => { + const [form] = Form.useForm(); + formRef(form); + + return ( + <Form form={form} name="signup" scrollToFirstError> + <Form.Item name="early" label="Early" rules={[{ required: true }]}> + <Input /> + </Form.Item> + </Form> + ); +}; + +/** A child that forwards nothing to the DOM — Astryx RadioList's shape. */ +const OpaqueInput: React.FC<any> = ({ value = "", onChange }) => ( + <input value={value} onChange={onChange} /> +); + +const FormWithOpaqueControl: React.FC<Pick<Props, "formRef">> = ({ formRef }) => { + const [form] = Form.useForm(); + formRef(form); + + return ( + <Form form={form} scrollToFirstError> + <Form.Item name="early" label="Early" rules={[{ required: true }]}> + <OpaqueInput /> + </Form.Item> + </Form> + ); +}; + +async function submit(form: FormInstance) { + await act(async () => { + form.submit(); + }); +} + +describe("scroll to the first invalid field", () => { + it("scrolls and focuses on a failed submit, without the control’s id", async () => { + let form!: FormInstance; + render(<TestForm formRef={(f) => (form = f)} scrollToFirstError />); + // The control did not keep the id `Form.Item` gave it. + expect(document.getElementById("early")).toBeNull(); + + await submit(form); + + expect(scrolled()?.matches(".uic-form-item")).toBe(true); + expect(scrolledField()).toBe("early"); + expect(document.activeElement).toBe(controlOf("early")); + }); + + it("does not scroll for a bare `validateFields()` — validating is not submitting", async () => { + let form!: FormInstance; + render(<TestForm formRef={(f) => (form = f)} scrollToFirstError />); + + await act(async () => { + await form.validateFields().catch(() => undefined); + }); + + expect(scrollIntoView).not.toHaveBeenCalled(); + }); + + it("stays off when the form does not ask — antd’s default", async () => { + let form!: FormInstance; + render(<TestForm formRef={(f) => (form = f)} />); + + await submit(form); + + expect(scrollIntoView).not.toHaveBeenCalled(); + }); + + it("reaches a `noStyle` field through the parent item that shows its error", async () => { + let form!: FormInstance; + render(<FormWithNoStyle formRef={(f) => (form = f)} />); + + await submit(form); + + expect(scrolled()?.matches(".uic-form-item")).toBe(true); + expect(scrolled()?.textContent).toContain("Group"); + expect(document.activeElement).toBe(controlOf("inner")); + }); + + it("asks for `nearest`, so an already-visible field is left alone", async () => { + let form!: FormInstance; + render(<TestForm formRef={(f) => (form = f)} scrollToFirstError />); + + await submit(form); + + expect(scrollIntoView).toHaveBeenCalledWith( + expect.objectContaining({ block: "nearest" }), + ); + }); + + it("leaves focus alone when the form says `focus: false`", async () => { + let form!: FormInstance; + render( + <TestForm + formRef={(f) => (form = f)} + scrollToFirstError={{ focus: false } as any} + />, + ); + const before = document.activeElement; + + await submit(form); + + expect(scrolledField()).toBe("early"); + expect(document.activeElement).toBe(before); + }); + + it("focuses the control inside a wrapper that carries the handle", async () => { + let form!: FormInstance; + render(<FormWithWrapperControl formRef={(f) => (form = f)} />); + + await submit(form); + + expect(scrolledField()).toBe("early"); + expect(document.activeElement).toBe(controlOf("early").querySelector("input")); + }); + + it("finds the field in a named form, whose DOM id is prefixed", async () => { + let form!: FormInstance; + render(<NamedForm formRef={(f) => (form = f)} />); + + await submit(form); + + expect(scrolledField()).toBe("early"); + expect(document.activeElement).toBe(controlOf("early")); + }); + + it("falls back to the item wrapper for a child that forwards nothing", async () => { + let form!: FormInstance; + render(<FormWithOpaqueControl formRef={(f) => (form = f)} />); + expect(document.querySelector("[data-uic-field-id]")).toBeNull(); + + await submit(form); + + expect(scrolled()?.matches(".uic-form-item")).toBe(true); + expect(scrolled()?.getAttribute("data-uic-field-item")).toBe("early"); + expect(document.activeElement).toBe(scrolled()?.querySelector("input")); + }); + + it("does not scroll when the submit succeeds", async () => { + let form!: FormInstance; + render(<TestForm formRef={(f) => (form = f)} scrollToFirstError />); + act(() => form.setFieldsValue({ early: "a", late: "b" })); + + await submit(form); + + expect(scrollIntoView).not.toHaveBeenCalled(); + }); + + it("lets the store’s `scrollToField` find an id-dropping control too", async () => { + let form!: FormInstance; + render(<TestForm formRef={(f) => (form = f)} />); + + act(() => form.scrollToField("late", { focus: true })); + + expect(scrolledField()).toBe("late"); + expect(document.activeElement).toBe(controlOf("late")); + }); +}); diff --git a/src/components/Form/Form.tsx b/src/components/Form/Form.tsx new file mode 100644 index 0000000..8d6cc75 --- /dev/null +++ b/src/components/Form/Form.tsx @@ -0,0 +1,352 @@ +/** + * `<Form>`: store wiring, contexts and submit/reset plumbing (rc-field-form's + * `<Form>`) plus layout, `requiredMark`, `disabled` and `scrollToFirstError` + * (antd's `<Form>`). + * + * `layout` defaults to `'horizontal'`, as in antd: a form that states no + * layout but passes a `labelCol` span only makes sense horizontally. + */ +import useForm, { FormStore } from "./FormStore"; +import { + FieldContext, + FormConfigContext, + FormItemLayoutContext, + FormProviderContext, + HOOK_MARK, + ListContext, + type FormItemCol, + type FormLayout, + type FormProviderContextValue, + type FormSize, + type RequiredMark, +} from "./context"; +import type { + Callbacks, + FieldData, + FormInstance, + InternalFormInstance, + ValidateErrorEntity, + ValidateMessages, +} from "./interface"; +import { useFormValidateMessages } from "./FormConfigProvider"; +import { mergeValidateMessages } from "./messages"; +import type { Store } from "./namePath"; +import * as React from "react"; + +export interface FormProps<Values = any> extends Omit< + React.FormHTMLAttributes<HTMLFormElement>, + "onSubmit" | "children" | "onChange" | "onReset" +> { + form?: FormInstance<Values>; + name?: string; + initialValues?: Store; + /** `false` drops unmounted fields' values; the default `true` keeps them. */ + preserve?: boolean; + layout?: FormLayout; + requiredMark?: RequiredMark; + disabled?: boolean; + size?: FormSize; + labelCol?: FormItemCol; + wrapperCol?: FormItemCol; + colon?: boolean; + labelAlign?: "left" | "right"; + labelWrap?: boolean; + validateMessages?: ValidateMessages; + validateTrigger?: string | string[] | false; + scrollToFirstError?: boolean | Record<string, unknown>; + clearOnDestroy?: boolean; + onValuesChange?: Callbacks<Values>["onValuesChange"]; + onFieldsChange?: Callbacks<Values>["onFieldsChange"]; + onFinish?: Callbacks<Values>["onFinish"]; + onFinishFailed?: Callbacks<Values>["onFinishFailed"]; + /** `false` renders no wrapper element at all (context only). */ + component?: React.ComponentType<any> | string | false; + children?: React.ReactNode; +} + +/** + * What `<Form ref>` hands back. `nativeElement` is OPTIONAL on purpose: call + * sites declare `useRef<FormInstance>(null)`, and a required extra member + * would make that ref type unassignable to the element's `ref` prop. + */ +export type FormRef<Values = any> = FormInstance<Values> & { + nativeElement?: HTMLElement | null; +}; + +const InternalForm = <Values,>( + props: FormProps<Values>, + ref: React.Ref<FormRef<Values>>, +) => { + const { + name, + initialValues, + form, + preserve, + children, + component: Component = "form", + validateMessages, + validateTrigger = "onChange", + onValuesChange, + onFieldsChange, + onFinish, + onFinishFailed, + clearOnDestroy, + layout = "horizontal", + requiredMark, + disabled, + scrollToFirstError, + // Visual props antd consumed through its stylesheet + `FormContext`. They + // are published on the layout context (never forwarded onto the DOM node, + // where React would warn) and the visual shell lays out from them. + size, + labelCol, + wrapperCol, + colon, + labelAlign, + labelWrap, + ...restProps + } = props; + + const nativeElementRef = React.useRef<HTMLElement | null>(null); + const formProviderContext = React.useContext(FormProviderContext); + const formConfig = React.useContext(FormConfigContext); + const localizedMessages = useFormValidateMessages(); + + const [formInstance] = useForm(form as InternalFormInstance | undefined); + const hooks = formInstance.getInternalHooks(HOOK_MARK)!; + const { + useSubscribe, + setInitialValues, + setCallbacks, + setValidateMessages, + setPreserve, + destroyForm, + } = hooks; + + React.useImperativeHandle( + ref, + () => + ({ + ...formInstance, + nativeElement: nativeElementRef.current, + }) as FormRef<Values>, + ); + + React.useEffect(() => { + formProviderContext.registerForm(name, formInstance); + return () => { + formProviderContext.unregisterForm(name); + }; + }, [formProviderContext, formInstance, name]); + + // Templates: the catalog in the active locale < app config < Form.Provider + // < this form's own prop. + setValidateMessages( + mergeValidateMessages( + localizedMessages, + formConfig.validateMessages, + formProviderContext.validateMessages, + validateMessages, + ), + ); + + setCallbacks({ + onValuesChange, + onFieldsChange: (changedFields: FieldData[], ...rest) => { + // Fires for PROGRAMMATIC changes too, unlike `onValuesChange` — which + // is exactly why `Form.Provider onFormChange` can observe + // `setFieldValue`. + formProviderContext.triggerFormChange(name, changedFields); + onFieldsChange?.(changedFields, ...(rest as [FieldData[]])); + }, + onFinish: (values: any) => { + formProviderContext.triggerFormFinish(name, values); + onFinish?.(values); + }, + onFinishFailed: (errorInfo: ValidateErrorEntity) => { + if (scrollToFirstError && errorInfo.errorFields.length) { + const options = + typeof scrollToFirstError === "object" ? scrollToFirstError : {}; + formInstance.scrollToField(errorInfo.errorFields[0]!.name, { + focus: true, + ...options, + }); + } + onFinishFailed?.(errorInfo); + }, + }); + setPreserve(preserve); + + // Seed the store — exactly once, in `useState`'s lazy initialiser. + React.useState(() => { + setInitialValues(initialValues, true); + return null; + }); + // Then on EVERY render, refresh what `initialValues` currently is without + // re-seeding: a changed + // `initialValues` prop must not overwrite what the user has typed, but it + // IS what a later `resetFields()` restores. + setInitialValues(initialValues, false); + + React.useEffect( + () => () => destroyForm(clearOnDestroy), + // eslint-disable-next-line react-hooks/exhaustive-deps + [], + ); + + useSubscribe(true); + + const formContextValue = React.useMemo( + () => ({ ...formInstance, validateTrigger }) as InternalFormInstance, + [formInstance, validateTrigger], + ); + + const layoutContextValue = React.useMemo( + () => ({ + form: formInstance as FormInstance, + layout, + requiredMark: requiredMark ?? formConfig.requiredMark, + disabled, + name, + size, + colon, + labelAlign, + labelCol, + wrapperCol, + labelWrap, + }), + [ + formInstance, + layout, + requiredMark, + formConfig.requiredMark, + disabled, + name, + size, + colon, + labelAlign, + labelCol, + wrapperCol, + labelWrap, + ], + ); + + const wrapperNode = ( + <ListContext.Provider value={null}> + <FormItemLayoutContext.Provider value={layoutContextValue}> + <FieldContext.Provider value={formContextValue}> + {children} + </FieldContext.Provider> + </FormItemLayoutContext.Provider> + </ListContext.Provider> + ); + + if (Component === false) { + return wrapperNode; + } + + const Wrapper = Component as any; + return ( + <Wrapper + {...restProps} + // An inline form is `display: flex; flex-wrap: wrap` on the FORM, which + // no per-item style can express (antd's `.ant-form-inline`). + className={["uic-form", restProps.className].filter(Boolean).join(" ")} + data-layout={layout} + data-size={size} + ref={nativeElementRef} + onSubmit={(event: React.FormEvent) => { + event.preventDefault(); + event.stopPropagation(); + formInstance.submit(); + }} + onReset={(event: React.FormEvent) => { + event.preventDefault(); + formInstance.resetFields(); + }} + > + {wrapperNode} + </Wrapper> + ); +}; + +/** + * The ref is declared as `Ref<FormInstance<Values>>`, not `Ref<FormRef<…>>`: + * call sites write `useRef<FormInstance<MyValues>>(null)`, and inference + * through the plain generic recovers `Values` from that, where an + * intersection type does not. The runtime object still carries + * `nativeElement`, which `FormRef` describes for anyone who wants it. + */ +const Form = React.forwardRef(InternalForm) as <Values = any>( + props: FormProps<Values> & { ref?: React.Ref<FormInstance<Values>> }, +) => React.ReactElement; + +// ============================== Form.Provider =============================== + +export interface FormProviderProps { + validateMessages?: ValidateMessages; + onFormChange?: ( + name: string | undefined, + info: { changedFields: FieldData[]; forms: Record<string, FormInstance> }, + ) => void; + onFormFinish?: ( + name: string | undefined, + info: { values: Store; forms: Record<string, FormInstance> }, + ) => void; + children?: React.ReactNode; +} + +/** + * Cross-form listener. `onFormChange` is wired to `onFieldsChange`, NOT to + * `onValuesChange`, so it observes programmatic mutations (`setFieldValue`, + * `setFieldsValue`, `setFields`) as well as user edits. + */ +export const FormProvider: React.FC<FormProviderProps> = ({ + validateMessages, + onFormChange, + onFormFinish, + children, +}) => { + const parent = React.useContext(FormProviderContext); + const formsRef = React.useRef<Record<string, FormInstance>>({}); + + const value: FormProviderContextValue = { + ...parent, + validateMessages: { + ...parent.validateMessages, + ...validateMessages, + }, + // Unnamed forms fire too, with `name` undefined, as in rc-field-form. + triggerFormChange: (formName, changedFields) => { + onFormChange?.(formName, { changedFields, forms: formsRef.current }); + parent.triggerFormChange(formName, changedFields); + }, + triggerFormFinish: (formName, values) => { + onFormFinish?.(formName, { values, forms: formsRef.current }); + parent.triggerFormFinish(formName, values); + }, + registerForm: (formName, formInstance) => { + if (formName) { + formsRef.current = { ...formsRef.current, [formName]: formInstance }; + } + parent.registerForm(formName, formInstance); + }, + unregisterForm: (formName) => { + if (formName) { + const newForms = { ...formsRef.current }; + delete newForms[formName]; + formsRef.current = newForms; + } + parent.unregisterForm(formName); + }, + }; + + return ( + <FormProviderContext.Provider value={value}> + {children} + </FormProviderContext.Provider> + ); +}; + +export { useForm, FormStore }; +export default Form; diff --git a/src/components/Form/FormConfigProvider.test.tsx b/src/components/Form/FormConfigProvider.test.tsx new file mode 100644 index 0000000..252e131 --- /dev/null +++ b/src/components/Form/FormConfigProvider.test.tsx @@ -0,0 +1,127 @@ +/** + * Validation messages come from ui-common's catalog in the active Astryx + * locale, with no provider at the call site; `FormConfigProvider` and the + * form's own `validateMessages` win over them. + */ +import { act, render, screen } from "@testing-library/react"; +import * as React from "react"; +import { describe, expect, it } from "vitest"; +import { InternationalizationProvider } from "@astryxdesign/core/i18n"; + +import { uiCommonMessages } from "../../i18n/messages"; +import { FormConfigProvider } from "./FormConfigProvider"; +import { Form } from "./index"; +import type { FormInstance } from "./interface"; + +const Input: React.FC<any> = ({ value = "", onChange, ...rest }) => ( + <input value={value} onChange={onChange} {...rest} /> +); + +function withLocale(locale: string | undefined, node: React.ReactElement) { + return locale ? ( + <InternationalizationProvider locale={locale} messages={uiCommonMessages}> + {node} + </InternationalizationProvider> + ) : ( + node + ); +} + +/** Validate a one-field form and return the messages it produced. */ +async function messagesFor( + field: React.ReactElement, + options: { locale?: string; validateMessages?: any; initialValues?: any } = {}, +) { + let form: FormInstance | undefined; + const Harness = () => { + const [f] = Form.useForm(); + form = f; + return ( + <FormConfigProvider validateMessages={options.validateMessages}> + <Form form={f} initialValues={options.initialValues}> + {field} + </Form> + </FormConfigProvider> + ); + }; + render(withLocale(options.locale, <Harness />)); + let errors: string[] = []; + await act(async () => { + await form!.validateFields().catch((info: any) => { + errors = info.errorFields.flatMap((f: any) => f.errors); + }); + }); + return errors; +} + +const requiredName = ( + <Form.Item name="name" label="Name" rules={[{ required: true }]}> + <Input /> + </Form.Item> +); + +describe("validation messages", () => { + it("uses the catalog's English with no provider at all", async () => { + expect(await messagesFor(requiredName)).toEqual(["Please enter Name"]); + }); + + it.each([ + ["ko-KR", "Name 값을 입력해 주세요"], + ["ja-JP", "Nameを入力してください"], + ["de-DE", "Bitte geben Sie Name an"], + ["zh-CN", "请输入Name"], + ])("follows the Astryx locale into %s", async (locale, expected) => { + expect(await messagesFor(requiredName, { locale })).toEqual([expected]); + }); + + it("interpolates the rule's values inside a translated template", async () => { + const errors = await messagesFor( + <Form.Item name="nickname" label="Nickname" rules={[{ type: "string", max: 3 }]}> + <Input /> + </Form.Item>, + { locale: "ko-KR", initialValues: { nickname: "abcdef" } }, + ); + expect(errors).toEqual(["Nickname 3글자 이하여야 합니다"]); + }); + + it("lets FormConfigProvider's validateMessages win over the catalog", async () => { + const errors = await messagesFor(requiredName, { + locale: "ko-KR", + validateMessages: { required: "CUSTOM ${label}" }, + }); + expect(errors).toEqual(["CUSTOM Name"]); + }); + + it("falls back to English for a locale ui-common does not translate", async () => { + expect(await messagesFor(requiredName, { locale: "sw-KE" })).toEqual([ + "Please enter Name", + ]); + }); + + it("localizes the requiredMark='optional' suffix", () => { + render( + withLocale( + "ko-KR", + <Form requiredMark="optional"> + <Form.Item name="nick" label="Nick"> + <Input /> + </Form.Item> + </Form>, + ), + ); + expect(screen.getByText("(선택사항)")).toBeInTheDocument(); + }); + + it("lets FormConfigProvider's optionalLabel win", () => { + render( + <FormConfigProvider optionalLabel="[opt]"> + <Form requiredMark="optional"> + <Form.Item name="nick" label="Nick"> + <Input /> + </Form.Item> + </Form> + </FormConfigProvider>, + ); + expect(screen.getByText("[opt]")).toBeInTheDocument(); + }); +}); diff --git a/src/components/Form/FormConfigProvider.tsx b/src/components/Form/FormConfigProvider.tsx new file mode 100644 index 0000000..a8668db --- /dev/null +++ b/src/components/Form/FormConfigProvider.tsx @@ -0,0 +1,47 @@ +/** + * App-level form configuration: antd's `<ConfigProvider form={{ + * validateMessages, requiredMark }}>` for the engine. A nested provider + * inherits what it does not set. + * + * Validation messages need no provider: every `<Form>` resolves them from + * ui-common's catalog in the locale of the nearest Astryx + * `InternationalizationProvider`. `validateMessages` here wins over that. + */ +import * as React from "react"; + +import { useUicTranslator } from "../../i18n/useUicTranslator"; +import { FormConfigContext, type FormConfig, type RequiredMark } from "./context"; +import type { ValidateMessages } from "./interface"; +import { buildValidateMessages } from "./messages"; + +/** The validate-message table in the active locale, from ui-common's catalog. */ +export function useFormValidateMessages(): ValidateMessages { + const t = useUicTranslator(); + return React.useMemo(() => buildValidateMessages(t), [t]); +} + +/** The `requiredMark="optional"` suffix in the active locale. */ +export function useFormOptionalLabel(): string { + const t = useUicTranslator(); + return t("uic.Form.optional"); +} + +export const FormConfigProvider: React.FC< + FormConfig & { children?: React.ReactNode } +> = ({ children, validateMessages, requiredMark, optionalLabel }) => { + const parent = React.useContext(FormConfigContext); + const value = React.useMemo( + () => ({ + ...parent, + ...(validateMessages !== undefined ? { validateMessages } : null), + ...(requiredMark !== undefined ? { requiredMark } : null), + ...(optionalLabel !== undefined ? { optionalLabel } : null), + }), + [parent, validateMessages, requiredMark, optionalLabel], + ); + return ( + <FormConfigContext.Provider value={value}>{children}</FormConfigContext.Provider> + ); +}; + +export type { FormConfig, RequiredMark }; diff --git a/src/components/Form/FormItem.tsx b/src/components/Form/FormItem.tsx new file mode 100644 index 0000000..278ac3a --- /dev/null +++ b/src/components/Form/FormItem.tsx @@ -0,0 +1,531 @@ +/** + * `Form.Item`: the visual shell (`FormItemVisual`) bound to a `Field`. + * + * Four behaviours are load-bearing: + * + * 1. `required` is not derived from `rules` naively. Function rules + * (`({ getFieldValue }) => ({ required: … })`) are evaluated against the + * live form to decide whether the marker shows; a `warningOnly` required + * rule does not show it. + * 2. Layout-only items aggregate their children's errors. An item with no + * `name` wrapping `noStyle` children collects their metas through + * `NoStyleItemContext`; without it their messages disappear. + * 3. `help={false}` opts out of bubbling, so a nested item that renders its + * own help does not report into an ancestor as well. + * 4. `noStyle` is a pure passthrough: state only, no shell. + */ +import Field, { type FieldProps } from "./Field"; +import FormItemVisual from "./FormItemVisual"; +import { + FieldContext, + FormConfigContext, + FormItemInputContext, + FormItemLayoutContext, + ListContext, + NoStyleItemContext, + type FormItemCol, + type FormItemStatusContextValue, + type FormLayout, + type RequiredMark, +} from "./context"; +import type { + InternalFormInstance, + Meta, + Rule, + RuleObject, + StoreValue, +} from "./interface"; +import { toArray, type InternalNamePath, type NamePath } from "./namePath"; +import * as React from "react"; + +import { useUicTranslator } from "../../i18n/useUicTranslator"; + +const NAME_SPLIT = "__SPLIT__"; + +/** antd's id scheme: `formName_a_b`, with a guard for a reserved DOM name. */ +const ID_BLACKLIST = ["parentNode"]; +function getFieldId(namePath: InternalNamePath, formName?: string): string | undefined { + if (!namePath.length) return undefined; + const mergedId = namePath.join("_"); + if (formName) return `${formName}_${mergedId}`; + return ID_BLACKLIST.includes(mergedId) ? `form_item_${mergedId}` : mergedId; +} + +function genEmptyMeta(): Meta { + return { + errors: [], + warnings: [], + touched: false, + validating: false, + name: [], + validated: false, + }; +} + +function getStatus( + errors: React.ReactNode[], + warnings: React.ReactNode[], + meta: Meta, + hasFeedback?: boolean, + validateStatus?: FormItemStatusContextValue["status"], +): FormItemStatusContextValue["status"] { + if (validateStatus !== undefined) return validateStatus; + if (meta.validating) return "validating"; + if (errors.length) return "error"; + if (warnings.length) return "warning"; + if (meta.touched || (hasFeedback && meta.validated)) return "success"; + return ""; +} + +export interface FormItemProps<Values = any> extends Omit< + FieldProps, + "children" | "name" | "onMetaChange" | "isList" +> { + name?: NamePath; + label?: React.ReactNode; + /** + * A node, or antd's `{ title, icon, placement, ... }` object form. `title` + * becomes the tooltip body and `icon` the trigger glyph, rendered behind a + * hover/focus target after the label. The other keys describe antd's + * overlay and are ignored. + */ + tooltip?: React.ReactNode | FormItemTooltipConfig; + extra?: React.ReactNode; + /** `false` also suppresses meta bubbling to an ancestor item. */ + help?: React.ReactNode; + /** Overrides the asterisk derived from `rules`. */ + required?: boolean; + /** Render no wrapper; bubble this field's meta to the nearest ancestor item. */ + noStyle?: boolean; + hidden?: boolean; + layout?: FormLayout; + className?: string; + style?: React.CSSProperties; + hasFeedback?: boolean; + validateStatus?: FormItemStatusContextValue["status"]; + colon?: boolean; + labelAlign?: "left" | "right"; + labelCol?: FormItemCol; + wrapperCol?: FormItemCol; + labelWrap?: boolean; + htmlFor?: string; + id?: string; + fieldKey?: React.Key | React.Key[]; + children?: React.ReactNode | ((form: InternalFormInstance) => React.ReactNode); + __values?: Values; +} + +export interface FormItemTooltipConfig { + title?: React.ReactNode; + icon?: React.ReactNode; + placement?: string; + [key: string]: unknown; +} + +const isTooltipConfig = ( + tooltip: FormItemProps["tooltip"], +): tooltip is FormItemTooltipConfig => + !!tooltip && + typeof tooltip === "object" && + !React.isValidElement(tooltip) && + "title" in tooltip; + +/** The tooltip BODY — antd's `tooltip` node, or the config object's `title`. */ +function normalizeTooltip(tooltip: FormItemProps["tooltip"]): React.ReactNode { + return isTooltipConfig(tooltip) ? tooltip.title : (tooltip as React.ReactNode); +} + +/** The tooltip TRIGGER glyph — antd's `tooltip.icon`, when supplied. */ +function tooltipIcon(tooltip: FormItemProps["tooltip"]): React.ReactNode { + return isTooltipConfig(tooltip) ? tooltip.icon : undefined; +} + +type SubMetaMap = Record< + string, + { + errors?: React.ReactNode[]; + warnings?: React.ReactNode[]; + name?: InternalNamePath; + } +>; + +const FormItem = <Values,>(props: FormItemProps<Values>) => { + const { + name, + label, + tooltip, + extra, + help, + required, + noStyle, + hidden, + layout: propsLayout, + className, + style, + hasFeedback, + validateStatus, + children, + rules, + dependencies, + shouldUpdate, + messageVariables, + trigger = "onChange", + validateTrigger, + colon, + labelAlign, + labelCol, + wrapperCol, + labelWrap, + htmlFor, + fieldKey: _fieldKey, + id: _id, + __values: _values, + ...restFieldProps + } = props; + + const fieldContext = React.useContext(FieldContext); + const { optionalLabel } = React.useContext(FormConfigContext); + const t = useUicTranslator(); + const { + layout: formLayout, + requiredMark, + name: formName, + disabled: formDisabled, + size: formSize, + colon: formColon, + labelAlign: formLabelAlign, + labelCol: formLabelCol, + wrapperCol: formWrapperCol, + labelWrap: formLabelWrap, + } = React.useContext(FormItemLayoutContext); + const notifyParentMetaChange = React.useContext(NoStyleItemContext); + const listContext = React.useContext(ListContext); + + const layout = propsLayout || formLayout; + const mergedLabelCol = labelCol ?? formLabelCol; + const mergedWrapperColProp = wrapperCol ?? formWrapperCol; + /** + * antd's `FormItemInput`: a LABEL-LESS item in a form that declares a + * `labelCol` gets that span as a wrapper OFFSET, so its control still lines + * up with the labelled rows above it. + */ + const mergedWrapperCol = + label === null && + !labelCol && + !wrapperCol && + formLabelCol?.span !== undefined && + formLabelCol.span < 24 + ? { ...mergedWrapperColProp, offset: formLabelCol.span } + : mergedWrapperColProp; + const mergedValidateTrigger = + validateTrigger !== undefined ? validateTrigger : fieldContext.validateTrigger; + + const [meta, setMeta] = React.useState<Meta>(genEmptyMeta); + const [subFieldErrors, setSubFieldErrors] = React.useState<SubMetaMap>({}); + // A removed list row no longer resolves through the key manager, so the + // path used to unpublish it has to be the one we published under. + const fieldKeyPathRef = React.useRef<InternalNamePath | null>(null); + + const onMetaChange = (nextMeta: Meta & { destroy?: boolean }) => { + const keyInfo = listContext?.getKey(nextMeta.name); + setMeta(nextMeta.destroy ? genEmptyMeta() : nextMeta); + + if (noStyle && help !== false && notifyParentMetaChange) { + let namePath: InternalNamePath = nextMeta.name; + if (!nextMeta.destroy) { + if (keyInfo !== undefined) { + const [fieldKey, restPath] = keyInfo; + namePath = [fieldKey as string | number, ...restPath]; + fieldKeyPathRef.current = namePath; + } + } else { + namePath = fieldKeyPathRef.current || namePath; + } + notifyParentMetaChange(nextMeta, namePath as React.Key[]); + } + }; + + const onSubItemMetaChange = ( + subMeta: SubMetaMap[string] & { name: InternalNamePath; destroy?: boolean }, + uniqueKeys: React.Key[], + ) => { + setSubFieldErrors((prev) => { + const clone = { ...prev }; + // name: ['user', 1] + key: [4] => 'user__SPLIT__4' — keyed by ROW + // IDENTITY, so removing a list row drops the right entry. + const mergedNameKey = [...subMeta.name.slice(0, -1), ...uniqueKeys].join( + NAME_SPLIT, + ); + if (subMeta.destroy) { + delete clone[mergedNameKey]; + } else { + clone[mergedNameKey] = subMeta; + } + return clone; + }); + }; + + const mergedErrors: React.ReactNode[] = [ + ...meta.errors, + ...Object.values(subFieldErrors).flatMap((sub) => sub.errors ?? []), + ]; + const mergedWarnings: React.ReactNode[] = [ + ...meta.warnings, + ...Object.values(subFieldErrors).flatMap((sub) => sub.warnings ?? []), + ]; + + const renderLayout = ( + baseChildren: React.ReactNode, + fieldId?: string, + isRequired?: boolean, + fieldHandle?: string, + ) => { + const status: FormItemStatusContextValue = { + status: getStatus( + mergedErrors, + mergedWarnings, + meta, + hasFeedback, + validateStatus, + ), + errors: mergedErrors, + warnings: mergedWarnings, + hasFeedback, + isFormItemInput: true, + }; + + // A `noStyle` item contributes state only. Its errors were already sent + // upward through `notifyParentMetaChange`. + if (noStyle && !hidden) { + return ( + <FormItemInputContext.Provider value={status}> + {baseChildren} + </FormItemInputContext.Provider> + ); + } + + // antd keeps `-required` on the label in every case and hides the GLYPH + // for `false` / `'optional'` / a function mark, because the label itself + // then carries the hint. Mirrored so `.uic-form-item__label--required` + // stays a stable anchor while the asterisk obeys the same rule. + const requiredMarkType = + requiredMark === false + ? ("hidden" as const) + : requiredMark === "optional" || typeof requiredMark === "function" + ? ("optional" as const) + : undefined; + // An explicit `required` wins over the rules-derived value, and it is the + // ONLY source on a layout-only item (no `name`, so no rules to derive + // from). `BulkEditFormItem` renders exactly that shape — it drops + // `name` from the item and drives the marker by hand. + const mergedRequired = required !== undefined ? required : !!isRequired; + const mergedLabel = applyRequiredMark( + label, + mergedRequired, + requiredMark, + optionalLabel ?? t("uic.Form.optional"), + ); + + return ( + <FormItemInputContext.Provider value={status}> + <FormItemVisual + label={mergedLabel} + labelTitle={typeof label === "string" ? label : undefined} + tooltip={normalizeTooltip(tooltip)} + tooltipIcon={tooltipIcon(tooltip)} + extra={extra} + help={help} + required={mergedRequired} + requiredMarkType={requiredMarkType} + layout={layout} + size={formSize} + colon={colon ?? formColon} + labelAlign={labelAlign ?? formLabelAlign} + labelCol={mergedLabelCol} + wrapperCol={mergedWrapperCol} + labelWrap={labelWrap ?? formLabelWrap} + status={status.status} + hasFeedback={hasFeedback} + className={className} + style={style} + hidden={hidden} + fieldId={fieldId} + fieldHandle={fieldHandle} + htmlFor={htmlFor} + errors={mergedErrors} + warnings={mergedWarnings} + > + <NoStyleItemContext.Provider value={onSubItemMetaChange}> + {baseChildren} + </NoStyleItemContext.Provider> + </FormItemVisual> + </FormItemInputContext.Provider> + ); + }; + + const hasName = name !== undefined && name !== null; + const isRenderProps = typeof children === "function"; + + // Pure layout item: no field is registered, but the shell still renders and + // still aggregates the `noStyle` children nested inside it. + if (!hasName && !isRenderProps && !dependencies) { + return renderLayout(children as React.ReactNode); + } + + // `${label}` in a validate message resolves to the item's label when it is + // a plain string, else to its name — matching antd's `messageVariables`. + let variables: Record<string, string> = {}; + if (typeof label === "string") { + variables.label = label; + } else if (name) { + variables.label = String(name); + } + if (messageVariables) { + variables = { ...variables, ...messageVariables }; + } + + return ( + <Field + {...restFieldProps} + name={name} + rules={rules} + dependencies={dependencies} + shouldUpdate={shouldUpdate} + messageVariables={variables} + trigger={trigger} + validateTrigger={mergedValidateTrigger} + onMetaChange={onMetaChange} + > + {(control, renderMeta, context) => { + const mergedName = toArray(name as any).length ? renderMeta.name : []; + const fieldId = getFieldId(mergedName, formName); + const isRequired = + required !== undefined ? required : isRuleRequired(rules, context); + + let childNode: React.ReactNode = null; + + if (isRenderProps && (shouldUpdate || dependencies) && !hasName) { + childNode = (children as (form: InternalFormInstance) => React.ReactNode)( + context, + ); + } else if (React.isValidElement(children)) { + const element = children as React.ReactElement<Record<string, any>>; + const childProps: Record<string, any> = { + ...element.props, + ...control, + }; + if (!childProps.id) { + childProps.id = fieldId; + } + // Astryx controls replace `id` with their own `useId()` but pass + // `data-*` through; this is what `FormStore.getFieldDOMNode` finds. + if (mergedName.length) { + childProps["data-uic-field-id"] = mergedName.join("_"); + } + if (formDisabled && childProps.disabled === undefined) { + childProps.disabled = true; + } + + const describedbyArr: string[] = []; + if (help || mergedErrors.length > 0) { + describedbyArr.push(`${fieldId}_help`); + } + if (extra) { + describedbyArr.push(`${fieldId}_extra`); + } + if (describedbyArr.length) { + childProps["aria-describedby"] = describedbyArr.join(" "); + } + if (mergedErrors.length > 0) { + childProps["aria-invalid"] = "true"; + } + if (isRequired) { + childProps["aria-required"] = "true"; + } + + // Keep the child's own handlers: `control[trigger]` writes to the + // store, then the author's handler runs. + const triggers = new Set<string>([ + ...toArray(trigger), + ...toArray(mergedValidateTrigger as string | string[]), + ]); + triggers.forEach((eventName) => { + childProps[eventName] = (...args: any[]) => { + control[eventName]?.(...args); + (element.props as any)[eventName]?.(...args); + }; + }); + + childNode = React.cloneElement(element, childProps); + } else { + childNode = children as React.ReactNode; + } + + return renderLayout( + childNode, + fieldId, + isRequired, + mergedName.length ? mergedName.join("_") : undefined, + ); + }} + </Field> + ); +}; + +/** + * Evaluate `rules` — INCLUDING function rules, against the live form — to + * decide whether the required marker shows. A `warningOnly` required rule + * does not count: it is advice, not a requirement. + */ +function isRuleRequired( + rules: Rule[] | undefined, + form: InternalFormInstance, +): boolean { + return !!rules?.some((rule) => { + if (typeof rule === "function") { + const resolved = rule(form) as RuleObject | undefined; + return !!resolved?.required && !resolved?.warningOnly; + } + return !!rule?.required && !rule?.warningOnly; + }); +} + +/** + * `requiredMark` decorates the LABEL. A function or `'optional'` mark replaces + * the asterisk entirely, as in antd: the label itself then carries the hint. + */ +function applyRequiredMark( + label: React.ReactNode, + required: boolean, + requiredMark: RequiredMark | undefined, + optionalLabel: React.ReactNode, +): React.ReactNode { + if (label === undefined || label === null) return label; + if (typeof requiredMark === "function") { + return requiredMark(label, { required }); + } + if (requiredMark === "optional" && !required) { + // antd renders the bare locale string and spaces it with + // `margin-inline-start: marginXXS`, so the gap survives text selection and + // copy. The engine had baked a literal leading space into the string, + // which put " (optional)" in `label.textContent` where antd has + // "(optional)". + return ( + <> + {label} + <span className="uic-form-item__optional">{optionalLabel}</span> + </> + ); + } + return label; +} + +/** Errors/warnings of the nearest enclosing `Form.Item`. */ +export const useFormItemStatus = () => { + const { status, errors = [], warnings = [] } = React.useContext(FormItemInputContext); + return { status, errors, warnings }; +}; + +export { getFieldId }; +export default FormItem; +export type { StoreValue }; diff --git a/src/components/Form/FormItemVisual.css b/src/components/Form/FormItemVisual.css new file mode 100644 index 0000000..2116aa2 --- /dev/null +++ b/src/components/Form/FormItemVisual.css @@ -0,0 +1,283 @@ +/* + * The form item shell and the inline form, laid out like antd's form + * stylesheet. Hooks a product may set: + * --uic-form-item-margin-bottom space below each item + * --uic-form-item-gap label-to-control gap in a vertical item + * --uic-form-item-description-color explain, extra, tooltip glyph and the + * "(optional)" suffix + * --uic-form-item-line-height the item's line height + */ +@layer ui-common { + /* An inline form wraps its items on one line (antd `.ant-form-inline`). */ + .uic-form[data-layout="inline"] { + display: flex; + flex-wrap: wrap; + } + + .uic-form[data-layout="inline"] > .uic-form-item, + .uic-form[data-layout="inline"] .uic-form-item[data-layout="inline"] { + flex: none; + margin-inline-end: var(--spacing-4); + } + + /* + * The type reset is load-bearing: the label is an inline-flex box inside a + * block column, so the column's line box sets the label row's height. + */ + .uic-form-item { + box-sizing: border-box; + vertical-align: top; + margin-bottom: var(--uic-form-item-margin-bottom, var(--spacing-6)); + font-size: var(--font-size-base); + line-height: var(--uic-form-item-line-height, var(--text-body-leading)); + } + + .uic-form-item[data-layout="inline"] { + margin-bottom: 0; + } + + .uic-form-item[data-hidden] { + display: none; + } + + .uic-form-item__row { + display: flex; + flex-flow: row wrap; + min-width: 0; + } + + .uic-form-item[data-layout="vertical"] .uic-form-item__row { + flex-direction: column; + } + + .uic-form-item[data-layout="inline"] .uic-form-item__row { + flex-wrap: nowrap; + } + + /* ------------------------------ label ------------------------------ */ + + /* A column that does not grow and clips its text; its width is the label's + own unless a `labelCol` span sets it. */ + .uic-form-item__label-col { + flex-grow: 0; + overflow: hidden; + white-space: nowrap; + text-align: end; + vertical-align: middle; + box-sizing: border-box; + max-width: 100%; + } + + .uic-form-item__label-col[data-align="left"] { + text-align: start; + } + + .uic-form-item__label-col[data-wrap] { + overflow: unset; + line-height: var(--uic-form-item-line-height, var(--text-body-leading)); + white-space: unset; + } + + .uic-form-item__label-col[data-wrap] > .uic-form-item__label { + vertical-align: middle; + text-wrap: balance; + } + + .uic-form-item[data-layout="vertical"] .uic-form-item__label-col { + padding: 0 0 var(--uic-form-item-gap, var(--spacing-2)); + margin: 0; + white-space: initial; + text-align: start; + } + + .uic-form-item[data-layout="inline"] .uic-form-item__label-col { + flex: none; + } + + /* A horizontal label is as tall as the control beside it. */ + .uic-form-item__label { + position: relative; + display: inline-flex; + align-items: center; + max-width: 100%; + height: var(--size-element-md); + color: var(--color-text-primary); + font-size: var(--font-size-base); + line-height: var(--uic-form-item-line-height, var(--text-body-leading)); + } + + .uic-form-item[data-layout="vertical"] .uic-form-item__label { + height: auto; + margin: 0; + } + + .uic-form-item[data-size="small"] .uic-form-item__label { + height: var(--size-element-sm); + } + + .uic-form-item[data-size="large"] .uic-form-item__label { + height: var(--size-element-lg); + } + + /* The asterisk is a pseudo-element, so the label's text is the bare label. */ + .uic-form-item__label--required::before { + display: inline-block; + margin-inline-end: var(--spacing-1); + color: var(--color-error); + font-size: var(--font-size-base); + font-family: sans-serif; + line-height: 1; + content: "*"; + } + + /* `requiredMark={false}`, `'optional'` and a function mark hide it. */ + .uic-form-item__label[data-required-mark]::before { + display: none; + } + + /* The colon: drawn in every layout, hidden (not removed) in vertical. */ + .uic-form-item__label::after { + content: ":"; + position: relative; + margin-block: 0; + margin-inline-start: var(--spacing-0-5); + margin-inline-end: var(--spacing-2); + } + + .uic-form-item__label[data-no-colon]::after { + content: "\a0"; + } + + .uic-form-item[data-layout="vertical"] .uic-form-item__label::after { + visibility: hidden; + } + + .uic-form-item__optional { + display: inline-block; + margin-inline-start: var(--spacing-1); + color: var(--uic-form-item-description-color, var(--color-text-secondary)); + } + + .uic-form-item__tooltip { + display: inline-flex; + align-items: center; + margin-inline-start: var(--spacing-1); + color: var(--uic-form-item-description-color, var(--color-text-secondary)); + cursor: help; + writing-mode: horizontal-tb; + } + + /* ----------------------------- control ----------------------------- */ + + .uic-form-item__control { + display: flex; + flex-direction: column; + flex-grow: 1; + box-sizing: border-box; + min-width: 0; + } + + /* A control with no label and no span fills the row. */ + .uic-form-item__row > .uic-form-item__control:first-child:not([data-span]) { + width: 100%; + } + + .uic-form-item:not([data-layout="vertical"]) + .uic-form-item__label-col + + .uic-form-item__control { + flex: 1 1 0; + } + + .uic-form-item[data-layout="vertical"] .uic-form-item__control { + width: 100%; + } + + .uic-form-item__control-input { + position: relative; + display: flex; + align-items: center; + min-height: var(--size-element-md); + } + + .uic-form-item[data-size="small"] .uic-form-item__control-input { + min-height: var(--size-element-sm); + } + + .uic-form-item[data-size="large"] .uic-form-item__control-input { + min-height: var(--size-element-lg); + } + + .uic-form-item__control-input-content { + flex: auto; + max-width: 100%; + } + + /* ------------------------- explain / extra ------------------------- */ + + .uic-form-item__additional { + display: flex; + flex-direction: column; + } + + .uic-form-item__explain, + .uic-form-item__extra { + clear: both; + color: var(--uic-form-item-description-color, var(--color-text-secondary)); + font-size: var(--font-size-base); + line-height: var(--uic-form-item-line-height, var(--text-body-leading)); + } + + .uic-form-item__extra { + min-height: var(--size-element-sm); + } + + .uic-form-item__explain-error { + color: var(--color-error); + } + + .uic-form-item__explain-warning { + color: var(--color-warning); + } + + /* -------------------------- feedback icon -------------------------- */ + + /* Parked where a control's own suffix would sit: inside the control's end, + vertically centred, one line tall. */ + .uic-form-item__feedback-icon { + position: absolute; + inset-inline-end: var(--spacing-3); + top: 50%; + transform: translateY(-50%); + display: inline-flex; + align-items: center; + justify-content: center; + height: calc( + var(--font-size-base) * var(--uic-form-item-line-height, var(--text-body-leading)) + ); + color: var(--color-accent); + font-size: var(--font-size-base); + pointer-events: none; + } + + .uic-form-item__feedback-icon[data-status="success"] { + color: var(--color-success); + } + + .uic-form-item__feedback-icon[data-status="warning"] { + color: var(--color-warning); + } + + .uic-form-item__feedback-icon[data-status="error"] { + color: var(--color-error); + } + + .uic-form-item__feedback-glyph[data-status="validating"] { + animation: uic-form-item-feedback-spin 1s linear infinite; + } + + @keyframes uic-form-item-feedback-spin { + to { + transform: rotate(360deg); + } + } +} diff --git a/src/components/Form/FormItemVisual.tooltip.test.tsx b/src/components/Form/FormItemVisual.tooltip.test.tsx new file mode 100644 index 0000000..44d9089 --- /dev/null +++ b/src/components/Form/FormItemVisual.tooltip.test.tsx @@ -0,0 +1,64 @@ +/** + * `Form.Item tooltip` renders behind a hover/focus target after the label, + * never inline in the label row. Asserted is the contract, not the popup: the + * help text is not in the label's own text, and a trigger reveals it. + */ +import FormItemVisual from "./FormItemVisual"; +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +describe("FormItemVisual — tooltip", () => { + it("puts the tooltip text in an overlay layer, not in the label flow", () => { + render( + <FormItemVisual label="Memory" tooltip="Computer memory is temporary."> + <input aria-label="memory" /> + </FormItemVisual>, + ); + const text = screen.getByText("Computer memory is temporary."); + // jsdom implements neither the Popover API nor CSS anchor positioning, so + // Astryx's layer sits in the tree and reads as "visible" here. What makes + // it a TOOLTIP rather than inline prose is the pair of attributes the + // browser acts on: `role="tooltip"` and `popover`, which keeps it out of + // the flow until hover/focus opens it. Before the fix the help text was a + // bare `<span>` in the label with neither. + const layer = text.closest('[role="tooltip"]'); + expect(layer).not.toBeNull(); + expect(layer?.hasAttribute("popover")).toBe(true); + }); + + it("renders a focusable trigger after the label", () => { + render( + <FormItemVisual label="Memory" tooltip="Computer memory is temporary."> + <input aria-label="memory" /> + </FormItemVisual>, + ); + const trigger = document.querySelector(".uic-form-item__tooltip"); + expect(trigger).not.toBeNull(); + expect(trigger?.getAttribute("tabindex")).toBe("0"); + // The glyph, not the prose. + expect(trigger?.textContent).toBe(""); + expect(trigger?.querySelector("svg")).not.toBeNull(); + }); + + it("renders no trigger when there is no tooltip", () => { + render( + <FormItemVisual label="Memory"> + <input aria-label="memory" /> + </FormItemVisual>, + ); + expect(document.querySelector(".uic-form-item__tooltip")).toBeNull(); + }); + + it("uses antd’s `tooltip.icon` as the trigger glyph when given", () => { + render( + <FormItemVisual + label="Memory" + tooltip="Computer memory is temporary." + tooltipIcon={<span data-testid="custom-glyph">i</span>} + > + <input aria-label="memory" /> + </FormItemVisual>, + ); + expect(screen.getByTestId("custom-glyph")).toBeInTheDocument(); + }); +}); diff --git a/src/components/Form/FormItemVisual.tsx b/src/components/Form/FormItemVisual.tsx new file mode 100644 index 0000000..91d6959 --- /dev/null +++ b/src/components/Form/FormItemVisual.tsx @@ -0,0 +1,314 @@ +/** + * The presentational shell of a form item: label, control, explain and extra, + * laid out the way antd lays out `Form.Item` (`layout`, `labelCol` / + * `wrapperCol` spans, `colon`, `labelAlign`, `labelWrap`, `size`, + * `hasFeedback`, `validateStatus`), so a form moved onto the engine keeps its + * geometry. Styles are in `FormItemVisual.css`. + * + * .uic-form-item + * .uic-form-item__row + * .uic-form-item__label-col + * label.uic-form-item__label (+ .uic-form-item__tooltip) + * .uic-form-item__control + * .uic-form-item__control-input + * .uic-form-item__control-input-content + * .uic-form-item__feedback-icon + * .uic-form-item__additional + * .uic-form-item__explain (.uic-form-item__explain-error / -warning rows) + * .uic-form-item__extra + * .uic-form-item__margin-offset + */ +import { CircleHelp } from "lucide-react"; +import * as React from "react"; +import { Tooltip } from "@astryxdesign/core/Tooltip"; + +import getFeedbackIcon from "./feedbackIcons"; +import "./FormItemVisual.css"; + +export type FormItemLayout = "vertical" | "horizontal" | "inline"; +export type FormItemSize = "small" | "middle" | "large"; +export type FormItemStatus = "success" | "warning" | "error" | "validating" | ""; + +/** antd's `Col` props, reduced to `span`, `offset`, `flex`, `className` and `style`. */ +export interface FormItemCol { + span?: number; + offset?: number; + flex?: string | number; + className?: string; + style?: React.CSSProperties; +} + +export interface FormItemVisualProps { + label?: React.ReactNode; + /** + * `<label title>`. Separate from `label` because it is taken from the + * original prop, before `requiredMark` wraps it: a function mark turns the + * label into an element and the title would disappear. + */ + labelTitle?: string; + /** The tooltip body, rendered behind a help glyph after the label. */ + tooltip?: React.ReactNode; + /** The tooltip trigger glyph. Defaults to a question mark in a circle. */ + tooltipIcon?: React.ReactNode; + extra?: React.ReactNode; + help?: React.ReactNode; + /** Renders the required marker. Independent of the `required` rule. */ + required?: boolean; + /** + * How the marker is drawn: `'optional'` and `'hidden'` hide the asterisk (a + * function or `'optional'` `requiredMark` puts the hint in the label). + */ + requiredMarkType?: "optional" | "hidden"; + layout?: FormItemLayout; + size?: FormItemSize; + colon?: boolean; + labelAlign?: "left" | "right"; + labelCol?: FormItemCol; + wrapperCol?: FormItemCol; + labelWrap?: boolean; + errors?: React.ReactNode[]; + warnings?: React.ReactNode[]; + /** Merged validation status; drives colours, the feedback icon and controls. */ + status?: FormItemStatus; + hasFeedback?: boolean; + fieldId?: string; + /** Same handle as the control's `data-uic-field-id`, for a child that forwards nothing to the DOM. */ + fieldHandle?: string; + htmlFor?: string; + className?: string; + style?: React.CSSProperties; + hidden?: boolean; + children?: React.ReactNode; +} + +const cx = (...names: (string | false | undefined)[]) => + names.filter(Boolean).join(" "); + +/** + * antd grid: `span` / `offset` are 24ths. On the label a span is a fixed + * basis; on the control it is only a max-width, because antd's horizontal + * control rule (`flex: 1 1 0`) outranks the column's basis. + */ +const GRID_MAX = 24; +const colStyle = ( + col: FormItemCol | undefined, + role: "label" | "control", +): React.CSSProperties | undefined => { + if (!col) return undefined; + const style: React.CSSProperties = { ...col.style }; + if (col.span !== undefined) { + const pct = `${(col.span / GRID_MAX) * 100}%`; + style.maxWidth = pct; + if (role === "label") style.flex = `0 0 ${pct}`; + } + if (col.flex !== undefined) { + style.flex = + typeof col.flex === "number" ? `${col.flex} ${col.flex} auto` : col.flex; + } + if (col.offset !== undefined) { + style.marginInlineStart = `${(col.offset / GRID_MAX) * 100}%`; + } + return style; +}; + +export const FormItemVisual: React.FC<FormItemVisualProps> = ({ + label, + labelTitle, + tooltip, + tooltipIcon, + extra, + help, + required, + requiredMarkType, + layout = "vertical", + size, + colon, + labelAlign, + labelCol, + wrapperCol, + labelWrap, + errors = [], + warnings = [], + status, + hasFeedback, + fieldId, + fieldHandle, + htmlFor, + className, + style, + hidden, + children, +}) => { + const hasHelp = help !== undefined && help !== null && help !== false; + // antd's trigger for the reserved-space / margin-offset pair. + const hasExplain = hasHelp || errors.length > 0 || warnings.length > 0; + + /** + * antd reserves the item's own computed `margin-bottom` inside the + * additional block while an explain block shows, and cancels it with a + * negative-margin spacer, so one line of error causes no layout jump. The + * computed value is read, so a `style.marginBottom` override still nets out. + */ + const itemRef = React.useRef<HTMLDivElement>(null); + const extraRef = React.useRef<HTMLDivElement>(null); + const [marginBottom, setMarginBottom] = React.useState<number | null>(null); + const [extraHeight, setExtraHeight] = React.useState(0); + + React.useLayoutEffect(() => { + if (hasExplain && itemRef.current) { + setMarginBottom( + Number.parseInt(getComputedStyle(itemRef.current).marginBottom, 10) || 0, + ); + } else { + setMarginBottom(null); + } + }, [hasExplain]); + + React.useLayoutEffect(() => { + setExtraHeight(extra && extraRef.current ? extraRef.current.clientHeight : 0); + }, [extra]); + + const labelNode = + label === undefined || label === null ? null : ( + <div + className={cx("uic-form-item__label-col", labelCol?.className)} + data-align={labelAlign === "left" ? "left" : undefined} + data-wrap={labelWrap ? "" : undefined} + style={colStyle(labelCol, "label")} + > + <label + htmlFor={htmlFor ?? fieldId} + className={cx( + "uic-form-item__label", + required && "uic-form-item__label--required", + )} + data-required-mark={requiredMarkType} + // The colon is drawn in every layout and hidden in vertical, as in + // antd, so a vertical label measures the same. + data-no-colon={colon === false ? "" : undefined} + title={labelTitle ?? (typeof label === "string" ? label : undefined)} + > + {label} + {tooltip ? ( + <Tooltip content={tooltip}> + <span + className="uic-form-item__tooltip" + role="button" + tabIndex={0} + aria-label={typeof label === "string" ? `${label} — info` : "info"} + // The label is a `<label htmlFor>`: a click inside it would + // move focus to the control the moment the hint is tapped. + onClick={(e) => e.preventDefault()} + > + {tooltipIcon ?? <CircleHelp size="1em" />} + </span> + </Tooltip> + ) : null} + </label> + </div> + ); + + // antd's `ErrorList`: `help` replaces the error/warning list and takes the + // item's status, so `validateStatus="error" help="…"` paints the help red. + const helpKind = + status === "error" ? "error" : status === "warning" ? "warning" : null; + const explainRows: Array<{ + key: string; + node: React.ReactNode; + kind: "error" | "warning" | null; + }> = hasHelp + ? [{ key: "help", node: help, kind: helpKind }] + : [ + ...errors.map((e, i) => ({ key: `e-${i}`, node: e, kind: "error" as const })), + ...warnings.map((w, i) => ({ + key: `w-${i}`, + node: w, + kind: "warning" as const, + })), + ]; + + const explainNode = explainRows.length ? ( + <div + id={fieldId ? `${fieldId}_help` : undefined} + className="uic-form-item__explain" + > + {explainRows.map((row) => ( + <div + key={row.key} + className={row.kind ? `uic-form-item__explain-${row.kind}` : undefined} + > + {row.node} + </div> + ))} + </div> + ) : null; + + const extraNode = extra ? ( + <div + ref={extraRef} + id={fieldId ? `${fieldId}_extra` : undefined} + className="uic-form-item__extra" + > + {extra} + </div> + ) : null; + + const feedbackNode = + hasFeedback && status ? ( + <span className="uic-form-item__feedback-icon" data-status={status}> + {getFeedbackIcon(status)} + </span> + ) : null; + + const control = ( + <div + className={cx("uic-form-item__control", wrapperCol?.className)} + data-span={wrapperCol?.span !== undefined ? "" : undefined} + style={colStyle(wrapperCol, "control")} + > + <div className="uic-form-item__control-input"> + <div className="uic-form-item__control-input-content">{children}</div> + {feedbackNode} + </div> + {explainNode || extraNode ? ( + <div + className="uic-form-item__additional" + style={marginBottom ? { minHeight: marginBottom + extraHeight } : undefined} + > + {explainNode} + {extraNode} + </div> + ) : null} + </div> + ); + + return ( + <div + ref={itemRef} + className={cx("uic-form-item", className)} + data-uic-field-item={fieldHandle} + data-layout={layout} + data-size={size} + data-status={status || undefined} + data-feedback={feedbackNode ? "" : undefined} + data-hidden={hidden ? "" : undefined} + // `display: none` inline too: hiding a field is a correctness property + // and must hold even without the stylesheet. + style={hidden ? { display: "none", ...style } : style} + > + <div className="uic-form-item__row"> + {labelNode} + {control} + </div> + {marginBottom ? ( + <div + aria-hidden + className="uic-form-item__margin-offset" + style={{ marginBottom: -marginBottom }} + /> + ) : null} + </div> + ); +}; + +export default FormItemVisual; diff --git a/src/components/Form/FormStore.ts b/src/components/Form/FormStore.ts new file mode 100644 index 0000000..a252bcf --- /dev/null +++ b/src/components/Form/FormStore.ts @@ -0,0 +1,1042 @@ +/** + * The form store: the value tree, the registry of mounted fields, validation, + * and every observer channel (`onStoreChange` for fields, `registerWatch` for + * `useWatch`, `onValuesChange` / `onFieldsChange` / `onFinish`, and + * `Form.Provider`'s `onFormChange`). + * + * Contracts that are easy to get subtly wrong: + * + * - `validateFields()` rejects with exactly `{ message, values, errorFields, + * outOfDate }`. `message` is the first error of the first failing field, + * `errorFields[].name` is a path array with numeric list indices, in field + * registration order (not DOM order). + * - `warningOnly` rules land in `warnings`, never `errors`. + * - `setFieldsValue` deep-merges (arrays replaced wholesale); `setFields` + * injects meta without touching other fields; `setFieldsValue` on an + * unregistered path writes the store but creates no field. + * - Unmount honours `preserve`: `true` (the default) keeps the value; `false` + * deletes the key from `getFieldsValue()`. + * - `resetFields()` re-applies the `initialValues` prop as of the call. + */ +import { HOOK_MARK } from "./context"; +import type { + FieldEntity, + FieldData, + FieldError, + Callbacks, + DistributiveOmit, + InternalHooks, + InternalFormInstance, + Meta, + NotifyInfo, + ReducerAction, + StoreValue, + ValidateErrorEntity, + ValidateMessages, + ValidateOptions, + ScrollOptions, +} from "./interface"; +import { mergeValidateMessages } from "./messages"; +import NameMap, { + cloneByNamePathList, + containsNamePath, + getNamePath, + getValue, + matchNamePath, + merge, + setValue, + type InternalNamePath, + type NamePath, + type Store, +} from "./namePath"; +import * as React from "react"; + +/** Run `fn` on the next macrotask. Batches watcher notifications. */ +function macroTask(fn: () => void) { + if (typeof MessageChannel !== "undefined") { + const channel = new MessageChannel(); + channel.port1.onmessage = () => fn(); + channel.port2.postMessage(null); + } else { + setTimeout(fn, 0); + } +} + +/** + * Wait for the next paint before validating dependency children, so a rule + * built from `useWatch` sees the value the dependency just produced instead + * of the previous render's. + */ +export function delayFrame(): Promise<void> { + return new Promise((resolve) => { + macroTask(() => { + if (typeof requestAnimationFrame === "function") { + requestAnimationFrame(() => resolve()); + } else { + setTimeout(resolve, 0); + } + }); + }); +} + +/** + * Resolve when every promise settles; REJECT with the full result array if + * any rejected. Deliberately not `Promise.allSettled` — index alignment and + * the reject-with-all-results shape are what `validateFields` builds on. + */ +function allPromiseFinish<T>(promiseList: Promise<T>[]): Promise<T[]> { + let hasError = false; + let count = promiseList.length; + const results: T[] = []; + if (!promiseList.length) { + return Promise.resolve([]); + } + return new Promise((resolve, reject) => { + promiseList.forEach((promise, index) => { + promise + .catch((e) => { + hasError = true; + return e as T; + }) + .then((result) => { + count -= 1; + results[index] = result; + if (count > 0) { + return; + } + if (hasError) { + reject(results); + } + resolve(results); + }); + }); + }); +} + +type WatchCallBack = (values: Store, allValues: Store) => void; + +/** Batches `useWatch` notifications so a burst of updates yields one render. */ +class WatcherCenter { + private namePathList: InternalNamePath[] = []; + private taskId = 0; + private watcherList = new Set<WatchCallBack>(); + private store: FormStore; + + constructor(store: FormStore) { + this.store = store; + } + + register(callback: WatchCallBack) { + this.watcherList.add(callback); + return () => { + this.watcherList.delete(callback); + }; + } + + notify(namePath: InternalNamePath[]) { + namePath.forEach((path) => { + if (this.namePathList.every((exist) => !matchNamePath(exist, path))) { + this.namePathList.push(path); + } + }); + this.doBatch(); + } + + private doBatch() { + this.taskId += 1; + const currentId = this.taskId; + macroTask(() => { + if (currentId === this.taskId && this.watcherList.size) { + const form = this.store.getForm(); + const values = form.getFieldsValue(); + const allValues = form.getFieldsValue(true); + this.watcherList.forEach((callback) => callback(values, allValues)); + this.namePathList = []; + } + }); + } +} + +/** What `focusField` lands on when the handle sits on a wrapper. */ +const EDITABLE = + 'input:not([type="hidden"]):not([disabled]),select:not([disabled]),textarea:not([disabled]),[contenteditable="true"],[role="combobox"],[role="textbox"],[role="spinbutton"]'; + +export class FormStore { + private forceRootUpdate: () => void; + private subscribable = true; + private store: Store = {}; + private fieldEntities: FieldEntity[] = []; + private initialValues: Store = {}; + private callbacks: Callbacks = {}; + private validateMessages: ValidateMessages | null = null; + private preserve?: boolean; + private lastValidatePromise: Promise<any> | null = null; + private watcherCenter = new WatcherCenter(this); + /** Paths of `preserve: false` fields alive at the previous unmount. */ + private prevWithoutPreserves: NameMap<boolean> | null = null; + + constructor(forceRootUpdate: () => void) { + this.forceRootUpdate = forceRootUpdate; + } + + getForm = (): InternalFormInstance => + ({ + getFieldValue: this.getFieldValue, + getFieldsValue: this.getFieldsValue, + getFieldError: this.getFieldError, + getFieldWarning: this.getFieldWarning, + getFieldsError: this.getFieldsError, + isFieldsTouched: this.isFieldsTouched, + isFieldTouched: this.isFieldTouched, + isFieldValidating: this.isFieldValidating, + isFieldsValidating: this.isFieldsValidating, + resetFields: this.resetFields, + setFields: this.setFields, + setFieldValue: this.setFieldValue, + setFieldsValue: this.setFieldsValue, + validateFields: this.validateFields, + submit: this.submit, + scrollToField: this.scrollToField, + focusField: this.focusField, + getFieldInstance: this.getFieldInstance, + _init: true, + getInternalHooks: this.getInternalHooks, + }) as unknown as InternalFormInstance; + + // ======================== Internal hooks ======================== + + getInternalHooks = (key: string): InternalHooks | null => { + if (key === HOOK_MARK) { + return { + dispatch: this.dispatch, + initEntityValue: this.initEntityValue, + registerField: this.registerField, + useSubscribe: this.useSubscribe, + setInitialValues: this.setInitialValues, + destroyForm: this.destroyForm, + setCallbacks: this.setCallbacks, + setValidateMessages: this.setValidateMessages, + getFields: this.getFields, + setPreserve: this.setPreserve, + getInitialValue: this.getInitialValue, + registerWatch: this.registerWatch, + }; + } + if (process.env.NODE_ENV !== "production") { + console.warn("[Form] `getInternalHooks` is internal usage."); + } + return null; + }; + + private useSubscribe = (subscribable: boolean) => { + this.subscribable = subscribable; + }; + + private setCallbacks = (callbacks: Callbacks) => { + this.callbacks = callbacks; + }; + + private setValidateMessages = (validateMessages: ValidateMessages) => { + this.validateMessages = validateMessages; + }; + + private setPreserve = (preserve?: boolean) => { + this.preserve = preserve; + }; + + /** + * `init` is true only on the very first render, so later `initialValues` + * prop changes update what `resetFields()` will restore WITHOUT stomping + * the values the user already typed. + */ + private setInitialValues = (initialValues: Store | undefined, init: boolean) => { + this.initialValues = initialValues || {}; + if (init) { + let nextStore = merge(initialValues, this.store); + // Fields the previous mount dropped for `preserve: false` must come + // back from `initialValues`, not from the stale store value. + this.prevWithoutPreserves?.map(({ key: namePath }) => { + nextStore = setValue(nextStore, namePath, getValue(initialValues, namePath)); + return null; + }); + this.prevWithoutPreserves = null; + this.updateStore(nextStore); + } + }; + + private destroyForm = (clearOnDestroy?: boolean) => { + if (clearOnDestroy) { + this.updateStore({}); + } else { + const prevWithoutPreserves = new NameMap<boolean>(); + this.getFieldEntities(true).forEach((entity) => { + if (!this.isMergedPreserve(entity.isPreserve())) { + prevWithoutPreserves.set(entity.getNamePath(), true); + } + }); + this.prevWithoutPreserves = prevWithoutPreserves; + } + }; + + private getInitialValue = (namePath: InternalNamePath) => { + const initValue = getValue(this.initialValues, namePath); + // Deep-clone nested initial values so a reset cannot hand back an object + // a previous edit already mutated in place. + return namePath.length ? merge(initValue) : initValue; + }; + + private registerWatch = (callback: WatchCallBack) => + this.watcherCenter.register(callback); + + private notifyWatch = (namePath: InternalNamePath[] = []) => { + this.watcherCenter.notify(namePath); + }; + + private updateStore = (nextStore: Store) => { + this.store = nextStore; + }; + + // ============================ Fields ============================ + + private getFieldEntities = (pure = false): FieldEntity[] => { + if (!pure) { + return this.fieldEntities; + } + return this.fieldEntities.filter((field) => field.getNamePath().length); + }; + + private getFieldsMap = (pure = false) => { + const cache = new NameMap<FieldEntity>(); + this.getFieldEntities(pure).forEach((field) => { + cache.set(field.getNamePath(), field); + }); + return cache; + }; + + private getFieldEntitiesForNamePathList = ( + nameList?: NamePath[] | null, + includesSubNamePath = false, + ): (FieldEntity | { INVALIDATE_NAME_PATH: InternalNamePath })[] => { + if (!nameList) { + return this.getFieldEntities(true); + } + const cache = this.getFieldsMap(true); + if (!includesSubNamePath) { + return nameList.map((name) => { + const namePath = getNamePath(name); + return cache.get(namePath) || { INVALIDATE_NAME_PATH: namePath }; + }); + } + return nameList.flatMap<FieldEntity | { INVALIDATE_NAME_PATH: InternalNamePath }>( + (name) => { + const namePath = getNamePath(name); + const fields = cache.getAsPrefix(namePath); + if (fields.length) { + return fields; + } + return [{ INVALIDATE_NAME_PATH: namePath }]; + }, + ); + }; + + /** + * `getFieldsValue()` — only REGISTERED fields. + * `getFieldsValue(true)` — the raw store, including values with no + * mounted field. + * `getFieldsValue(['a','b'])`— those paths and everything beneath them. + */ + getFieldsValue = ( + nameList?: + NamePath[] | true | { strict?: boolean; filter?: (meta: Meta) => boolean }, + filterFunc?: (meta: Meta) => boolean, + ): Store => { + let mergedNameList: NamePath[] | true | undefined; + let mergedFilterFunc = filterFunc; + if (nameList === true || Array.isArray(nameList)) { + mergedNameList = nameList; + } else if (nameList && typeof nameList === "object") { + mergedFilterFunc = nameList.filter; + } + if (mergedNameList === true && !mergedFilterFunc) { + return this.store; + } + + const fieldEntities = this.getFieldEntitiesForNamePathList( + Array.isArray(mergedNameList) ? mergedNameList : null, + true, + ); + const filteredNameList: InternalNamePath[] = []; + const listNamePaths: InternalNamePath[] = []; + fieldEntities.forEach((entity) => { + const namePath = + (entity as { INVALIDATE_NAME_PATH?: InternalNamePath }).INVALIDATE_NAME_PATH || + (entity as FieldEntity).getNamePath(); + if ((entity as FieldEntity).isList?.()) { + // The list's own value is covered by its children; recording it here + // too would overwrite them with the pre-change array. + listNamePaths.push(namePath); + return; + } + if (!mergedFilterFunc) { + filteredNameList.push(namePath); + } else { + const meta = "getMeta" in entity ? (entity as FieldEntity).getMeta() : null; + if (meta && mergedFilterFunc(meta)) { + filteredNameList.push(namePath); + } + } + }); + + let mergedValues = cloneByNamePathList(this.store, filteredNameList); + // An empty Form.List must still appear as `[]`, not as a missing key. + listNamePaths.forEach((namePath) => { + if (!getValue(mergedValues, namePath)) { + mergedValues = setValue(mergedValues, namePath, []); + } + }); + return mergedValues; + }; + + getFieldValue = (name: NamePath): StoreValue => + getValue(this.store, getNamePath(name)); + + getFieldsError = (nameList?: NamePath[]): FieldError[] => { + const fieldEntities = this.getFieldEntitiesForNamePathList(nameList); + return fieldEntities.map((entity, index) => { + if (entity && !(entity as any).INVALIDATE_NAME_PATH) { + const field = entity as FieldEntity; + return { + name: field.getNamePath(), + errors: field.getErrors(), + warnings: field.getWarnings(), + }; + } + return { + name: getNamePath(nameList![index]), + errors: [], + warnings: [], + }; + }); + }; + + getFieldError = (name: NamePath): string[] => + this.getFieldsError([getNamePath(name)])[0]!.errors; + + getFieldWarning = (name: NamePath): string[] => + this.getFieldsError([getNamePath(name)])[0]!.warnings; + + isFieldsTouched = (...args: any[]): boolean => { + const [arg0, arg1] = args; + let namePathList: InternalNamePath[] | null; + let isAllFieldsTouched = false; + if (args.length === 0) { + namePathList = null; + } else if (args.length === 1) { + if (Array.isArray(arg0)) { + namePathList = arg0.map(getNamePath); + } else { + namePathList = null; + isAllFieldsTouched = arg0; + } + } else { + namePathList = arg0.map(getNamePath); + isAllFieldsTouched = arg1; + } + + const fieldEntities = this.getFieldEntities(true); + const isFieldTouched = (field: FieldEntity) => field.isFieldTouched(); + + if (!namePathList) { + return isAllFieldsTouched + ? fieldEntities.every((entity) => isFieldTouched(entity) || entity.isList()) + : fieldEntities.some(isFieldTouched); + } + + const map = new NameMap<FieldEntity[]>(); + namePathList.forEach((shortNamePath) => { + map.set(shortNamePath, []); + }); + fieldEntities.forEach((field) => { + const fieldNamePath = field.getNamePath(); + namePathList!.forEach((shortNamePath) => { + if (shortNamePath.every((nameUnit, i) => fieldNamePath[i] === nameUnit)) { + map.update(shortNamePath, (list) => [...(list || []), field]); + } + }); + }); + + const isNamePathListTouched = (entities: FieldEntity[]) => + entities.some(isFieldTouched); + const namePathListEntities = map.map(({ value }) => value); + return isAllFieldsTouched + ? namePathListEntities.every(isNamePathListTouched) + : namePathListEntities.some(isNamePathListTouched); + }; + + isFieldTouched = (name: NamePath) => this.isFieldsTouched([name]); + + isFieldsValidating = (nameList?: NamePath[]) => { + const fieldEntities = this.getFieldEntities(); + if (!nameList) { + return fieldEntities.some((field) => field.isFieldValidating()); + } + const namePathList = nameList.map(getNamePath); + return fieldEntities.some( + (field) => + containsNamePath(namePathList, field.getNamePath()) && + field.isFieldValidating(), + ); + }; + + isFieldValidating = (name: NamePath) => this.isFieldsValidating([name]); + + /** + * Apply per-field `initialValue` props. Form-level `initialValues` wins; + * two fields declaring `initialValue` for the same path is ambiguous and + * neither is applied. + */ + private resetWithFieldInitialValue = ( + info: { + entities?: FieldEntity[]; + namePathList?: InternalNamePath[]; + skipExist?: boolean; + } = {}, + ) => { + const cache = new NameMap<Set<{ entity: FieldEntity; value: any }>>(); + const fieldEntities = this.getFieldEntities(true); + fieldEntities.forEach((field) => { + const { initialValue } = field.props; + const namePath = field.getNamePath(); + if (initialValue !== undefined) { + const records = cache.get(namePath) || new Set(); + records.add({ entity: field, value: initialValue }); + cache.set(namePath, records); + } + }); + + const resetWithFields = (entities: FieldEntity[]) => { + entities.forEach((field) => { + const { initialValue } = field.props; + if (initialValue === undefined) return; + const namePath = field.getNamePath(); + if (this.getInitialValue(namePath) !== undefined) { + return; + } + const records = cache.get(namePath); + if (!records || records.size > 1) { + return; + } + const originValue = this.getFieldValue(namePath); + if (!field.isListField() && (!info.skipExist || originValue === undefined)) { + this.updateStore(setValue(this.store, namePath, [...records][0]!.value)); + } + }); + }; + + let requiredFieldEntities: FieldEntity[]; + if (info.entities) { + requiredFieldEntities = info.entities; + } else if (info.namePathList) { + requiredFieldEntities = []; + info.namePathList.forEach((namePath) => { + const records = cache.get(namePath); + if (records) { + requiredFieldEntities.push(...[...records].map((r) => r.entity)); + } + }); + } else { + requiredFieldEntities = fieldEntities; + } + resetWithFields(requiredFieldEntities); + }; + + resetFields = (nameList?: NamePath[]) => { + const prevStore = this.store; + if (!nameList) { + // `this.initialValues` is whatever the LAST render passed, so a form + // whose `initialValues` prop changed resets to the new values. + this.updateStore(merge(this.initialValues)); + this.resetWithFieldInitialValue(); + this.notifyObservers(prevStore, null, { type: "reset" }); + this.notifyWatch(); + return; + } + const namePathList = nameList.map(getNamePath); + namePathList.forEach((namePath) => { + this.updateStore(setValue(this.store, namePath, this.getInitialValue(namePath))); + }); + this.resetWithFieldInitialValue({ namePathList }); + this.notifyObservers(prevStore, namePathList, { type: "reset" }); + this.notifyWatch(namePathList); + }; + + setFields = (fields: FieldData[]) => { + const prevStore = this.store; + const namePathList: InternalNamePath[] = []; + fields.forEach((fieldData) => { + const { name, ...data } = fieldData; + const namePath = getNamePath(name); + namePathList.push(namePath); + if ("value" in data) { + this.updateStore(setValue(this.store, namePath, data.value)); + } + this.notifyObservers(prevStore, [namePath], { + type: "setField", + data: fieldData, + }); + }); + this.notifyWatch(namePathList); + }; + + private getFields = (): FieldData[] => + this.getFieldEntities(true).map((field) => { + const namePath = field.getNamePath(); + const meta = field.getMeta(); + const fieldData: FieldData = { + ...meta, + name: namePath, + value: this.getFieldValue(namePath), + }; + Object.defineProperty(fieldData, "originRCField", { value: true }); + return fieldData; + }); + + // =========================== Observer =========================== + + private initEntityValue = (entity: FieldEntity) => { + const { initialValue } = entity.props; + if (initialValue !== undefined) { + const namePath = entity.getNamePath(); + if (getValue(this.store, namePath) === undefined) { + this.updateStore(setValue(this.store, namePath, initialValue)); + } + } + }; + + private isMergedPreserve = (fieldPreserve?: boolean) => { + const mergedPreserve = fieldPreserve !== undefined ? fieldPreserve : this.preserve; + return mergedPreserve ?? true; + }; + + private registerField = (entity: FieldEntity) => { + this.fieldEntities.push(entity); + const namePath = entity.getNamePath(); + this.notifyWatch([namePath]); + + if (entity.props.initialValue !== undefined) { + const prevStore = this.store; + this.resetWithFieldInitialValue({ entities: [entity], skipExist: true }); + this.notifyObservers(prevStore, [namePath], { + type: "valueUpdate", + source: "internal", + }); + } + + return ( + isListField?: boolean, + preserve?: boolean, + subNamePath: InternalNamePath = [], + ) => { + this.fieldEntities = this.fieldEntities.filter((item) => item !== entity); + + if ( + !this.isMergedPreserve(preserve) && + (!isListField || subNamePath.length > 1) + ) { + const defaultValue = isListField ? undefined : this.getInitialValue(namePath); + if ( + namePath.length && + this.getFieldValue(namePath) !== defaultValue && + // Another field may still own this path (two controls, one name). + this.fieldEntities.every( + (field) => !matchNamePath(field.getNamePath(), namePath), + ) + ) { + const prevStore = this.store; + this.updateStore(setValue(prevStore, namePath, defaultValue, true)); + this.notifyObservers(prevStore, [namePath], { type: "remove" }); + this.triggerDependenciesUpdate(prevStore, namePath); + } + } + this.notifyWatch([namePath]); + }; + }; + + private dispatch = (action: ReducerAction) => { + switch (action.type) { + case "updateValue": { + this.updateValue(action.namePath, action.value); + break; + } + case "validateField": { + this.validateFields([action.namePath], { + triggerName: action.triggerName, + }); + break; + } + default: + break; + } + }; + + private notifyObservers = ( + prevStore: Store, + namePathList: InternalNamePath[] | null, + info: DistributiveOmit<NotifyInfo, "store">, + ) => { + if (this.subscribable) { + const mergedInfo = { + ...info, + store: this.getFieldsValue(true), + } as NotifyInfo; + this.getFieldEntities().forEach(({ onStoreChange }) => { + onStoreChange(prevStore, namePathList, mergedInfo); + }); + } else { + this.forceRootUpdate(); + } + }; + + /** + * Re-validate and re-render the fields that declare `namePath` in their + * `dependencies`, transitively. Only DIRTY dependents are re-validated — + * that is why a pristine field does not flash an error the moment an + * unrelated field changes. + */ + private triggerDependenciesUpdate = ( + prevStore: Store, + namePath: InternalNamePath, + ) => { + const childrenFields = this.getDependencyChildrenFields(namePath); + if (childrenFields.length) { + this.validateFields(childrenFields, { + delayFrame: true, + } as ValidateOptions); + } + this.notifyObservers(prevStore, childrenFields, { + type: "dependenciesUpdate", + relatedFields: [namePath, ...childrenFields], + }); + return childrenFields; + }; + + private updateValue = (name: NamePath, value: StoreValue) => { + const namePath = getNamePath(name); + const prevStore = this.store; + this.updateStore(setValue(this.store, namePath, value)); + this.notifyObservers(prevStore, [namePath], { + type: "valueUpdate", + source: "internal", + }); + this.notifyWatch([namePath]); + + const childrenFields = this.triggerDependenciesUpdate(prevStore, namePath); + + const { onValuesChange } = this.callbacks; + if (onValuesChange) { + const changedValues = cloneByNamePathList(this.store, [namePath]); + const allValues = this.getFieldsValue(); + onValuesChange( + changedValues, + setValue(allValues, namePath, getValue(changedValues, namePath)), + ); + } + this.triggerOnFieldsChange([namePath, ...childrenFields]); + }; + + setFieldsValue = (store: Store) => { + const prevStore = this.store; + if (store) { + // Deep merge, arrays replaced — `setFieldsValue({resource:{cpu:2}})` + // keeps the other `resource.*` keys intact. + this.updateStore(merge(this.store, store)); + } + this.notifyObservers(prevStore, null, { + type: "valueUpdate", + source: "external", + }); + this.notifyWatch(); + }; + + setFieldValue = (name: NamePath, value: StoreValue) => { + this.setFields([{ name, value, errors: [], warnings: [], touched: true }]); + }; + + private getDependencyChildrenFields = ( + rootNamePath: InternalNamePath, + ): InternalNamePath[] => { + const children = new Set<FieldEntity>(); + const childrenFields: InternalNamePath[] = []; + const dependencies2fields = new NameMap<Set<FieldEntity>>(); + + this.getFieldEntities().forEach((field) => { + const { dependencies } = field.props; + (dependencies || []).forEach((dependency) => { + dependencies2fields.update(getNamePath(dependency), (fields) => { + const next = fields || new Set<FieldEntity>(); + next.add(field); + return next; + }); + }); + }); + + const fillChildren = (namePath: InternalNamePath) => { + const fields = dependencies2fields.get(namePath) || new Set<FieldEntity>(); + fields.forEach((field) => { + if (!children.has(field)) { + children.add(field); + const fieldNamePath = field.getNamePath(); + if (field.isFieldDirty() && fieldNamePath.length) { + childrenFields.push(fieldNamePath); + fillChildren(fieldNamePath); + } + } + }); + }; + fillChildren(rootNamePath); + return childrenFields; + }; + + private triggerOnFieldsChange = ( + namePathList: InternalNamePath[], + fieldErrors?: FieldError[], + ) => { + const { onFieldsChange } = this.callbacks; + if (!onFieldsChange) return; + const fields = this.getFields(); + if (fieldErrors) { + const cache = new NameMap<string[]>(); + fieldErrors.forEach(({ name, errors }) => cache.set(name, errors)); + fields.forEach((field) => { + field.errors = cache.get(field.name as InternalNamePath) || field.errors; + }); + } + const changedFields = fields.filter(({ name }) => + containsNamePath(namePathList, name as InternalNamePath), + ); + if (changedFields.length) { + onFieldsChange(changedFields, fields); + } + }; + + // =========================== Validate =========================== + + validateFields = (arg1?: any, arg2?: any): Promise<Store> => { + let nameList: NamePath[] | undefined; + let options: ValidateOptions | undefined; + if (Array.isArray(arg1) || typeof arg1 === "string" || typeof arg2 === "string") { + nameList = arg1; + options = arg2; + } else { + options = arg1; + } + + const provideNameList = !!nameList; + const namePathList: InternalNamePath[] = provideNameList + ? nameList!.map(getNamePath) + : []; + // Resolve value excludes Form.List container paths so a list's items win. + const finalValueNamePathList = [...namePathList]; + + const promiseList: Promise<FieldError>[] = []; + const TMP_SPLIT = String(Date.now()); + const validateNamePathList = new Set<string>(); + const { recursive, dirty } = options || {}; + const mergedMessages = mergeValidateMessages(this.validateMessages); + + this.getFieldEntities(true).forEach((field) => { + const fieldNamePath = field.getNamePath(); + + if (!provideNameList) { + if ( + !field.isList() || + !namePathList.some((name) => matchNamePath(name, fieldNamePath, true)) + ) { + finalValueNamePathList.push(fieldNamePath); + } + namePathList.push(fieldNamePath); + } + + if (!field.props.rules || !field.props.rules.length) { + return; + } + if (dirty && !field.isFieldDirty()) { + return; + } + validateNamePathList.add(fieldNamePath.join(TMP_SPLIT)); + + if ( + !provideNameList || + containsNamePath(namePathList, fieldNamePath, recursive) + ) { + const promise = field.validateRules({ + ...options, + validateMessages: mergedMessages, + }); + promiseList.push( + promise + .then(() => ({ name: fieldNamePath, errors: [], warnings: [] })) + .catch( + ( + ruleErrors: { + rule: { warningOnly?: boolean }; + errors: string[]; + }[], + ) => { + const mergedErrors: string[] = []; + const mergedWarnings: string[] = []; + ruleErrors.forEach?.(({ rule: { warningOnly }, errors }) => { + if (warningOnly) { + mergedWarnings.push(...errors); + } else { + mergedErrors.push(...errors); + } + }); + if (mergedErrors.length) { + return Promise.reject({ + name: fieldNamePath, + errors: mergedErrors, + warnings: mergedWarnings, + }); + } + return { + name: fieldNamePath, + errors: mergedErrors, + warnings: mergedWarnings, + }; + }, + ), + ); + } + }); + + const summaryPromise = allPromiseFinish(promiseList); + this.lastValidatePromise = summaryPromise; + + summaryPromise + .catch((results) => results) + .then((results: FieldError[]) => { + const resultNamePathList = results.map(({ name }) => name); + this.notifyObservers(this.store, resultNamePathList, { + type: "validateFinish", + }); + this.triggerOnFieldsChange(resultNamePathList, results); + }); + + const returnPromise = summaryPromise + .then((): Promise<Store> => { + if (this.lastValidatePromise === summaryPromise) { + return Promise.resolve(this.getFieldsValue(finalValueNamePathList)); + } + return Promise.reject([]); + }) + .catch((results: FieldError[]) => { + const errorList = results.filter((result) => result && result.errors.length); + return Promise.reject<Store>({ + message: errorList[0]?.errors?.[0], + values: this.getFieldsValue(namePathList), + errorFields: errorList, + outOfDate: this.lastValidatePromise !== summaryPromise, + } satisfies ValidateErrorEntity & { message: any }); + }); + + // Swallow the internal branch so an unhandled rejection never reaches the + // console; the caller's own `.catch()` still sees it. + returnPromise.catch((e) => e); + + const triggerNamePathList = namePathList.filter((namePath) => + validateNamePathList.has(namePath.join(TMP_SPLIT)), + ); + this.triggerOnFieldsChange(triggerNamePathList); + + return returnPromise; + }; + + submit = () => { + this.validateFields() + .then((values) => { + const { onFinish } = this.callbacks; + if (onFinish) { + try { + onFinish(values); + } catch (err) { + // A throwing `onFinish` is the app's bug, not a validation + // failure — surface it instead of routing to onFinishFailed. + console.error(err); + } + } + }) + .catch((e) => { + this.callbacks.onFinishFailed?.(e); + }); + }; + + // ======================= Scroll / focus ========================= + + getFieldInstance = (name: NamePath) => this.getFieldDOMNode(name); + + scrollToField = (name: NamePath, options: ScrollOptions = {}) => { + const { focus, ...restOpt } = options; + const node = this.getFieldDOMNode(name); + if (node) { + // The item, so the label and message come along; a `noStyle` field's + // message lives on the parent item. + (node.closest<HTMLElement>(".uic-form-item") ?? node).scrollIntoView({ + block: "nearest", + ...restOpt, + }); + if (focus) { + this.focusField(name); + } + } + }; + + focusField = (name: NamePath) => { + const node = this.getFieldDOMNode(name); + if (!node) return; + // The handle may sit on a wrapper (Astryx Switch / SegmentedControl spread + // `rest` onto their root; the item wrapper is the fallback). + const target = node.matches(EDITABLE) + ? node + : (node.querySelector<HTMLElement>(EDITABLE) ?? node); + target.focus?.({ preventScroll: true }); + }; + + /** + * The control carrying `data-uic-field-id` (Astryx inputs drop the `id` + * `FormItem` gives them but keep `data-*`), else the item wrapper carrying + * `data-uic-field-item` (a child that forwards nothing to the DOM). + */ + private getFieldDOMNode = (name: NamePath): HTMLElement | undefined => { + if (typeof document === "undefined") return undefined; + // Quoted attribute value: only the quote and the backslash need escaping + // (`CSS.escape` is missing in jsdom). + const handle = `"${getNamePath(name).join("_").replace(/["\\]/g, "\\$&")}"`; + return ( + document.querySelector<HTMLElement>(`[data-uic-field-id=${handle}]`) ?? + document.querySelector<HTMLElement>(`[data-uic-field-item=${handle}]`) ?? + undefined + ); + }; +} + +/** + * Create (or adopt) a form instance. + * + * The instance lives in `useState`'s lazy initialiser rather than a ref: both + * give "construct exactly once", but a ref would have to be read AND written + * during render, which React's rules-of-refs lint rightly rejects. The + * `forceUpdate` handed to the store is the escape hatch for the one case + * field subscriptions cannot express — a `<Form>` whose children read values + * without registering a field. + */ +export default function useForm<Values = any>( + form?: InternalFormInstance, +): [InternalFormInstance] { + const [, forceUpdate] = React.useState({}); + const [ownInstance] = React.useState<InternalFormInstance>(() => + new FormStore(() => forceUpdate({})).getForm(), + ); + + return [(form ?? ownInstance) as InternalFormInstance & { __values?: Values }]; +} diff --git a/src/components/Form/List.tsx b/src/components/Form/List.tsx new file mode 100644 index 0000000..fe37ba6 --- /dev/null +++ b/src/components/Form/List.tsx @@ -0,0 +1,180 @@ +/** + * `Form.List`: repeated field groups. `move` is not implemented. + * + * Row identity is not the array index: `keyManager.keys` holds a stable id per + * row and is spliced in lockstep with the value array, so `remove(index)` + * does not move the removed row's errors onto its neighbour. + * `listContext.getKey` maps an absolute field path back to + * `[stableRowKey, restPath]`; `FormItem` uses it to key bubbled `noStyle` + * metas by row identity too. + */ +import Field from "./Field"; +import { FieldContext, ListContext, type ListContextValue } from "./context"; +import type { Meta, Rule, StoreValue } from "./interface"; +import { getNamePath, type InternalNamePath, type NamePath } from "./namePath"; +import * as React from "react"; + +export interface ListField { + name: number; + key: number; + isListField: boolean; +} + +export interface ListOperations { + add: (defaultValue?: StoreValue, insertIndex?: number) => void; + remove: (index: number | number[]) => void; +} + +export interface ListProps { + name: NamePath; + rules?: Rule[]; + validateTrigger?: string | string[] | false; + initialValue?: any[]; + isListField?: boolean; + children: ( + fields: ListField[], + operations: ListOperations, + meta: Meta, + ) => React.ReactNode; +} + +const List: React.FC<ListProps> = ({ + name, + initialValue, + children, + rules, + validateTrigger, + isListField, +}) => { + const context = React.useContext(FieldContext); + const wrapperListContext = React.useContext(ListContext); + const keyRef = React.useRef<{ keys: number[]; id: number }>({ + keys: [], + id: 0, + }); + const keyManager = keyRef.current; + + // Nested lists compose: the inner list's prefix is the outer prefix plus + // its own (relative) name — `AdminDeploymentPresetModelConfigItem` nests + // `preStartActions` inside `modelDefinition.models[N].service`. + const prefixName: InternalNamePath = React.useMemo(() => { + const parentPrefixName = getNamePath(context.prefixName) || []; + return [...parentPrefixName, ...getNamePath(name)]; + }, [context.prefixName, name]); + + const fieldContext = React.useMemo( + () => ({ ...context, prefixName }), + [context, prefixName], + ); + + const listContext: ListContextValue = React.useMemo( + () => ({ + getKey: (namePath) => { + const len = prefixName.length; + const pathName = namePath[len] as number; + return [keyManager.keys[pathName]!, namePath.slice(len + 1)]; + }, + }), + [keyManager, prefixName], + ); + + if (typeof children !== "function") { + if (process.env.NODE_ENV !== "production") { + console.warn("[Form] Form.List only accepts a function as children."); + } + return null; + } + + /** + * The list container re-renders on EXTERNAL changes only. An internal + * `onChange` (add/remove) already re-renders through the value it wrote; + * returning true here as well would double-render every mutation. + */ + const shouldUpdate = ( + prevValue: any, + nextValue: any, + { source }: { source?: string }, + ) => { + if (source === "internal") { + return false; + } + return prevValue !== nextValue; + }; + + return ( + <ListContext.Provider value={listContext}> + <FieldContext.Provider value={fieldContext}> + <Field + name={[]} + shouldUpdate={shouldUpdate} + rules={rules} + validateTrigger={validateTrigger} + initialValue={initialValue} + isList + isListField={isListField ?? !!wrapperListContext} + > + {({ value = [], onChange }: any, meta: Meta) => { + const { getFieldValue } = context; + // Always read the live value: the app may have replaced the whole + // list through `form.setFieldsValue` since the last render. + const getNewValue = (): any[] => getFieldValue(prefixName || []) || []; + + const operations: ListOperations = { + add: (defaultValue, index) => { + const newValue = getNewValue(); + if (index !== undefined && index >= 0 && index <= newValue.length) { + keyManager.keys = [ + ...keyManager.keys.slice(0, index), + keyManager.id, + ...keyManager.keys.slice(index), + ]; + onChange([ + ...newValue.slice(0, index), + defaultValue, + ...newValue.slice(index), + ]); + } else { + keyManager.keys = [...keyManager.keys, keyManager.id]; + onChange([...newValue, defaultValue]); + } + keyManager.id += 1; + }, + remove: (index) => { + const newValue = getNewValue(); + const indexSet = new Set(Array.isArray(index) ? index : [index]); + if (indexSet.size <= 0) { + return; + } + keyManager.keys = keyManager.keys.filter( + (_, keysIndex) => !indexSet.has(keysIndex), + ); + onChange(newValue.filter((_, valueIndex) => !indexSet.has(valueIndex))); + }, + }; + + let listValue: any[] = value || []; + if (!Array.isArray(listValue)) { + listValue = []; + } + + return children( + listValue.map((__, index) => { + let key = keyManager.keys[index]; + if (key === undefined) { + keyManager.keys[index] = keyManager.id; + key = keyManager.keys[index]; + keyManager.id += 1; + } + return { name: index, key, isListField: true }; + }), + operations, + meta, + ); + }} + </Field> + </FieldContext.Provider> + </ListContext.Provider> + ); +}; + +export default List; diff --git a/src/components/Form/context.ts b/src/components/Form/context.ts new file mode 100644 index 0000000..debd494 --- /dev/null +++ b/src/components/Form/context.ts @@ -0,0 +1,172 @@ +/** + * React contexts of the form engine. Collapses what upstream splits across + * rc-field-form's `FieldContext` / `FormContext` / `ListContext` and antd's + * `form/context` into one module. + */ +import type { + FieldData, + FormInstance, + InternalFormInstance, + InternalHooks, + Meta, + ValidateMessages, +} from "./interface"; +import type { InternalNamePath } from "./namePath"; +import * as React from "react"; + +/** Guard for `getInternalHooks`; keeps the engine-private API out of reach. */ +export const HOOK_MARK = "UIC_FORM_INTERNAL_HOOKS"; + +const noop = () => { + if (process.env.NODE_ENV !== "production") { + console.warn( + "[Form] Field used outside of a <Form>. Wrap it in a Form, or pass an explicit `form` instance.", + ); + } + return undefined as any; +}; + +/** + * Carries the live `FormInstance` down to every `Field`. Also carries + * `prefixName`, which is what makes `Form.List` children address their fields + * RELATIVELY while `dependencies` and `useWatch` stay ABSOLUTE. + */ +export const FieldContext = React.createContext<InternalFormInstance>({ + getFieldValue: noop, + getFieldsValue: noop, + getFieldError: noop, + getFieldWarning: noop, + getFieldsError: noop, + isFieldsTouched: noop, + isFieldTouched: noop, + isFieldValidating: noop, + isFieldsValidating: noop, + resetFields: noop, + setFields: noop, + setFieldValue: noop, + setFieldsValue: noop, + validateFields: noop, + submit: noop, + scrollToField: noop, + focusField: noop, + getFieldInstance: noop, + getInternalHooks: () => null, +} as unknown as InternalFormInstance); + +export interface ListContextValue { + /** Map an absolute field path to `[stableKey, restPath]` for the row it belongs to. */ + getKey: (namePath: InternalNamePath) => [React.Key, InternalNamePath]; +} + +export const ListContext = React.createContext<ListContextValue | null>(null); + +// ============================== Form.Provider =============================== + +export interface FormProviderContextValue { + validateMessages?: ValidateMessages; + triggerFormChange: (name: string | undefined, changedFields: FieldData[]) => void; + triggerFormFinish: (name: string | undefined, values: any) => void; + registerForm: (name: string | undefined, form: FormInstance) => void; + unregisterForm: (name: string | undefined) => void; +} + +export const FormProviderContext = React.createContext<FormProviderContextValue>({ + triggerFormChange: () => {}, + triggerFormFinish: () => {}, + registerForm: () => {}, + unregisterForm: () => {}, +}); + +// ========================= App-level form configuration ===================== + +export type RequiredMark = + | boolean + | "optional" + | ((label: React.ReactNode, info: { required: boolean }) => React.ReactNode); + +export interface FormConfig { + /** + * Message templates, `${label}`-based. Every `<Form>` already defaults them + * to ui-common's catalog in the active locale; these win over that. + */ + validateMessages?: ValidateMessages; + requiredMark?: RequiredMark; + /** + * The suffix `requiredMark="optional"` appends. Defaults to the catalog's + * `uic.Form.optional`. + */ + optionalLabel?: React.ReactNode; +} + +/** What antd sourced from `<ConfigProvider form={{...}}>`. */ +export const FormConfigContext = React.createContext<FormConfig>({}); + +// The provider lives in `./FormConfigProvider.tsx`: this module is engine +// core, and stays free of anything heavier than React. + +// ============================ Per-form UI context =========================== + +/** + * What antd carries on its own `FormContext` — the per-form visual settings a + * `Form.Item` inherits unless it states its own. antd's `feedbackIcons`, + * `classNames`, `styles` and `variant` are not supported. + */ +export interface FormItemLayoutContextValue { + form?: FormInstance; + layout: FormLayout; + requiredMark?: RequiredMark; + disabled?: boolean; + name?: string; + size?: FormSize; + colon?: boolean; + labelAlign?: "left" | "right"; + labelCol?: FormItemCol; + wrapperCol?: FormItemCol; + labelWrap?: boolean; +} + +export type FormLayout = "vertical" | "horizontal" | "inline"; +export type FormSize = "small" | "middle" | "large"; + +/** antd `Col` props, reduced to `span`, `offset`, `flex`, `className` and `style`. */ +export interface FormItemCol { + span?: number; + offset?: number; + flex?: string | number; + className?: string; + style?: React.CSSProperties; +} + +export const FormItemLayoutContext = React.createContext<FormItemLayoutContextValue>({ + // antd's default: a form that states no layout is horizontal. + layout: "horizontal", +}); + +// ========================== Item status / bubbling =========================== + +export interface FormItemStatusContextValue { + status?: "success" | "warning" | "error" | "validating" | ""; + errors?: React.ReactNode[]; + warnings?: React.ReactNode[]; + hasFeedback?: boolean; + isFormItemInput?: boolean; +} + +/** Read by `Form.Item.useStatus()` and by controls that paint the item's status. */ +export const FormItemInputContext = React.createContext<FormItemStatusContextValue>({}); + +export type SubItemMeta = Partial<Meta> & { + name: InternalNamePath; + destroy?: boolean; +}; + +/** + * How a rendering `Form.Item` collects the errors of the `noStyle` items + * nested inside it. Without it, a layout-only item loses its children's + * messages. + */ +export const NoStyleItemContext = React.createContext< + ((meta: SubItemMeta, uniqueKeys: React.Key[]) => void) | null +>(null); + +export type { InternalHooks }; diff --git a/src/components/Form/feedbackIcons.tsx b/src/components/Form/feedbackIcons.tsx new file mode 100644 index 0000000..802c02c --- /dev/null +++ b/src/components/Form/feedbackIcons.tsx @@ -0,0 +1,59 @@ +/** + * The four validation feedback glyphs `hasFeedback` paints. The path data is + * Ant Design Icons' CheckCircleFilled, CloseCircleFilled, + * ExclamationCircleFilled and LoadingOutlined (MIT, see NOTICE). They draw in + * `currentColor`; the item shell sets the status colour. + */ +import * as React from "react"; + +type Status = "success" | "warning" | "error" | "validating"; + +/** antd icons draw on a 1024 grid with a 64-unit bleed; `1em` sizes to font. */ +const BASE = { + width: "1em", + height: "1em", + fill: "currentColor", + "aria-hidden": true, + focusable: "false", +} as const; + +const PATHS: Record<Status, { viewBox: string; d: string; fillRule?: "evenodd" }> = { + success: { + viewBox: "64 64 896 896", + d: "M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64zm193.5 301.7l-210.6 292a31.8 31.8 0 01-51.7 0L318.5 484.9c-3.8-5.3 0-12.7 6.5-12.7h46.9c10.2 0 19.9 4.9 25.9 13.3l71.2 98.8 157.2-218c6-8.3 15.6-13.3 25.9-13.3H699c6.5 0 10.3 7.4 6.5 12.7z", + }, + error: { + viewBox: "64 64 896 896", + fillRule: "evenodd", + d: "M512 64c247.4 0 448 200.6 448 448S759.4 960 512 960 64 759.4 64 512 264.6 64 512 64zm127.98 274.82h-.04l-.08.06L512 466.75 384.14 338.88c-.04-.05-.06-.06-.08-.06a.12.12 0 00-.07 0c-.03 0-.05.01-.09.05l-45.02 45.02a.2.2 0 00-.05.09.12.12 0 000 .07v.02a.27.27 0 00.06.06L466.75 512 338.88 639.86c-.05.04-.06.06-.06.08a.12.12 0 000 .07c0 .03.01.05.05.09l45.02 45.02a.2.2 0 00.09.05.12.12 0 00.07 0c.02 0 .04-.01.08-.05L512 557.25l127.86 127.87c.04.04.06.05.08.05a.12.12 0 00.07 0c.03 0 .05-.01.09-.05l45.02-45.02a.2.2 0 00.05-.09.12.12 0 000-.07v-.02a.27.27 0 00-.05-.06L557.25 512l127.87-127.86c.04-.04.05-.06.05-.08a.12.12 0 000-.07c0-.03-.01-.05-.05-.09l-45.02-45.02a.2.2 0 00-.09-.05.12.12 0 00-.07 0z", + }, + warning: { + viewBox: "64 64 896 896", + d: "M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64zm-32 232c0-4.4 3.6-8 8-8h48c4.4 0 8 3.6 8 8v272c0 4.4-3.6 8-8 8h-48c-4.4 0-8-3.6-8-8V296zm32 440a48.01 48.01 0 010-96 48.01 48.01 0 010 96z", + }, + validating: { + viewBox: "0 0 1024 1024", + d: "M988 548c-19.9 0-36-16.1-36-36 0-59.4-11.6-117-34.6-171.3a440.45 440.45 0 00-94.3-139.9 437.71 437.71 0 00-139.9-94.3C629 83.6 571.4 72 512 72c-19.9 0-36-16.1-36-36s16.1-36 36-36c69.1 0 136.2 13.5 199.3 40.3C772.3 66 827 103 874 150c47 47 83.9 101.8 109.7 162.7 26.7 63.1 40.2 130.2 40.2 199.3.1 19.9-16 36-35.9 36z", + }, +}; + +/** + * The glyph for a validation status, or `null` for the neutral state (antd + * draws nothing when `mergedValidateStatus` is empty). + */ +export const getFeedbackIcon = (status: string | undefined): React.ReactNode => { + if (!status || !(status in PATHS)) return null; + const spec = PATHS[status as Status]; + return ( + <svg + {...BASE} + viewBox={spec.viewBox} + className="uic-form-item__feedback-glyph" + data-status={status} + > + <path d={spec.d} fillRule={spec.fillRule} /> + </svg> + ); +}; + +export default getFeedbackIcon; diff --git a/src/components/Form/index.ts b/src/components/Form/index.ts new file mode 100644 index 0000000..c003561 --- /dev/null +++ b/src/components/Form/index.ts @@ -0,0 +1,93 @@ +/** + * `@lablup/ui-common/Form`: a form engine with antd's form API (`Form`, + * `Form.Item`, `Form.List`, `Form.ErrorList`, `Form.Provider`, + * `Form.useForm`, `Form.useWatch`, `Form.useFormInstance`, + * `Form.Item.useStatus`), so code written against antd's form moves onto it + * with an import rewrite. The state half is a behavioural port of + * rc-field-form; the item shell renders on Astryx. + * + * The API is a form-state API, not an Astryx-shaped component: it keeps + * antd's names and shapes on purpose. + */ +import ErrorList from "./ErrorList"; +import InternalForm, { FormProvider, useForm } from "./Form"; +import FormItem, { useFormItemStatus } from "./FormItem"; +import List from "./List"; +import useWatch, { useFormInstance } from "./useWatch"; + +type InternalFormType = typeof InternalForm; + +interface FormInterface extends InternalFormType { + Item: typeof FormItem; + List: typeof List; + ErrorList: typeof ErrorList; + useForm: typeof useForm; + useFormInstance: typeof useFormInstance; + useWatch: typeof useWatch; + Provider: typeof FormProvider; +} + +type FormItemWithStatus = typeof FormItem & { + useStatus: typeof useFormItemStatus; +}; + +(FormItem as FormItemWithStatus).useStatus = useFormItemStatus; + +const Form = InternalForm as FormInterface; +Form.Item = FormItem; +Form.List = List; +Form.ErrorList = ErrorList; +Form.useForm = useForm; +Form.useFormInstance = useFormInstance; +Form.useWatch = useWatch; +Form.Provider = FormProvider; + +export default Form; +export { Form }; + +export { + FormItem, + List as FormList, + ErrorList, + FormProvider, + useForm, + useWatch, + useFormInstance, +}; +export { FormItemVisual, type FormItemVisualProps } from "./FormItemVisual"; +export { + // Read by controls that paint their item's validation status, and by + // custom item shells that publish sub-item metas. + FormItemInputContext, + NoStyleItemContext, + FormConfigContext, + type FormConfig, + type FormItemStatusContextValue, + type RequiredMark, +} from "./context"; +export { FormConfigProvider, useFormValidateMessages } from "./FormConfigProvider"; +export { FormStore } from "./FormStore"; +export { defaultValidateMessages } from "./messages"; + +export type { FormProps, FormRef } from "./Form"; +export type { FormItemProps } from "./FormItem"; +export type { ListProps as FormListProps, ListField, ListOperations } from "./List"; +export type { ErrorListProps } from "./ErrorList"; +export type { WatchOptions } from "./useWatch"; +export type { + FormInstance, + FieldData, + FieldError, + Meta, + NamePath, + InternalNamePath, + Rule, + RuleObject, + RuleRender, + RuleType, + Store, + StoreValue, + ValidateErrorEntity, + ValidateMessages, + ValidatorRule, +} from "./interface"; diff --git a/src/components/Form/interface.ts b/src/components/Form/interface.ts new file mode 100644 index 0000000..1b6db0a --- /dev/null +++ b/src/components/Form/interface.ts @@ -0,0 +1,254 @@ +/** + * Public types of the form engine. Shapes are assignment-compatible with the + * antd / rc-field-form declarations, so moving a call site onto the engine is + * an import rewrite. + * + * Supported rule keys: required, message, validator, type, max, min, pattern, + * warningOnly, whitespace. Not implemented: enum, len, transform, + * defaultField, fields, the date/regexp/hex/float/integer/method/array/boolean + * `type` values, `Form.Item normalize` and `Form.List move`. + */ +import type { InternalNamePath, NamePath, Store } from "./namePath"; +import type * as React from "react"; + +export type { NamePath, InternalNamePath, Store, NamePathSegment } from "./namePath"; + +export type StoreValue = any; + +/** The supported `type` values. */ +export type RuleType = "string" | "number" | "email" | "url" | "object"; + +export type ValidatorRule = { + warningOnly?: boolean; + message?: React.ReactNode; + validator: ( + rule: RuleObject, + value: StoreValue, + callback: (error?: string) => void, + ) => Promise<void | any> | void; +}; + +export interface BaseRule { + warningOnly?: boolean; + /** + * `undefined` means "generate the default message"; an EMPTY STRING means + * "this rule intentionally shows no text", so the engine must not treat + * `''` as absent. + */ + message?: React.ReactNode; + required?: boolean; + type?: RuleType; + max?: number; + min?: number; + pattern?: RegExp; + whitespace?: boolean; + /** Per-rule trigger filter; the field-level `validateTrigger` is the default. */ + validateTrigger?: string | string[]; +} + +export interface RuleObject extends BaseRule { + validator?: ValidatorRule["validator"]; +} + +export type RuleRender = (form: FormInstance) => RuleObject; + +export type Rule = RuleObject | RuleRender; + +export interface FieldError { + name: InternalNamePath; + errors: string[]; + warnings: string[]; +} + +export interface Meta { + touched: boolean; + validating: boolean; + errors: string[]; + warnings: string[]; + name: InternalNamePath; + validated: boolean; +} + +export interface FieldData extends Partial<Omit<Meta, "name">> { + name: NamePath; + value?: StoreValue; +} + +export interface ValidateErrorEntity<Values = any> { + values: Values; + errorFields: { name: InternalNamePath; errors: string[] }[]; + outOfDate: boolean; +} + +export interface ValidateOptions { + triggerName?: string; + validateMessages?: ValidateMessages; + /** Also validate every field NESTED under a requested path. */ + recursive?: boolean; + /** Only validate fields the user has touched / that have been validated. */ + dirty?: boolean; +} + +/** + * A template is normally a `${label}`-style string, but antd's locale bundles + * also allow a thunk (some locales build the sentence at call time). `format` + * invokes the thunk; only strings go through `${}` substitution. + */ +export type ValidateMessage = string | (() => string); + +type RangeMessages = { + min?: ValidateMessage; + max?: ValidateMessage; + range?: ValidateMessage; + len?: ValidateMessage; +}; + +export interface ValidateMessages { + default?: ValidateMessage; + required?: ValidateMessage; + whitespace?: ValidateMessage; + types?: Partial<Record<RuleType, ValidateMessage>>; + string?: RangeMessages; + number?: RangeMessages; + array?: RangeMessages; + pattern?: { mismatch?: ValidateMessage }; + [key: string]: any; +} + +export interface FormInstance<Values = any> { + getFieldValue: (name: NamePath) => StoreValue; + getFieldsValue: ((nameList?: NamePath[]) => Values) & + ((nameList: true, filterFunc?: (meta: Meta) => boolean) => Values) & + ((config: { strict?: boolean; filter?: (meta: Meta) => boolean }) => Values); + getFieldError: (name: NamePath) => string[]; + getFieldWarning: (name: NamePath) => string[]; + getFieldsError: (nameList?: NamePath[]) => FieldError[]; + isFieldsTouched: ((nameList?: NamePath[], allFieldsTouched?: boolean) => boolean) & + ((allFieldsTouched?: boolean) => boolean); + isFieldTouched: (name: NamePath) => boolean; + isFieldValidating: (name: NamePath) => boolean; + isFieldsValidating: (nameList?: NamePath[]) => boolean; + resetFields: (fields?: NamePath[]) => void; + setFields: (fields: FieldData[]) => void; + setFieldValue: (name: NamePath, value: StoreValue) => void; + setFieldsValue: (values: RecursivePartial<Values>) => void; + validateFields: ValidateFields<Values>; + submit: () => void; + /** Thin implementation — see `FormStore.scrollToField` for the caveat. */ + scrollToField: (name: NamePath, options?: ScrollOptions) => void; + focusField: (name: NamePath) => void; + getFieldInstance: (name: NamePath) => any; +} + +export interface ScrollOptions extends ScrollIntoViewOptions { + focus?: boolean; +} + +export type RecursivePartial<T> = T extends object + ? { [K in keyof T]?: RecursivePartial<T[K]> } + : T; + +type ValidateFields<Values = any> = (( + nameList?: NamePath[], + options?: ValidateOptions, +) => Promise<Values>) & + ((options?: ValidateOptions) => Promise<Values>); + +/** Engine-internal view of a mounted `Field`. */ +export interface FieldEntity { + onStoreChange: ( + store: Store, + namePathList: InternalNamePath[] | null, + info: NotifyInfo, + ) => void; + isFieldTouched: () => boolean; + isFieldDirty: () => boolean; + isFieldValidating: () => boolean; + isListField: () => boolean | undefined; + isList: () => boolean | undefined; + isPreserve: () => boolean | undefined; + validateRules: (options?: InternalValidateOptions) => Promise<RuleError[]>; + getMeta: () => Meta; + getNamePath: () => InternalNamePath; + getErrors: () => string[]; + getWarnings: () => string[]; + props: { + name?: NamePath; + rules?: Rule[]; + dependencies?: NamePath[]; + initialValue?: any; + }; +} + +export interface InternalValidateOptions extends ValidateOptions { + validateOnly?: boolean; + /** Wait a frame so `useWatch`-derived rules see the latest value. */ + delayFrame?: boolean; +} + +export interface RuleError { + errors: string[]; + rule: RuleObject; +} + +/** `Omit` that distributes over a union instead of collapsing it. */ +export type DistributiveOmit<T, K extends PropertyKey> = T extends any + ? Omit<T, K> + : never; + +export type NotifyInfo = + | { type: "valueUpdate"; source: "internal" | "external"; store: Store } + | { type: "validateFinish"; store: Store } + | { type: "reset"; store: Store } + | { type: "remove"; store: Store } + | { type: "setField"; data: FieldData; store: Store } + | { + type: "dependenciesUpdate"; + relatedFields: InternalNamePath[]; + store: Store; + }; + +export interface Callbacks<Values = any> { + onValuesChange?: (changedValues: any, values: Values) => void; + onFieldsChange?: (changedFields: FieldData[], allFields: FieldData[]) => void; + onFinish?: (values: Values) => void; + onFinishFailed?: (errorInfo: ValidateErrorEntity<Values>) => void; +} + +export interface InternalHooks { + dispatch: (action: ReducerAction) => void; + initEntityValue: (entity: FieldEntity) => void; + registerField: ( + entity: FieldEntity, + ) => ( + isListField?: boolean, + preserve?: boolean, + subNamePath?: InternalNamePath, + ) => void; + useSubscribe: (subscribable: boolean) => void; + setInitialValues: (values: Store | undefined, init: boolean) => void; + destroyForm: (clearOnDestroy?: boolean) => void; + setCallbacks: (callbacks: Callbacks) => void; + setValidateMessages: (validateMessages: ValidateMessages) => void; + getFields: () => FieldData[]; + setPreserve: (preserve?: boolean) => void; + getInitialValue: (namePath: InternalNamePath) => StoreValue; + registerWatch: (callback: WatchCallBack) => () => void; +} + +export type WatchCallBack = (values: Store, allValues: Store) => void; + +export type ReducerAction = + | { type: "updateValue"; namePath: InternalNamePath; value: StoreValue } + | { + type: "validateField"; + namePath: InternalNamePath; + triggerName: string; + }; + +export interface InternalFormInstance extends FormInstance { + prefixName?: InternalNamePath; + validateTrigger?: string | string[] | false; + getInternalHooks: (secret: string) => InternalHooks | null; + _init?: boolean; +} diff --git a/src/components/Form/isEqual.test.ts b/src/components/Form/isEqual.test.ts new file mode 100644 index 0000000..5bdccde --- /dev/null +++ b/src/components/Form/isEqual.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; + +import { isEqual } from "./isEqual"; + +describe("isEqual", () => { + it("compares primitives, with NaN equal to itself", () => { + expect(isEqual(1, 1)).toBe(true); + expect(isEqual("a", "b")).toBe(false); + expect(isEqual(NaN, NaN)).toBe(true); + expect(isEqual(0, -0)).toBe(true); + expect(isEqual(null, undefined)).toBe(false); + expect(isEqual(undefined, undefined)).toBe(true); + }); + + it("compares arrays and plain objects deeply", () => { + expect(isEqual([1, { a: [2, 3] }], [1, { a: [2, 3] }])).toBe(true); + expect(isEqual([1, 2], [1, 2, 3])).toBe(false); + expect(isEqual({ a: 1, b: undefined }, { a: 1 })).toBe(false); + expect(isEqual({ a: 1, b: 2 }, { b: 2, a: 1 })).toBe(true); + }); + + it("compares dates, regexps and boxed values by value", () => { + expect(isEqual(new Date(5), new Date(5))).toBe(true); + expect(isEqual(new Date(5), new Date(6))).toBe(false); + expect(isEqual(/a/g, /a/g)).toBe(true); + expect(isEqual(/a/g, /a/i)).toBe(false); + }); + + it("compares maps and sets regardless of order", () => { + expect(isEqual(new Set([1, 2]), new Set([2, 1]))).toBe(true); + expect(isEqual(new Map([["a", { x: 1 }]]), new Map([["a", { x: 1 }]]))).toBe(true); + expect(isEqual(new Set([1]), new Set([2]))).toBe(false); + }); + + it("compares class instances by own keys and constructor", () => { + class A { + constructor(public v: number) {} + } + class B { + constructor(public v: number) {} + } + expect(isEqual(new A(1), new A(1))).toBe(true); + expect(isEqual(new A(1), new B(1))).toBe(false); + }); + + it("does not compare functions or opaque built-ins by content", () => { + expect( + isEqual( + () => 1, + () => 1, + ), + ).toBe(false); + const blob = new Blob(["x"]); + expect(isEqual(blob, blob)).toBe(true); + expect(isEqual(blob, new Blob(["x"]))).toBe(false); + }); + + it("survives cycles", () => { + const a: Record<string, unknown> = { v: 1 }; + a.self = a; + const b: Record<string, unknown> = { v: 1 }; + b.self = b; + expect(isEqual(a, b)).toBe(true); + }); +}); diff --git a/src/components/Form/isEqual.ts b/src/components/Form/isEqual.ts new file mode 100644 index 0000000..a14b8cd --- /dev/null +++ b/src/components/Form/isEqual.ts @@ -0,0 +1,140 @@ +/** + * Deep equality for form values and field metas, with lodash `isEqual`'s + * semantics for the shapes a form holds: primitives (`NaN` equals `NaN`), + * arrays and typed arrays, plain and class-instance objects (own enumerable + * keys, then a constructor check), `Date`, `RegExp`, boxed primitives, + * `Error`, `Map` and `Set` (order-insensitive), and cycles. Any other + * built-in tag (`File`, `Blob`, DOM nodes, …) is equal only by reference, as + * in lodash. + */ +const toTag = (value: unknown) => Object.prototype.toString.call(value); +const hasOwn = (value: object, key: PropertyKey) => + Object.prototype.hasOwnProperty.call(value, key); + +type Stack = Array<[unknown, unknown]>; + +function ownKeys(value: object): PropertyKey[] { + return [ + ...Object.keys(value), + ...Object.getOwnPropertySymbols(value).filter((s) => + Object.prototype.propertyIsEnumerable.call(value, s), + ), + ]; +} + +function equalUnordered(a: unknown[], b: unknown[], stack: Stack): boolean { + if (a.length !== b.length) return false; + const used = new Set<number>(); + return a.every((item) => { + const index = b.findIndex((other, i) => !used.has(i) && equal(item, other, stack)); + if (index === -1) return false; + used.add(index); + return true; + }); +} + +function equalSequence(a: ArrayLike<unknown>, b: ArrayLike<unknown>, stack: Stack) { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i += 1) { + if (!equal(a[i], b[i], stack)) return false; + } + return true; +} + +function equalObjects(a: object, b: object, stack: Stack): boolean { + const keys = ownKeys(a); + if (keys.length !== ownKeys(b).length) return false; + for (const key of keys) { + if (!hasOwn(b, key)) return false; + if ( + !equal( + (a as Record<PropertyKey, unknown>)[key], + (b as Record<PropertyKey, unknown>)[key], + stack, + ) + ) + return false; + } + const ctorA = (a as { constructor?: unknown }).constructor; + const ctorB = (b as { constructor?: unknown }).constructor; + if ( + ctorA !== ctorB && + "constructor" in a && + "constructor" in b && + !( + typeof ctorA === "function" && + ctorA instanceof ctorA && + typeof ctorB === "function" && + ctorB instanceof ctorB + ) + ) { + return false; + } + return true; +} + +function equal(a: unknown, b: unknown, stack: Stack): boolean { + if (a === b) return true; + if (a == null || b == null || typeof a !== "object" || typeof b !== "object") { + // Only NaN is unequal to itself; functions compare by reference. + return a !== a && b !== b; + } + const tag = toTag(a); + if (tag !== toTag(b)) return false; + + for (const [x, y] of stack) { + if (x === a) return y === b; + } + stack.push([a, b]); + try { + switch (tag) { + case "[object Array]": + return equalSequence(a as unknown[], b as unknown[], stack); + case "[object Object]": + case "[object Arguments]": + return equalObjects(a, b, stack); + case "[object Date]": + case "[object Number]": + case "[object Boolean]": { + const x = Number(a); + const y = Number(b); + return x === y || (x !== x && y !== y); + } + case "[object RegExp]": + case "[object String]": + return String(a) === String(b); + case "[object Error]": + return ( + (a as Error).name === (b as Error).name && + (a as Error).message === (b as Error).message + ); + case "[object Map]": + return equalUnordered( + [...(a as Map<unknown, unknown>)], + [...(b as Map<unknown, unknown>)], + stack, + ); + case "[object Set]": + return equalUnordered( + [...(a as Set<unknown>)], + [...(b as Set<unknown>)], + stack, + ); + default: + if (ArrayBuffer.isView(a) && ArrayBuffer.isView(b)) { + return equalSequence( + a as unknown as ArrayLike<unknown>, + b as unknown as ArrayLike<unknown>, + stack, + ); + } + return false; + } + } finally { + stack.pop(); + } +} + +export function isEqual(a: unknown, b: unknown): boolean { + return equal(a, b, []); +} diff --git a/src/components/Form/messages.ts b/src/components/Form/messages.ts new file mode 100644 index 0000000..1cd4d0d --- /dev/null +++ b/src/components/Form/messages.ts @@ -0,0 +1,89 @@ +/** + * Validate-message templates. + * + * The strings live in ui-common's catalog (`./Form.messages.ts`) as ICU + * messages. The engine interpolates rule values itself, with `${name}`-style + * placeholders (see `validate.ts`), so a template is the catalog message + * formatted with each placeholder standing for itself: `{label}` becomes + * `${label}`. + */ +import IntlMessageFormat from "intl-messageformat"; + +import { formMessages } from "./Form.messages"; +import type { ValidateMessages } from "./interface"; + +/** Formats a catalog key with the given values, like `useUicTranslator`'s `t`. */ +export type ValidateMessageTranslate = ( + key: keyof typeof formMessages, + values: Record<string, string>, +) => string; + +const PLACEHOLDERS = ["label", "type", "len", "min", "max", "pattern"] as const; +const TEMPLATE_VALUES = Object.fromEntries(PLACEHOLDERS.map((p) => [p, `\${${p}}`])); + +/** The engine's template table, in the language `translate` resolves. */ +export function buildValidateMessages( + translate: ValidateMessageTranslate, +): ValidateMessages { + const m = (key: keyof typeof formMessages) => translate(key, TEMPLATE_VALUES); + const lengths = (group: "string" | "number" | "array") => ({ + len: m(`uic.Form.${group}Len`), + min: m(`uic.Form.${group}Min`), + max: m(`uic.Form.${group}Max`), + range: m(`uic.Form.${group}Range`), + }); + return { + default: m("uic.Form.default"), + required: m("uic.Form.required"), + whitespace: m("uic.Form.whitespace"), + types: { + string: m("uic.Form.typeString"), + number: m("uic.Form.typeNumber"), + object: m("uic.Form.typeObject"), + email: m("uic.Form.typeEmail"), + url: m("uic.Form.typeUrl"), + }, + string: lengths("string"), + number: lengths("number"), + array: lengths("array"), + pattern: { mismatch: m("uic.Form.patternMismatch") }, + }; +} + +/** The English templates: the fallback under every other table. */ +export const defaultValidateMessages: ValidateMessages = buildValidateMessages( + (key, values) => + String( + new IntlMessageFormat(formMessages[key].defaultMessage, "en").format(values), + ), +); + +/** + * Shallow-per-section merge, matching async-validator's `deepMerge`: a + * provided `types` object is spread over the default `types` object rather + * than replacing it, so a table that only sets `required` keeps the other + * templates. + */ +export function mergeValidateMessages( + ...sources: (ValidateMessages | undefined | null)[] +): ValidateMessages { + const target: ValidateMessages = { ...defaultValidateMessages }; + sources.forEach((source) => { + if (!source) return; + Object.keys(source).forEach((key) => { + const value = source[key]; + const existing = target[key]; + if ( + value && + typeof value === "object" && + existing && + typeof existing === "object" + ) { + target[key] = { ...existing, ...value }; + } else { + target[key] = value; + } + }); + }); + return target; +} diff --git a/src/components/Form/namePath.ts b/src/components/Form/namePath.ts new file mode 100644 index 0000000..80c4e95 --- /dev/null +++ b/src/components/Form/namePath.ts @@ -0,0 +1,370 @@ +/** + * Path and store primitives: a behavioural port of rc-field-form's + * `utils/valueUtil` and `utils/NameMap` and rc-util's `get` / `set` / `merge`. + * Pure and dependency-free. + * + * `set` must keep upstream's copy-on-write with numeric-segment array + * creation: `setValue({}, ['a', 0, 'b'], 1)` produces `{ a: [{ b: 1 }] }`, + * not `{ a: { '0': { b: 1 } } }`, or Form.List values are reshaped. + */ + +/** A single addressable segment of a field path. */ +export type NamePathSegment = string | number; +/** Normalised, always-array internal path — what `errorFields[].name` carries. */ +export type InternalNamePath = NamePathSegment[]; + +export type BaseNamePath = string | number | boolean | (string | number | boolean)[]; + +/** + * antd's own deep path type, copied so `NamePath` behaves identically. + * + * For a typed store it walks up to three levels and produces the tuples that + * store admits; for an untyped `FormInstance` (`Store = any`) the conditional + * collapses to `any`, which is why `[listName, index, 'variable']` with a + * `NamePath`-typed `listName` type-checks. Tightening it rejects such code. + */ +export type DeepNamePath< + Store = any, + ParentNamePath extends any[] = [], +> = ParentNamePath["length"] extends 3 + ? never + : true extends (Store extends BaseNamePath ? true : false) + ? ParentNamePath["length"] extends 0 + ? Store | BaseNamePath + : Store extends any[] + ? [...ParentNamePath, number] + : never + : Store extends any[] + ? | [...ParentNamePath, number] + | DeepNamePath<Store[number], [...ParentNamePath, number]> + : keyof Store extends never + ? Store + : { + [FieldKey in keyof Store]: Store[FieldKey] extends (...args: any[]) => any + ? never + : | (ParentNamePath["length"] extends 0 ? FieldKey : never) + | [...ParentNamePath, FieldKey] + | DeepNamePath< + Required<Store>[FieldKey], + [...ParentNamePath, FieldKey] + >; + }[keyof Store]; + +/** Public name path shape accepted by every engine API. */ +export type NamePath<Store = any> = DeepNamePath<Store>; + +/** Arbitrary form value store. Values are never coerced (Relay objects live here). */ +export type Store = Record<string, any>; + +export function toArray<T>(value: T | T[] | undefined | null): T[] { + if (value === undefined || value === null) { + return []; + } + return Array.isArray(value) ? value : [value]; +} + +/** + * `'a'` -> `['a']`, `123` -> `[123]`, `['a', 123]` -> `['a', 123]`. + * Deliberately NOT dot-splitting: `'a.b'` stays a single segment, matching + * rc-field-form (its `getNamePath` is `toArray`, nothing more). + */ +export function getNamePath(path: NamePath | undefined | null): InternalNamePath { + return toArray(path as NamePathSegment); +} + +export function getValue(entity: any, path: InternalNamePath): any { + let current = entity; + for (let i = 0; i < path.length; i += 1) { + if (current === null || current === undefined) { + return undefined; + } + current = current[path[i]!]; + } + return current; +} + +/** + * Segments that must never reach a plain `obj[key] = v`. + * + * `clone.__proto__ = v` mutates the prototype CHAIN instead of storing a value, + * and `constructor` / `prototype` are the adjacent reachable slots. A form + * whose field is literally named one of these is pathological, but name paths + * flow in from `Form.List` indices and caller-built arrays, so the engine + * should not be the thing that trusts them (CodeQL `js/prototype-polluting- + * assignment`). + * + * Written as explicit `===` comparisons rather than a `Set.has()` lookup on + * purpose: CodeQL recognises the former as a barrier guard and does not track + * through the latter, so the `Set` version left the alert standing even though + * the runtime behaviour was identical. + */ +const isUnsafeSegment = (key: NamePathSegment): boolean => + key === "__proto__" || key === "constructor" || key === "prototype"; + +/** + * Store `value` at `key` WITHOUT ever walking the prototype chain. + * + * For an ordinary key this is a plain assignment — the hot path is unchanged. + * For a dangerous one it defines an own, enumerable, writable data property, + * which is what the caller meant: the value is kept and readable through the + * same path, it simply stops being a prototype mutation. Nothing is silently + * dropped, so a field genuinely named `constructor` still round-trips. + */ +const safeAssign = (target: any, key: NamePathSegment, value: any): void => { + if (isUnsafeSegment(key)) { + Object.defineProperty(target, key, { + value, + enumerable: true, + writable: true, + configurable: true, + }); + return; + } + target[key] = value; +}; + +/** Read a segment without falling through to the prototype chain. */ +const safeRead = (source: any, key: NamePathSegment): any => { + if (source == null) return undefined; + if (isUnsafeSegment(key)) { + return Object.prototype.hasOwnProperty.call(source, key) + ? (Object.getOwnPropertyDescriptor(source, key)?.value as unknown) + : undefined; + } + return source[key]; +}; + +function internalSet( + entity: any, + paths: InternalNamePath, + value: any, + removeIfUndefined: boolean, +): any { + if (!paths.length) { + return value; + } + const [path, ...restPath] = paths as [InternalNamePath[number], ...InternalNamePath]; + let clone: any; + if (!entity && typeof path === "number") { + clone = []; + } else if (Array.isArray(entity)) { + clone = [...entity]; + } else { + clone = { ...entity }; + } + + // `removeIfUndefined` deletes the key instead of writing `undefined` — this + // is what makes `preserve={false}` drop the key from `getFieldsValue()` + // rather than leaving `{ field: undefined }`. + if (removeIfUndefined && value === undefined && restPath.length === 1) { + const child = safeRead(clone, path); + const leaf = restPath[0]!; + // Guarded for the same reason as `safeAssign`: a computed `delete` on + // `__proto__` / `constructor` / `prototype` reaches the prototype chain. + // Nothing legitimate deletes those, so skipping is the whole behaviour. + if (child != null && !isUnsafeSegment(leaf)) delete child[leaf]; + } else { + safeAssign( + clone, + path, + internalSet(safeRead(clone, path), restPath, value, removeIfUndefined), + ); + } + return clone; +} + +export function setValue<T = Store>( + entity: T, + paths: InternalNamePath, + value: any, + removeIfUndefined = false, +): T { + if ( + paths.length && + removeIfUndefined && + value === undefined && + !getValue(entity, paths.slice(0, -1)) + ) { + return entity; + } + return internalSet(entity, paths, value, removeIfUndefined); +} + +function isPlainObject(obj: any): boolean { + return ( + typeof obj === "object" && + obj !== null && + Object.getPrototypeOf(obj) === Object.prototype + ); +} + +function createEmpty(source: any): any { + return Array.isArray(source) ? [] : {}; +} + +/** + * Deep merge with ARRAY REPLACEMENT (not concatenation / index merge). + * `setFieldsValue({ resource: {...} })` partially merges the `resource` + * object but replaces any array it contains wholesale. Non-plain objects + * (class instances, records from a data store) are assigned by reference. + */ +export function merge<T = Store>(...sources: any[]): T { + let clone: any = createEmpty(sources[0]); + + sources.forEach((src) => { + const walk = (path: InternalNamePath, loop: Set<any>) => { + const loopSet = new Set(loop); + const value = getValue(src, path); + const isArr = Array.isArray(value); + if (isArr || isPlainObject(value)) { + if (loopSet.has(value)) { + return; + } + loopSet.add(value); + const originValue = getValue(clone, path); + if (isArr) { + clone = setValue(clone, path, []); + } else if (!originValue || typeof originValue !== "object") { + clone = setValue(clone, path, createEmpty(value)); + } + Reflect.ownKeys(value as object).forEach((key) => { + if (Object.getOwnPropertyDescriptor(value, key)?.enumerable) { + walk([...path, key as NamePathSegment], loopSet); + } + }); + } else { + clone = setValue(clone, path, value); + } + }; + walk([], new Set()); + }); + + return clone as T; +} + +/** Build a store containing only the given paths (used by `validateFields`' resolve value). */ +export function cloneByNamePathList( + store: Store, + namePathList: InternalNamePath[], +): Store { + let newStore: Store = {}; + namePathList.forEach((namePath) => { + const value = getValue(store, namePath); + newStore = setValue(newStore, namePath, value); + }); + return newStore; +} + +/** + * Is `namePath` equal to (or, with `partialMatch`, prefixed by) `subNamePath`? + * `partialMatch` is what makes `validateFields(['resource'], {recursive:true})` + * reach `['resource','cpu']`. + */ +export function matchNamePath( + namePath: InternalNamePath | undefined | null, + subNamePath: InternalNamePath | undefined | null, + partialMatch = false, +): boolean { + if (!namePath || !subNamePath) { + return false; + } + if (!partialMatch && namePath.length !== subNamePath.length) { + return false; + } + return subNamePath.every((nameUnit, i) => namePath[i] === nameUnit); +} + +export function containsNamePath( + namePathList: InternalNamePath[] | undefined | null, + namePath: InternalNamePath, + partialMatch = false, +): boolean { + return ( + !!namePathList && + namePathList.some((path) => matchNamePath(namePath, path, partialMatch)) + ); +} + +/** + * Pull the next value out of a control's change payload. Mirrors + * rc-field-form: DOM-ish events expose the value under `event.target[ + * valuePropName]` (so `valuePropName="checked"` reads `target.checked`), + * everything else is passed through as-is. + */ +export function defaultGetValueFromEvent(valuePropName: string, ...args: any[]): any { + const event = args[0]; + if ( + event && + event.target && + typeof event.target === "object" && + valuePropName in event.target + ) { + return (event.target as any)[valuePropName]; + } + return event; +} + +const SPLIT = "__@field_split__"; + +function normalizeKey(namePath: InternalNamePath): string { + // The `typeof` prefix keeps `['a', 0]` and `['a', '0']` distinct keys — + // Form.List indices are numbers and object keys are strings. + return namePath.map((cell) => `${typeof cell}:${cell}`).join(SPLIT); +} + +/** `Map` keyed by name path. */ +export default class NameMap<T> { + private kvs = new Map<string, T>(); + + set(key: InternalNamePath, value: T) { + this.kvs.set(normalizeKey(key), value); + } + + get(key: InternalNamePath): T | undefined { + return this.kvs.get(normalizeKey(key)); + } + + /** The entry at `key` plus every entry nested underneath it. */ + getAsPrefix(key: InternalNamePath): T[] { + const normalizedKey = normalizeKey(key); + const normalizedPrefix = normalizedKey + SPLIT; + const results: T[] = []; + const current = this.kvs.get(normalizedKey); + if (current !== undefined) { + results.push(current); + } + this.kvs.forEach((value, itemNormalizedKey) => { + if (itemNormalizedKey.startsWith(normalizedPrefix)) { + results.push(value); + } + }); + return results; + } + + update(key: InternalNamePath, updater: (origin: T | undefined) => T | null) { + const origin = this.get(key); + const next = updater(origin); + if (!next) { + this.delete(key); + } else { + this.set(key, next); + } + } + + delete(key: InternalNamePath) { + this.kvs.delete(normalizeKey(key)); + } + + map<U>(callback: (entry: { key: InternalNamePath; value: T }) => U): U[] { + return [...this.kvs.entries()].map(([key, value]) => { + const cells = key.split(SPLIT); + return callback({ + key: cells.map((cell) => { + const [, type, unit] = /^([^:]*):(.*)$/.exec(cell) as RegExpExecArray; + return type === "number" ? Number(unit) : unit!; + }), + value, + }); + }); + } +} diff --git a/src/components/Form/useWatch.ts b/src/components/Form/useWatch.ts new file mode 100644 index 0000000..3f77b94 --- /dev/null +++ b/src/components/Form/useWatch.ts @@ -0,0 +1,120 @@ +/** + * `Form.useWatch` and `Form.useFormInstance`. + * + * `{ preserve: true }` reads the raw store rather than the registered-field + * projection, so a value whose field is unmounted is still observed. Paths are + * absolute even inside a `Form.List`, because `useWatch` reads from the form + * instance, never from `prefixName`. + * + * Change detection is by JSON stringification, matching upstream, so a watcher + * on an object path settles instead of re-rendering on every new identity. + */ +import { FieldContext, FormItemLayoutContext, HOOK_MARK } from "./context"; +import type { FormInstance, InternalFormInstance, StoreValue } from "./interface"; +import { getNamePath, getValue, type NamePath, type Store } from "./namePath"; +import * as React from "react"; + +function stringify(value: any): string | number { + try { + return JSON.stringify(value); + } catch { + return Math.random(); + } +} + +function isFormInstance(form: any): form is InternalFormInstance { + return form && !!form._init; +} + +export interface WatchOptions<Form = FormInstance> { + form?: Form; + /** Read the raw store, including values whose field is unmounted. */ + preserve?: boolean; +} + +function useWatch<Values = StoreValue>( + dependencies: NamePath | ((values: Store) => Values), + formOrOptions?: FormInstance | WatchOptions, +): Values { + const options: WatchOptions = isFormInstance(formOrOptions) + ? { form: formOrOptions } + : ((formOrOptions ?? {}) as WatchOptions); + const form = options.form as InternalFormInstance | undefined; + + const fieldContext = React.useContext(FieldContext); + const formInstance = (form || fieldContext) as InternalFormInstance; + const isValidForm = !!formInstance && !!formInstance._init; + + const read = (values?: Store, allValues?: Store) => { + const watchValue = options.preserve + ? (allValues ?? formInstance.getFieldsValue(true)) + : (values ?? formInstance.getFieldsValue()); + return typeof dependencies === "function" + ? (dependencies as any)(watchValue) + : getValue(watchValue, getNamePath(dependencies as NamePath)); + }; + + // Seed from the store rather than from `undefined`. Upstream starts empty + // and syncs in a mount effect; reading here removes that extra render (and + // the `undefined` flash a `useWatch`-gated subtree shows because of it). + // Fields that register AFTER this hook still reach us: `registerField` + // notifies every watcher, and that notification is batched onto a + // macrotask, by which point the subscription below is in place. + const [value, setValue] = React.useState<any>(() => + isValidForm + ? read() + : typeof dependencies === "function" + ? (dependencies as any)({}) + : undefined, + ); + + /** + * Stable across renders while always reading the latest `dependencies` / + * `value` / `options`. That combination is the whole point: the watcher is + * registered ONCE (re-registering on every render would churn the store's + * subscriber set), yet a selector function redefined inline at the call + * site must still be the one that runs. + */ + const triggerUpdate = React.useEffectEvent((values?: Store, allValues?: Store) => { + if (!isValidForm) return; + const nextValue = read(values, allValues); + // Compare by serialisation, not identity: the store hands back a fresh + // object on every write, so identity comparison would never settle. + if (stringify(value) !== stringify(nextValue)) { + setValue(nextValue); + } + }); + + // A selector function has no stable serialisation, so it identifies itself. + const flattenDeps = + typeof dependencies === "function" ? dependencies : JSON.stringify(dependencies); + + React.useEffect(() => { + if (!isValidForm) return undefined; + const registerWatch = formInstance.getInternalHooks(HOOK_MARK)?.registerWatch; + const cancel = registerWatch?.((values, allValues) => { + triggerUpdate(values, allValues); + }); + // Re-read once the WATCHED PATH itself changes (not the value — the store + // notifies us for that). This matters for dynamic paths such as + // `useWatch(['rows', index, 'slot'])`, where removing a list row shifts + // the index: without this the hook keeps reporting the OLD row's value + // until some unrelated write happens to notify the watchers. + // The `useState` seed above covers mount, so this only fires on a genuine + // path change: a synchronisation against the form store. + triggerUpdate(); + return cancel; + // `triggerUpdate` is recreated every render; these are the real triggers. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isValidForm, formInstance, flattenDeps]); + + return value; +} + +export default useWatch; + +/** The nearest `<Form>`'s instance, or `undefined` outside one. */ +export function useFormInstance<Values = any>(): FormInstance<Values> { + const { form } = React.useContext(FormItemLayoutContext); + return form as FormInstance<Values>; +} diff --git a/src/components/Form/validate.test.ts b/src/components/Form/validate.test.ts new file mode 100644 index 0000000..c529674 --- /dev/null +++ b/src/components/Form/validate.test.ts @@ -0,0 +1,37 @@ +/** + * `${type}` interpolation for rules whose type is inferred rather than + * declared: `validateRule` normalises `{}` to a `string` rule, so the message + * names the normalised type, not `undefined`. + */ +import { defaultValidateMessages } from "./messages"; +import { validateRules } from "./validate"; +import { describe, expect, it } from "vitest"; + +// Default (parallel) mode always rejects with the per-rule summaries. +const collectErrors = (value: unknown, rules: any[]) => + validateRules(["resource_slots", "cuda.device"], value, rules, { + ...defaultValidateMessages, + }).then( + () => { + throw new Error("validateRules resolved in parallel mode"); + }, + (summaries: { errors: any[] }[]) => summaries.flatMap((s) => s.errors), + ); + +describe("validateRules — inferred-type message interpolation", () => { + it("interpolates ${type} as the normalised type for an empty rule", async () => { + const errors = await collectErrors(1, [{}]); + // No `label` variable: `{label}` falls back to the field's name. + expect(errors).toEqual(["resource_slots.cuda.device is not a valid string"]); + }); + + it("still resolves ${type} for an explicitly typed rule", async () => { + const errors = await collectErrors("one", [{ type: "number" }]); + expect(errors).toEqual(["resource_slots.cuda.device is not a valid number"]); + }); + + it("accepts a number under a declared number rule", async () => { + const errors = await collectErrors(1, [{ type: "number" }]); + expect(errors).toEqual([]); + }); +}); diff --git a/src/components/Form/validate.ts b/src/components/Form/validate.ts new file mode 100644 index 0000000..99838ad --- /dev/null +++ b/src/components/Form/validate.ts @@ -0,0 +1,521 @@ +/** + * Rule engine: a behavioural port of the slice of async-validator that + * rc-field-form uses, for the rule keys `required`, `message`, `validator`, + * `type`, `max`, `min`, `pattern`, `warningOnly` and `whitespace`, and the + * `type` values `number`, `string`, `email`, `url` and `object`. + * + * Two upstream quirks are reproduced on purpose, because the messages users + * see depend on them: + * + * 1. Method dispatch by key shape. async-validator picks its validator from + * `Object.keys(rule)` minus `message`: exactly `['required']` uses the bare + * required check, anything else falls through to `rule.type ?? 'string'` + * (or `'pattern'` for a RegExp `pattern` without a `type`). So `{ max: 64 }` + * on a non-string value also reports "is not a valid string", and + * `{ pattern, max }` ignores `max`. + * 2. `message` replaces, and `''` is a message. Once a rule produces an error, + * a non-null `rule.message` replaces the generated text, so an empty-string + * message yields an error state without an error line. + */ +import type { RuleObject, RuleType, StoreValue, ValidateMessages } from "./interface"; +import type { InternalNamePath } from "./namePath"; +import { toArray } from "./namePath"; +import { isValidElement, cloneElement } from "react"; + +/** Sentinel used when a validator throws synchronously. */ +const CODE_LOGIC_ERROR = "CODE_LOGIC_ERROR"; + +// ============================ Message formatting ============================ + +const formatRegExp = /%[sdj%]/g; + +/** + * async-validator's `format`. Our templates use `${}` placeholders (resolved + * later by `replaceMessage`), so in practice this only matters for the + * function-template form that a locale may supply. + */ +function format(template: any, ...args: any[]): any { + let i = 0; + const len = args.length; + if (typeof template === "function") { + return template(...args); + } + if (typeof template === "string") { + return template.replace(formatRegExp, (x) => { + if (x === "%%") return "%"; + if (i >= len) return x; + switch (x) { + case "%s": + return String(args[i++]); + case "%d": + return String(Number(args[i++])); + case "%j": + try { + return JSON.stringify(args[i++]); + } catch { + return "[Circular]"; + } + default: + return x; + } + }); + } + return template; +} + +/** `I'm ${name}` + `{ name: 'bamboo' }` => `I'm bamboo`. `\${x}` escapes. */ +function replaceMessage(template: string, kv: Record<string, any>): string { + return template.replace(/\\?\$\{\w+\}/g, (str) => { + if (str.startsWith("\\")) { + return str.slice(1); + } + const key = str.slice(2, -1); + return kv[key]; + }); +} + +// ================================ Type checks =============================== + +// http://emailregex.com/ — copied verbatim from async-validator so the same +// addresses pass/fail as before the migration. +const EMAIL_PATTERN = + /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF]+\.)+[a-zA-Z\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF]{2,}))$/; + +let urlReg: RegExp | undefined; +/** Lazily built URL regex (kevva/url-regex), same construction as upstream. */ +function getUrlRegex(): RegExp { + if (urlReg) return urlReg; + const v4 = + "(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)(?:\\.(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)){3}"; + const v6seg = "[a-fA-F\\d]{1,4}"; + const v6List = [ + `(?:${v6seg}:){7}(?:${v6seg}|:)`, + `(?:${v6seg}:){6}(?:${v4}|:${v6seg}|:)`, + `(?:${v6seg}:){5}(?::${v4}|(?::${v6seg}){1,2}|:)`, + `(?:${v6seg}:){4}(?:(?::${v6seg}){0,1}:${v4}|(?::${v6seg}){1,3}|:)`, + `(?:${v6seg}:){3}(?:(?::${v6seg}){0,2}:${v4}|(?::${v6seg}){1,4}|:)`, + `(?:${v6seg}:){2}(?:(?::${v6seg}){0,3}:${v4}|(?::${v6seg}){1,5}|:)`, + `(?:${v6seg}:){1}(?:(?::${v6seg}){0,4}:${v4}|(?::${v6seg}){1,6}|:)`, + `(?::(?:(?::${v6seg}){0,5}:${v4}|(?::${v6seg}){1,7}|:))`, + ]; + const v6 = `(?:${v6List.join("|")})(?:%[0-9a-zA-Z]{1,})?`; + const protocol = "(?:(?:[a-z]+:)?//)"; + const auth = "(?:\\S+(?::\\S*)?@)?"; + const host = "(?:(?:[a-z\\u00a1-\\uffff0-9][-_]*)*[a-z\\u00a1-\\uffff0-9]+)"; + const domain = "(?:\\.(?:[a-z\\u00a1-\\uffff0-9]-*)*[a-z\\u00a1-\\uffff0-9]+)*"; + const tld = "(?:\\.(?:[a-z\\u00a1-\\uffff]{2,}))"; + const port = "(?::\\d{2,5})?"; + const path = '(?:[/?#][^\\s"]*)?'; + const regex = `(?:${protocol}|www\\.)${auth}(?:localhost|${v4}|${v6}|${host}${domain}${tld})${port}${path}`; + urlReg = new RegExp(`(?:^${regex}$)`, "i"); + return urlReg; +} + +const TYPE_CHECKS: Partial<Record<string, (value: any) => boolean>> = { + number: (value) => !isNaN(value) && typeof value === "number", + object: (value) => typeof value === "object" && !Array.isArray(value), + email: (value) => + typeof value === "string" && value.length <= 320 && EMAIL_PATTERN.test(value), + url: (value) => + typeof value === "string" && value.length <= 2048 && getUrlRegex().test(value), +}; + +/** Types whose "empty" notion includes the empty string. */ +function isNativeStringType(type?: string) { + return type === "string" || type === "url" || type === "email" || type === "pattern"; +} + +function isEmptyValue(value: any, type?: string): boolean { + if (value === undefined || value === null) return true; + if (type === "array" && Array.isArray(value) && !value.length) return true; + if (isNativeStringType(type) && typeof value === "string" && !value) return true; + return false; +} + +// ============================= Declarative rules ============================ + +/** + * `type` widens to `string` here because the dispatcher normalises it the way + * async-validator does: an absent type becomes `'string'` and a bare RegExp + * `pattern` becomes `'pattern'` — neither of which is a public `RuleType`. + */ +type NormalizedRule = Omit<RuleObject, "type"> & { type?: string }; + +interface RuleCtx { + rule: NormalizedRule; + value: StoreValue; + name: string; + messages: ValidateMessages; + errors: any[]; +} + +function checkRequired( + { rule, value, name, messages, errors }: RuleCtx, + type?: string, +) { + if (rule.required && isEmptyValue(value, type || rule.type)) { + errors.push(format(messages.required, name)); + } +} + +function checkType({ rule, value, name, messages, errors }: RuleCtx) { + if (rule.required && value === undefined) { + checkRequired({ rule, value, name, messages, errors }); + return; + } + const ruleType = rule.type; + const custom = TYPE_CHECKS[ruleType as string]; + if (custom) { + if (!custom(value)) { + errors.push(format(messages.types?.[ruleType as RuleType], name, ruleType)); + } + } else if (ruleType && typeof value !== ruleType) { + errors.push(format(messages.types?.[ruleType as RuleType], name, ruleType)); + } +} + +// Surrogate pairs count as one character, matching upstream's `range`. +const SP_REGEXP = /[\uD800-\uDBFF][\uDC00-\uDFFF]/g; + +function checkRange({ rule, value, name, messages, errors }: RuleCtx) { + const hasMin = typeof rule.min === "number"; + const hasMax = typeof rule.max === "number"; + let val: number; + let key: "number" | "string" | "array"; + if (typeof value === "number") { + key = "number"; + val = value; + } else if (typeof value === "string") { + key = "string"; + val = value.replace(SP_REGEXP, "_").length; + } else if (Array.isArray(value)) { + key = "array"; + val = value.length; + } else { + // Unsupported type for range validation — upstream bails out silently. + return; + } + const section = messages[key] as Record<string, any> | undefined; + if (hasMin && !hasMax && val < (rule.min as number)) { + errors.push(format(section?.min, name, rule.min)); + } else if (hasMax && !hasMin && val > (rule.max as number)) { + errors.push(format(section?.max, name, rule.max)); + } else if ( + hasMin && + hasMax && + (val < (rule.min as number) || val > (rule.max as number)) + ) { + errors.push(format(section?.range, name, rule.min, rule.max)); + } +} + +function checkPattern({ rule, value, name, messages, errors }: RuleCtx) { + if (rule.pattern instanceof RegExp) { + // Reset in case the RegExp carries the `g` flag — a stateful `lastIndex` + // makes the same rule pass and fail alternately. + rule.pattern.lastIndex = 0; + if (!rule.pattern.test(value)) { + errors.push(format(messages.pattern?.mismatch, name, value, rule.pattern)); + } + } +} + +function checkWhitespace({ value, name, messages, errors }: RuleCtx) { + if (/^\s+$/.test(value) || value === "") { + errors.push(format(messages.whitespace, name)); + } +} + +/** + * Run the declarative validator async-validator would have selected. + * `ctx.rule.type` is already normalised by the caller. + */ +function runDeclarative(method: string, ctx: RuleCtx) { + const { rule, value } = ctx; + switch (method) { + case "required": { + checkRequired(ctx, Array.isArray(value) ? "array" : typeof value); + return; + } + case "number": { + const numeric = { ...ctx, value: value === "" ? undefined : value }; + if (isEmptyValue(numeric.value) && !rule.required) return; + checkRequired(numeric); + if (numeric.value !== undefined) { + checkType(numeric); + checkRange(numeric); + } + return; + } + case "object": { + if (isEmptyValue(value) && !rule.required) return; + checkRequired(ctx); + if (value !== undefined) { + checkType(ctx); + } + return; + } + case "email": + case "url": { + if (isEmptyValue(value, method) && !rule.required) return; + checkRequired(ctx, method); + if (!isEmptyValue(value, method)) { + checkType(ctx); + } + return; + } + case "pattern": { + if (isEmptyValue(value, "string") && !rule.required) return; + checkRequired(ctx); + if (!isEmptyValue(value, "string")) { + checkPattern(ctx); + } + return; + } + // 'string' and anything else fall back to the string validator, which is + // also async-validator's default when no `type` is declared. + default: { + if (isEmptyValue(value, "string") && !rule.required) return; + checkRequired(ctx, "string"); + if (!isEmptyValue(value, "string")) { + checkType(ctx); + checkRange(ctx); + checkPattern(ctx); + if (rule.whitespace === true) { + checkWhitespace(ctx); + } + } + } + } +} + +// ============================= Custom validators ============================ + +/** + * Collapse the promise/callback bridge rc-component's `form` builds around a + * user `validator` into a single promise for the raw "callback argument". + * + * Net semantics preserved from the two-layer upstream wrapping: + * resolve() -> no error + * reject('msg') / Error -> that message + * reject() -> one error whose text is a single space, i.e. + * an error STATE with no readable line + * callback('msg') -> that message, one microtask later + * synchronous throw -> the generic `default` message + * A validator that both returns a promise and calls `callback` has its + * callback ignored, exactly as upstream warns. + */ +function runCustomValidator(rule: RuleObject, value: StoreValue): Promise<any> { + return new Promise((resolve) => { + let settled = false; + let hasPromise = false; + const finish = (arg: any) => { + if (settled) return; + settled = true; + resolve(arg); + }; + const userCallback = (...args: any[]) => { + Promise.resolve().then(() => { + if (!hasPromise) { + finish(args.length ? args[0] : undefined); + } + }); + }; + let returned: any; + try { + returned = rule.validator!(rule, value, userCallback); + } catch (error) { + // Upstream logs and converts a thrown validator into the generic + // `default` message; swallowing it silently would hide real bugs. + console.error(error); + finish(CODE_LOGIC_ERROR); + return; + } + hasPromise = + !!returned && + typeof returned.then === "function" && + typeof returned.catch === "function"; + if (hasPromise) { + returned.then( + () => finish(undefined), + (err: any) => finish(err || " "), + ); + } + }); +} + +// ================================ Rule driver =============================== + +/** Which async-validator method would handle this rule? */ +function pickMethod(rule: RuleObject): string { + const keys = Object.keys(rule).filter((key) => key !== "message"); + if (keys.length === 1 && keys[0] === "required") { + return "required"; + } + if (rule.type === undefined && rule.pattern instanceof RegExp) { + return "pattern"; + } + return rule.type ?? "string"; +} + +async function validateRule( + name: string, + value: StoreValue, + rule: RuleObject, + messages: ValidateMessages, + messageVariables?: Record<string, string>, +): Promise<any[]> { + let rawErrors: any[] = []; + // Kept in sync with the declarative branch so `${type}` interpolates the + // normalised type, not `undefined`, when the rule's type was inferred. + let effectiveRule: NormalizedRule = rule; + + if (rule.validator) { + const cbArg = await runCustomValidator(rule, value); + rawErrors = cbArg === undefined ? [] : Array.isArray(cbArg) ? cbArg : [cbArg]; + } else { + const method = pickMethod(rule); + // `getType` normalises the rule's own `type` before the validators read + // it — `undefined` becomes `'string'`, a bare RegExp becomes `'pattern'`. + effectiveRule = { + ...rule, + type: method === "required" ? rule.type : method, + }; + const ctx: RuleCtx = { + rule: effectiveRule, + value, + name, + messages, + errors: [], + }; + runDeclarative(method, ctx); + rawErrors = ctx.errors; + } + + // A declared `message` replaces the generated text — including `''`. + if (rawErrors.length && rule.message !== undefined && rule.message !== null) { + rawErrors = ([] as any[]).concat(rule.message); + } + + const result = rawErrors.map((entry, index) => { + // Mirrors async-validator's `isErrorObj` + `complementError`: anything + // carrying a defined `.message` (an `Error`, a rejected object) is read + // through it; a thunk is invoked; everything else is the message itself. + const message = + entry && entry.message !== undefined + ? entry.message + : typeof entry === "function" + ? entry() + : entry; + const merged = message === CODE_LOGIC_ERROR ? messages.default : message; + return isValidElement(merged) + ? cloneElement(merged as any, { key: `error_${index}` }) + : merged; + }); + + // `label` falls back to the field's name, so a template never prints + // `undefined` for a field rendered without a label. + const kv: Record<string, any> = { + ...effectiveRule, + name, + label: name, + ...messageVariables, + }; + return result.map((error) => + typeof error === "string" ? replaceMessage(error, kv) : error, + ); +} + +/** + * Validate one field's value against its rules. + * + * ALWAYS REJECTS in the default (parallel) mode — with a `RuleError[]` that + * may well be empty. This is upstream's contract and both `Field` and + * `FormStore` are written against it: the caller partitions the array into + * errors and warnings by each rule's `warningOnly`, and an empty array means + * "valid". `validateFirst === true` instead resolves `[]` on success. + */ +export function validateRules( + namePath: InternalNamePath, + value: StoreValue, + rules: RuleObject[], + messages: ValidateMessages, + validateFirst?: boolean | "parallel", + messageVariables?: Record<string, string>, +): Promise<{ errors: any[]; rule: RuleObject }[]> { + const name = namePath.join("."); + + // Non-warning rules first; ties keep declaration order. This is what makes + // a hard error win over a `warningOnly` rule on the same field. + const filledRules = rules + .map((rule, ruleIndex) => ({ rule, ruleIndex })) + .sort((a, b) => { + if (!!a.rule.warningOnly === !!b.rule.warningOnly) { + return a.ruleIndex - b.ruleIndex; + } + return a.rule.warningOnly ? 1 : -1; + }) + .map(({ rule }) => rule); + + let summaryPromise: Promise<{ errors: any[]; rule: RuleObject }[]>; + + if (validateFirst === true) { + summaryPromise = new Promise((resolve, reject) => { + (async () => { + for (let i = 0; i < filledRules.length; i += 1) { + const rule = filledRules[i]!; + // Serial ON PURPOSE: `validateFirst` means "stop at the first + // failing rule", so the rules must not run in parallel. + const errors = await validateRule( + name, + value, + rule, + messages, + messageVariables, + ); + if (errors.length) { + reject([{ errors, rule }]); + return; + } + } + resolve([]); + })(); + }); + } else { + const rulePromises = filledRules.map((rule) => + validateRule(name, value, rule, messages, messageVariables).then((errors) => ({ + errors, + rule, + })), + ); + summaryPromise = ( + validateFirst ? finishOnFirstFailed(rulePromises) : Promise.all(rulePromises) + ).then((errors) => Promise.reject(errors)); + } + + // Keep the rejection from surfacing as an unhandled rejection; every real + // consumer attaches its own handler. + summaryPromise.catch((e) => e); + return summaryPromise; +} + +function finishOnFirstFailed( + rulePromises: Promise<{ errors: any[]; rule: RuleObject }>[], +): Promise<{ errors: any[]; rule: RuleObject }[]> { + let count = 0; + return new Promise((resolve) => { + rulePromises.forEach((promise) => { + promise.then((ruleError) => { + if (ruleError.errors.length) { + resolve([ruleError]); + } + count += 1; + if (count === rulePromises.length) { + resolve([]); + } + }); + }); + }); +} + +export { toArray }; diff --git a/src/i18n/catalog.ts b/src/i18n/catalog.ts index 9a1775f..902dda6 100644 --- a/src/i18n/catalog.ts +++ b/src/i18n/catalog.ts @@ -25,8 +25,10 @@ import type { Catalog, MessageEntry } from "@astryxdesign/core/i18n"; // These modules import `defineMessages` back from here. The cycle is safe: // it is a function declaration, so it is bound before either module runs. import { booleanTokenMessages } from "../components/BooleanToken/BooleanToken.messages"; +import { bulkEditFormItemMessages } from "../components/BulkEditFormItem/BulkEditFormItem.messages"; import { colorPickerMessages } from "../components/ColorPicker/ColorPicker.messages"; import { deleteConfirmModalMessages } from "../components/DeleteConfirmModal/DeleteConfirmModal.messages"; +import { formMessages } from "../components/Form/Form.messages"; import { notificationStackMessages } from "../components/NotificationStack/NotificationStack.messages"; import { pageHeaderMessages } from "../components/PageHeader/PageHeader.messages"; import { selectionLabelMessages } from "../components/SelectionLabel/SelectionLabel.messages"; @@ -57,8 +59,10 @@ export function defineMessages<const T extends Record<UicMessageKey, MessageEntr export const uiCommonCatalog: Catalog = { ...commonMessages, ...booleanTokenMessages, + ...bulkEditFormItemMessages, ...colorPickerMessages, ...deleteConfirmModalMessages, + ...formMessages, ...notificationStackMessages, ...numberStepperMessages, ...pageHeaderMessages, diff --git a/src/i18n/locales/de-DE.json b/src/i18n/locales/de-DE.json index 09e121b..c06814d 100644 --- a/src/i18n/locales/de-DE.json +++ b/src/i18n/locales/de-DE.json @@ -94,5 +94,105 @@ "uic.UnitGrid.useColor": { "defaultMessage": "Farbe {index} verwenden", "description": "Accessible name of one palette swatch in a unit grid; {index} is its 1-based position" + }, + "uic.Form.optional": { + "defaultMessage": "(optional)", + "description": "Suffix after the label of an optional field when requiredMark=\"optional\"" + }, + "uic.Form.default": { + "defaultMessage": "Feld-Validierungsfehler: {label}", + "description": "Validation error when a rule failed without a more specific message" + }, + "uic.Form.required": { + "defaultMessage": "Bitte geben Sie {label} an", + "description": "Validation error for an empty required field" + }, + "uic.Form.whitespace": { + "defaultMessage": "{label} darf kein Leerzeichen sein", + "description": "Validation error for a required field that holds only whitespace" + }, + "uic.Form.typeString": { + "defaultMessage": "{label} ist nicht gültig. {type} erwartet", + "description": "Validation error for a value that is not a string; {type} is the type name" + }, + "uic.Form.typeNumber": { + "defaultMessage": "{label} ist nicht gültig. {type} erwartet", + "description": "Validation error for a value that is not a number; {type} is the type name" + }, + "uic.Form.typeObject": { + "defaultMessage": "{label} ist nicht gültig. {type} erwartet", + "description": "Validation error for a value that is not an object; {type} is the type name" + }, + "uic.Form.typeEmail": { + "defaultMessage": "{label} ist nicht gültig. {type} erwartet", + "description": "Validation error for an invalid email address; {type} is the type name" + }, + "uic.Form.typeUrl": { + "defaultMessage": "{label} ist nicht gültig. {type} erwartet", + "description": "Validation error for an invalid URL; {type} is the type name" + }, + "uic.Form.stringLen": { + "defaultMessage": "{label} muss genau {len} Zeichen lang sein", + "description": "Validation error for text of the wrong exact length" + }, + "uic.Form.stringMin": { + "defaultMessage": "{label} muss mindestens {min} Zeichen lang sein", + "description": "Validation error for text shorter than the minimum" + }, + "uic.Form.stringMax": { + "defaultMessage": "{label} darf höchstens {max} Zeichen lang sein", + "description": "Validation error for text longer than the maximum" + }, + "uic.Form.stringRange": { + "defaultMessage": "{label} muss zwischen {min} und {max} Zeichen lang sein", + "description": "Validation error for text outside a length range" + }, + "uic.Form.numberLen": { + "defaultMessage": "{label} muss gleich {len} sein", + "description": "Validation error for a number that must equal a value" + }, + "uic.Form.numberMin": { + "defaultMessage": "{label} muss mindestens {min} sein", + "description": "Validation error for a number below the minimum" + }, + "uic.Form.numberMax": { + "defaultMessage": "{label} darf maximal {max} sein", + "description": "Validation error for a number above the maximum" + }, + "uic.Form.numberRange": { + "defaultMessage": "{label} muss zwischen {min} und {max} liegen", + "description": "Validation error for a number outside a range" + }, + "uic.Form.arrayLen": { + "defaultMessage": "Es müssen {len} {label} sein", + "description": "Validation error for a list with the wrong number of items" + }, + "uic.Form.arrayMin": { + "defaultMessage": "Es müssen mindestens {min} {label} sein", + "description": "Validation error for a list with too few items" + }, + "uic.Form.arrayMax": { + "defaultMessage": "Es dürfen maximal {max} {label} sein", + "description": "Validation error for a list with too many items" + }, + "uic.Form.arrayRange": { + "defaultMessage": "Die Anzahl an {label} muss zwischen {min} und {max} liegen", + "description": "Validation error for a list whose item count is outside a range" + }, + "uic.Form.patternMismatch": { + "defaultMessage": "{label} entspricht nicht dem {pattern} Muster", + "description": "Validation error for text that does not match a pattern" + }, + "uic.BulkEditFormItem.keepAsIs": { + "defaultMessage": "Unverändert lassen", + "description": "Placeholder of a bulk-edit field that leaves every selected item's current value unchanged" + }, + "uic.BulkEditFormItem.clear": { + "defaultMessage": "Löschen", + "description": "Link that clears the field on every selected item, and the field's placeholder once cleared" + }, + "uic.BulkEditFormItem.undoChanges": { + "defaultMessage": "Änderungen rückgängig machen", + "description": "Link that returns a bulk-edit field to keeping the current values" } } diff --git a/src/i18n/locales/el-GR.json b/src/i18n/locales/el-GR.json index 50861c4..a4750fd 100644 --- a/src/i18n/locales/el-GR.json +++ b/src/i18n/locales/el-GR.json @@ -94,5 +94,105 @@ "uic.UnitGrid.useColor": { "defaultMessage": "Χρήση χρώματος {index}", "description": "Accessible name of one palette swatch in a unit grid; {index} is its 1-based position" + }, + "uic.Form.optional": { + "defaultMessage": "(προαιρετικό)", + "description": "Suffix after the label of an optional field when requiredMark=\"optional\"" + }, + "uic.Form.default": { + "defaultMessage": "Σφάλμα επικύρωσης πεδίου για {label}", + "description": "Validation error when a rule failed without a more specific message" + }, + "uic.Form.required": { + "defaultMessage": "Παρακαλώ εισάγετε {label}", + "description": "Validation error for an empty required field" + }, + "uic.Form.whitespace": { + "defaultMessage": "Το {label} δεν μπορεί να είναι κενός χαρακτήρας", + "description": "Validation error for a required field that holds only whitespace" + }, + "uic.Form.typeString": { + "defaultMessage": "Το {label} δεν είναι έγκυρο {type}", + "description": "Validation error for a value that is not a string; {type} is the type name" + }, + "uic.Form.typeNumber": { + "defaultMessage": "Το {label} δεν είναι έγκυρο {type}", + "description": "Validation error for a value that is not a number; {type} is the type name" + }, + "uic.Form.typeObject": { + "defaultMessage": "Το {label} δεν είναι έγκυρο {type}", + "description": "Validation error for a value that is not an object; {type} is the type name" + }, + "uic.Form.typeEmail": { + "defaultMessage": "Το {label} δεν είναι έγκυρο {type}", + "description": "Validation error for an invalid email address; {type} is the type name" + }, + "uic.Form.typeUrl": { + "defaultMessage": "Το {label} δεν είναι έγκυρο {type}", + "description": "Validation error for an invalid URL; {type} is the type name" + }, + "uic.Form.stringLen": { + "defaultMessage": "Το {label} πρέπει να είναι {len} χαρακτήρες", + "description": "Validation error for text of the wrong exact length" + }, + "uic.Form.stringMin": { + "defaultMessage": "Το {label} πρέπει να είναι τουλάχιστον {min} χαρακτήρες", + "description": "Validation error for text shorter than the minimum" + }, + "uic.Form.stringMax": { + "defaultMessage": "Το {label} πρέπει να είναι το πολύ {max} χαρακτήρες", + "description": "Validation error for text longer than the maximum" + }, + "uic.Form.stringRange": { + "defaultMessage": "Το {label} πρέπει να είναι μεταξύ {min}-{max} χαρακτήρων", + "description": "Validation error for text outside a length range" + }, + "uic.Form.numberLen": { + "defaultMessage": "Το {label} πρέπει να είναι ίσο με {len}", + "description": "Validation error for a number that must equal a value" + }, + "uic.Form.numberMin": { + "defaultMessage": "Το {label} πρέπει να είναι τουλάχιστον {min}", + "description": "Validation error for a number below the minimum" + }, + "uic.Form.numberMax": { + "defaultMessage": "Το {label} πρέπει να είναι το πολύ {max}", + "description": "Validation error for a number above the maximum" + }, + "uic.Form.numberRange": { + "defaultMessage": "Το {label} πρέπει να είναι μεταξύ {min}-{max}", + "description": "Validation error for a number outside a range" + }, + "uic.Form.arrayLen": { + "defaultMessage": "Πρέπει να είναι {len} {label}", + "description": "Validation error for a list with the wrong number of items" + }, + "uic.Form.arrayMin": { + "defaultMessage": "Τουλάχιστον {min} {label}", + "description": "Validation error for a list with too few items" + }, + "uic.Form.arrayMax": { + "defaultMessage": "Το πολύ {max} {label}", + "description": "Validation error for a list with too many items" + }, + "uic.Form.arrayRange": { + "defaultMessage": "Το ποσό του {label} πρέπει να είναι μεταξύ {min}-{max}", + "description": "Validation error for a list whose item count is outside a range" + }, + "uic.Form.patternMismatch": { + "defaultMessage": "Το {label} δεν ταιριάζει με το μοτίβο {pattern}", + "description": "Validation error for text that does not match a pattern" + }, + "uic.BulkEditFormItem.keepAsIs": { + "defaultMessage": "Διατήρηση ως έχει", + "description": "Placeholder of a bulk-edit field that leaves every selected item's current value unchanged" + }, + "uic.BulkEditFormItem.clear": { + "defaultMessage": "Καθαρισμός", + "description": "Link that clears the field on every selected item, and the field's placeholder once cleared" + }, + "uic.BulkEditFormItem.undoChanges": { + "defaultMessage": "Αναίρεση αλλαγών", + "description": "Link that returns a bulk-edit field to keeping the current values" } } diff --git a/src/i18n/locales/es-ES.json b/src/i18n/locales/es-ES.json index b7af326..2f5146a 100644 --- a/src/i18n/locales/es-ES.json +++ b/src/i18n/locales/es-ES.json @@ -94,5 +94,105 @@ "uic.UnitGrid.useColor": { "defaultMessage": "Usar el color {index}", "description": "Accessible name of one palette swatch in a unit grid; {index} is its 1-based position" + }, + "uic.Form.optional": { + "defaultMessage": "(opcional)", + "description": "Suffix after the label of an optional field when requiredMark=\"optional\"" + }, + "uic.Form.default": { + "defaultMessage": "Error de validación del campo {label}", + "description": "Validation error when a rule failed without a more specific message" + }, + "uic.Form.required": { + "defaultMessage": "Por favor, rellena {label}", + "description": "Validation error for an empty required field" + }, + "uic.Form.whitespace": { + "defaultMessage": "{label} no puede ser un carácter en blanco", + "description": "Validation error for a required field that holds only whitespace" + }, + "uic.Form.typeString": { + "defaultMessage": "{label} no es un {type} válido", + "description": "Validation error for a value that is not a string; {type} is the type name" + }, + "uic.Form.typeNumber": { + "defaultMessage": "{label} no es un {type} válido", + "description": "Validation error for a value that is not a number; {type} is the type name" + }, + "uic.Form.typeObject": { + "defaultMessage": "{label} no es un {type} válido", + "description": "Validation error for a value that is not an object; {type} is the type name" + }, + "uic.Form.typeEmail": { + "defaultMessage": "{label} no es un {type} válido", + "description": "Validation error for an invalid email address; {type} is the type name" + }, + "uic.Form.typeUrl": { + "defaultMessage": "{label} no es un {type} válido", + "description": "Validation error for an invalid URL; {type} is the type name" + }, + "uic.Form.stringLen": { + "defaultMessage": "{label} debe tener {len} caracteres", + "description": "Validation error for text of the wrong exact length" + }, + "uic.Form.stringMin": { + "defaultMessage": "{label} debe tener al menos {min} caracteres", + "description": "Validation error for text shorter than the minimum" + }, + "uic.Form.stringMax": { + "defaultMessage": "{label} debe tener hasta {max} caracteres", + "description": "Validation error for text longer than the maximum" + }, + "uic.Form.stringRange": { + "defaultMessage": "{label} debe tener entre {min}-{max} caracteres", + "description": "Validation error for text outside a length range" + }, + "uic.Form.numberLen": { + "defaultMessage": "{label} debe ser igual a {len}", + "description": "Validation error for a number that must equal a value" + }, + "uic.Form.numberMin": { + "defaultMessage": "{label} valor mínimo es {min}", + "description": "Validation error for a number below the minimum" + }, + "uic.Form.numberMax": { + "defaultMessage": "{label} valor máximo es {max}", + "description": "Validation error for a number above the maximum" + }, + "uic.Form.numberRange": { + "defaultMessage": "{label} debe ser entre {min}-{max}", + "description": "Validation error for a number outside a range" + }, + "uic.Form.arrayLen": { + "defaultMessage": "Debe ser {len} {label}", + "description": "Validation error for a list with the wrong number of items" + }, + "uic.Form.arrayMin": { + "defaultMessage": "Al menos {min} {label}", + "description": "Validation error for a list with too few items" + }, + "uic.Form.arrayMax": { + "defaultMessage": "Como máximo {max} {label}", + "description": "Validation error for a list with too many items" + }, + "uic.Form.arrayRange": { + "defaultMessage": "El valor de {label} debe estar entre {min}-{max}", + "description": "Validation error for a list whose item count is outside a range" + }, + "uic.Form.patternMismatch": { + "defaultMessage": "{label} no coincide con el patrón {pattern}", + "description": "Validation error for text that does not match a pattern" + }, + "uic.BulkEditFormItem.keepAsIs": { + "defaultMessage": "Mantener como está", + "description": "Placeholder of a bulk-edit field that leaves every selected item's current value unchanged" + }, + "uic.BulkEditFormItem.clear": { + "defaultMessage": "Limpiar", + "description": "Link that clears the field on every selected item, and the field's placeholder once cleared" + }, + "uic.BulkEditFormItem.undoChanges": { + "defaultMessage": "Deshacer cambios", + "description": "Link that returns a bulk-edit field to keeping the current values" } } diff --git a/src/i18n/locales/fi-FI.json b/src/i18n/locales/fi-FI.json index 23e151a..ffbe422 100644 --- a/src/i18n/locales/fi-FI.json +++ b/src/i18n/locales/fi-FI.json @@ -94,5 +94,105 @@ "uic.UnitGrid.useColor": { "defaultMessage": "Käytä väriä {index}", "description": "Accessible name of one palette swatch in a unit grid; {index} is its 1-based position" + }, + "uic.Form.optional": { + "defaultMessage": "(optional)", + "description": "Suffix after the label of an optional field when requiredMark=\"optional\"" + }, + "uic.Form.default": { + "defaultMessage": "Kentän {label} vahvistus epäonnistui", + "description": "Validation error when a rule failed without a more specific message" + }, + "uic.Form.required": { + "defaultMessage": "Syötä {label}", + "description": "Validation error for an empty required field" + }, + "uic.Form.whitespace": { + "defaultMessage": "{label} ei voi olla tyhjä", + "description": "Validation error for a required field that holds only whitespace" + }, + "uic.Form.typeString": { + "defaultMessage": "{label} ei ole kelvollinen {type}", + "description": "Validation error for a value that is not a string; {type} is the type name" + }, + "uic.Form.typeNumber": { + "defaultMessage": "{label} ei ole kelvollinen {type}", + "description": "Validation error for a value that is not a number; {type} is the type name" + }, + "uic.Form.typeObject": { + "defaultMessage": "{label} ei ole kelvollinen {type}", + "description": "Validation error for a value that is not an object; {type} is the type name" + }, + "uic.Form.typeEmail": { + "defaultMessage": "{label} ei ole kelvollinen {type}", + "description": "Validation error for an invalid email address; {type} is the type name" + }, + "uic.Form.typeUrl": { + "defaultMessage": "{label} ei ole kelvollinen {type}", + "description": "Validation error for an invalid URL; {type} is the type name" + }, + "uic.Form.stringLen": { + "defaultMessage": "{label} täytyy olla täsmälleen {len} merkkiä pitkä", + "description": "Validation error for text of the wrong exact length" + }, + "uic.Form.stringMin": { + "defaultMessage": "{label} täytyy olla vähintään {min} merkkiä pitkä", + "description": "Validation error for text shorter than the minimum" + }, + "uic.Form.stringMax": { + "defaultMessage": "{label} saa olla enintään {max} merkkiä pitkä", + "description": "Validation error for text longer than the maximum" + }, + "uic.Form.stringRange": { + "defaultMessage": "{label} täytyy olla {min}–{max} merkkiä pitkä", + "description": "Validation error for text outside a length range" + }, + "uic.Form.numberLen": { + "defaultMessage": "{label} täytyy olla yhtä suuri kuin {len}", + "description": "Validation error for a number that must equal a value" + }, + "uic.Form.numberMin": { + "defaultMessage": "{label} täytyy olla vähintään {min}", + "description": "Validation error for a number below the minimum" + }, + "uic.Form.numberMax": { + "defaultMessage": "{label} saa olla enintään {max}", + "description": "Validation error for a number above the maximum" + }, + "uic.Form.numberRange": { + "defaultMessage": "{label} täytyy olla välillä {min}–{max}", + "description": "Validation error for a number outside a range" + }, + "uic.Form.arrayLen": { + "defaultMessage": "{label} täytyy sisältää täsmälleen {len} kohdetta", + "description": "Validation error for a list with the wrong number of items" + }, + "uic.Form.arrayMin": { + "defaultMessage": "{label} täytyy sisältää vähintään {min} kohdetta", + "description": "Validation error for a list with too few items" + }, + "uic.Form.arrayMax": { + "defaultMessage": "{label} saa sisältää enintään {max} kohdetta", + "description": "Validation error for a list with too many items" + }, + "uic.Form.arrayRange": { + "defaultMessage": "{label} täytyy sisältää {min}–{max} kohdetta", + "description": "Validation error for a list whose item count is outside a range" + }, + "uic.Form.patternMismatch": { + "defaultMessage": "{label} ei vastaa mallia {pattern}", + "description": "Validation error for text that does not match a pattern" + }, + "uic.BulkEditFormItem.keepAsIs": { + "defaultMessage": "Pidä ennallaan", + "description": "Placeholder of a bulk-edit field that leaves every selected item's current value unchanged" + }, + "uic.BulkEditFormItem.clear": { + "defaultMessage": "Tyhjennä", + "description": "Link that clears the field on every selected item, and the field's placeholder once cleared" + }, + "uic.BulkEditFormItem.undoChanges": { + "defaultMessage": "Kumoa muutokset", + "description": "Link that returns a bulk-edit field to keeping the current values" } } diff --git a/src/i18n/locales/fr-FR.json b/src/i18n/locales/fr-FR.json index 7863138..c287ab1 100644 --- a/src/i18n/locales/fr-FR.json +++ b/src/i18n/locales/fr-FR.json @@ -94,5 +94,105 @@ "uic.UnitGrid.useColor": { "defaultMessage": "Utiliser la couleur {index}", "description": "Accessible name of one palette swatch in a unit grid; {index} is its 1-based position" + }, + "uic.Form.optional": { + "defaultMessage": "(optionnel)", + "description": "Suffix after the label of an optional field when requiredMark=\"optional\"" + }, + "uic.Form.default": { + "defaultMessage": "Erreur de validation pour le champ {label}", + "description": "Validation error when a rule failed without a more specific message" + }, + "uic.Form.required": { + "defaultMessage": "Le champ {label} est obligatoire", + "description": "Validation error for an empty required field" + }, + "uic.Form.whitespace": { + "defaultMessage": "La valeur du champ {label} ne peut pas être vide", + "description": "Validation error for a required field that holds only whitespace" + }, + "uic.Form.typeString": { + "defaultMessage": "La valeur du champ {label} n'est pas valide pour le type {type}", + "description": "Validation error for a value that is not a string; {type} is the type name" + }, + "uic.Form.typeNumber": { + "defaultMessage": "La valeur du champ {label} n'est pas valide pour le type {type}", + "description": "Validation error for a value that is not a number; {type} is the type name" + }, + "uic.Form.typeObject": { + "defaultMessage": "La valeur du champ {label} n'est pas valide pour le type {type}", + "description": "Validation error for a value that is not an object; {type} is the type name" + }, + "uic.Form.typeEmail": { + "defaultMessage": "La valeur du champ {label} n'est pas valide pour le type {type}", + "description": "Validation error for an invalid email address; {type} is the type name" + }, + "uic.Form.typeUrl": { + "defaultMessage": "La valeur du champ {label} n'est pas valide pour le type {type}", + "description": "Validation error for an invalid URL; {type} is the type name" + }, + "uic.Form.stringLen": { + "defaultMessage": "La taille du champ {label} doit être de {len} caractères", + "description": "Validation error for text of the wrong exact length" + }, + "uic.Form.stringMin": { + "defaultMessage": "La taille du champ {label} doit être au minimum de {min} caractères", + "description": "Validation error for text shorter than the minimum" + }, + "uic.Form.stringMax": { + "defaultMessage": "La taille du champ {label} doit être au maximum de {max} caractères", + "description": "Validation error for text longer than the maximum" + }, + "uic.Form.stringRange": { + "defaultMessage": "La taille du champ {label} doit être entre {min} et {max} caractères", + "description": "Validation error for text outside a length range" + }, + "uic.Form.numberLen": { + "defaultMessage": "La valeur du champ {label} doit être égale à {len}", + "description": "Validation error for a number that must equal a value" + }, + "uic.Form.numberMin": { + "defaultMessage": "La valeur du champ {label} doit être plus grande que {min}", + "description": "Validation error for a number below the minimum" + }, + "uic.Form.numberMax": { + "defaultMessage": "La valeur du champ {label} doit être plus petit que {max}", + "description": "Validation error for a number above the maximum" + }, + "uic.Form.numberRange": { + "defaultMessage": "La valeur du champ {label} doit être entre {min} et {max}", + "description": "Validation error for a number outside a range" + }, + "uic.Form.arrayLen": { + "defaultMessage": "La taille du tableau {label} doit être de {len}", + "description": "Validation error for a list with the wrong number of items" + }, + "uic.Form.arrayMin": { + "defaultMessage": "La taille du tableau {label} doit être au minimum de {min}", + "description": "Validation error for a list with too few items" + }, + "uic.Form.arrayMax": { + "defaultMessage": "La taille du tableau {label} doit être au maximum de {max}", + "description": "Validation error for a list with too many items" + }, + "uic.Form.arrayRange": { + "defaultMessage": "La taille du tableau {label} doit être entre {min}-{max}", + "description": "Validation error for a list whose item count is outside a range" + }, + "uic.Form.patternMismatch": { + "defaultMessage": "La valeur du champ {label} ne correspond pas au modèle {pattern}", + "description": "Validation error for text that does not match a pattern" + }, + "uic.BulkEditFormItem.keepAsIs": { + "defaultMessage": "Conserver tel quel", + "description": "Placeholder of a bulk-edit field that leaves every selected item's current value unchanged" + }, + "uic.BulkEditFormItem.clear": { + "defaultMessage": "Effacer", + "description": "Link that clears the field on every selected item, and the field's placeholder once cleared" + }, + "uic.BulkEditFormItem.undoChanges": { + "defaultMessage": "Annuler les modifications", + "description": "Link that returns a bulk-edit field to keeping the current values" } } diff --git a/src/i18n/locales/id-ID.json b/src/i18n/locales/id-ID.json index 8141ded..a73cfc7 100644 --- a/src/i18n/locales/id-ID.json +++ b/src/i18n/locales/id-ID.json @@ -94,5 +94,105 @@ "uic.UnitGrid.useColor": { "defaultMessage": "Gunakan warna {index}", "description": "Accessible name of one palette swatch in a unit grid; {index} is its 1-based position" + }, + "uic.Form.optional": { + "defaultMessage": "(optional)", + "description": "Suffix after the label of an optional field when requiredMark=\"optional\"" + }, + "uic.Form.default": { + "defaultMessage": "Kesalahan validasi untuk {label}", + "description": "Validation error when a rule failed without a more specific message" + }, + "uic.Form.required": { + "defaultMessage": "Tolong masukkan {label}", + "description": "Validation error for an empty required field" + }, + "uic.Form.whitespace": { + "defaultMessage": "{label} tidak boleh berupa karakter kosong", + "description": "Validation error for a required field that holds only whitespace" + }, + "uic.Form.typeString": { + "defaultMessage": "{label} tidak valid {type}", + "description": "Validation error for a value that is not a string; {type} is the type name" + }, + "uic.Form.typeNumber": { + "defaultMessage": "{label} tidak valid {type}", + "description": "Validation error for a value that is not a number; {type} is the type name" + }, + "uic.Form.typeObject": { + "defaultMessage": "{label} tidak valid {type}", + "description": "Validation error for a value that is not an object; {type} is the type name" + }, + "uic.Form.typeEmail": { + "defaultMessage": "{label} tidak valid {type}", + "description": "Validation error for an invalid email address; {type} is the type name" + }, + "uic.Form.typeUrl": { + "defaultMessage": "{label} tidak valid {type}", + "description": "Validation error for an invalid URL; {type} is the type name" + }, + "uic.Form.stringLen": { + "defaultMessage": "{label} harus berupa {len} karakter", + "description": "Validation error for text of the wrong exact length" + }, + "uic.Form.stringMin": { + "defaultMessage": "{label} harus minimal {min} karakter", + "description": "Validation error for text shorter than the minimum" + }, + "uic.Form.stringMax": { + "defaultMessage": "{label} harus maksimal {max} karakter", + "description": "Validation error for text longer than the maximum" + }, + "uic.Form.stringRange": { + "defaultMessage": "{label} harus diantara {min}-{max} karakter", + "description": "Validation error for text outside a length range" + }, + "uic.Form.numberLen": { + "defaultMessage": "{label} harus sama dengan {len}", + "description": "Validation error for a number that must equal a value" + }, + "uic.Form.numberMin": { + "defaultMessage": "{label} harus minimal {min}", + "description": "Validation error for a number below the minimum" + }, + "uic.Form.numberMax": { + "defaultMessage": "{label} harus maksimal {max}", + "description": "Validation error for a number above the maximum" + }, + "uic.Form.numberRange": { + "defaultMessage": "{label} harus di antara {min}-{max}", + "description": "Validation error for a number outside a range" + }, + "uic.Form.arrayLen": { + "defaultMessage": "Harus {len} {label}", + "description": "Validation error for a list with the wrong number of items" + }, + "uic.Form.arrayMin": { + "defaultMessage": "Minimal {min} {label}", + "description": "Validation error for a list with too few items" + }, + "uic.Form.arrayMax": { + "defaultMessage": "Maksimal {max} {label}", + "description": "Validation error for a list with too many items" + }, + "uic.Form.arrayRange": { + "defaultMessage": "Jumlah {label} harus di antara {min}-{max}", + "description": "Validation error for a list whose item count is outside a range" + }, + "uic.Form.patternMismatch": { + "defaultMessage": "{label} tidak sesuai dengan pola {pattern}", + "description": "Validation error for text that does not match a pattern" + }, + "uic.BulkEditFormItem.keepAsIs": { + "defaultMessage": "Pertahankan", + "description": "Placeholder of a bulk-edit field that leaves every selected item's current value unchanged" + }, + "uic.BulkEditFormItem.clear": { + "defaultMessage": "Hapus", + "description": "Link that clears the field on every selected item, and the field's placeholder once cleared" + }, + "uic.BulkEditFormItem.undoChanges": { + "defaultMessage": "Batalkan perubahan", + "description": "Link that returns a bulk-edit field to keeping the current values" } } diff --git a/src/i18n/locales/it-IT.json b/src/i18n/locales/it-IT.json index 525d9dc..dffdf35 100644 --- a/src/i18n/locales/it-IT.json +++ b/src/i18n/locales/it-IT.json @@ -94,5 +94,105 @@ "uic.UnitGrid.useColor": { "defaultMessage": "Usa il colore {index}", "description": "Accessible name of one palette swatch in a unit grid; {index} is its 1-based position" + }, + "uic.Form.optional": { + "defaultMessage": "(opzionale)", + "description": "Suffix after the label of an optional field when requiredMark=\"optional\"" + }, + "uic.Form.default": { + "defaultMessage": "Errore di convalida del campo {label}", + "description": "Validation error when a rule failed without a more specific message" + }, + "uic.Form.required": { + "defaultMessage": "Si prega di inserire {label}", + "description": "Validation error for an empty required field" + }, + "uic.Form.whitespace": { + "defaultMessage": "{label} non può essere un carattere vuoto", + "description": "Validation error for a required field that holds only whitespace" + }, + "uic.Form.typeString": { + "defaultMessage": " {label} non è un {type} valido", + "description": "Validation error for a value that is not a string; {type} is the type name" + }, + "uic.Form.typeNumber": { + "defaultMessage": " {label} non è un {type} valido", + "description": "Validation error for a value that is not a number; {type} is the type name" + }, + "uic.Form.typeObject": { + "defaultMessage": " {label} non è un {type} valido", + "description": "Validation error for a value that is not an object; {type} is the type name" + }, + "uic.Form.typeEmail": { + "defaultMessage": " {label} non è un {type} valido", + "description": "Validation error for an invalid email address; {type} is the type name" + }, + "uic.Form.typeUrl": { + "defaultMessage": " {label} non è un {type} valido", + "description": "Validation error for an invalid URL; {type} is the type name" + }, + "uic.Form.stringLen": { + "defaultMessage": "{label} deve avere {len} caratteri", + "description": "Validation error for text of the wrong exact length" + }, + "uic.Form.stringMin": { + "defaultMessage": "{label} deve contenere almeno {min} caratteri", + "description": "Validation error for text shorter than the minimum" + }, + "uic.Form.stringMax": { + "defaultMessage": "{label} deve contenere fino a {max} caratteri", + "description": "Validation error for text longer than the maximum" + }, + "uic.Form.stringRange": { + "defaultMessage": "{label} deve contenere tra {min}-{max} caratteri", + "description": "Validation error for text outside a length range" + }, + "uic.Form.numberLen": { + "defaultMessage": "{label} deve essere uguale a {len}", + "description": "Validation error for a number that must equal a value" + }, + "uic.Form.numberMin": { + "defaultMessage": "{label} valore minimo è {min}", + "description": "Validation error for a number below the minimum" + }, + "uic.Form.numberMax": { + "defaultMessage": "{label} valor e massimo è {max}", + "description": "Validation error for a number above the maximum" + }, + "uic.Form.numberRange": { + "defaultMessage": "{label} deve essere compreso tra {min}-{max}", + "description": "Validation error for a number outside a range" + }, + "uic.Form.arrayLen": { + "defaultMessage": "Deve essere {len} {label}", + "description": "Validation error for a list with the wrong number of items" + }, + "uic.Form.arrayMin": { + "defaultMessage": "Almeno {min} {label}", + "description": "Validation error for a list with too few items" + }, + "uic.Form.arrayMax": { + "defaultMessage": "Massimo {max} {label}", + "description": "Validation error for a list with too many items" + }, + "uic.Form.arrayRange": { + "defaultMessage": "Il totale di {label} deve essere compreso tra {min}-{max}", + "description": "Validation error for a list whose item count is outside a range" + }, + "uic.Form.patternMismatch": { + "defaultMessage": "{label} non corrisponde al modello {pattern}", + "description": "Validation error for text that does not match a pattern" + }, + "uic.BulkEditFormItem.keepAsIs": { + "defaultMessage": "Mantieni invariato", + "description": "Placeholder of a bulk-edit field that leaves every selected item's current value unchanged" + }, + "uic.BulkEditFormItem.clear": { + "defaultMessage": "Cancella", + "description": "Link that clears the field on every selected item, and the field's placeholder once cleared" + }, + "uic.BulkEditFormItem.undoChanges": { + "defaultMessage": "Annulla modifiche", + "description": "Link that returns a bulk-edit field to keeping the current values" } } diff --git a/src/i18n/locales/ja-JP.json b/src/i18n/locales/ja-JP.json index be383d2..a85357f 100644 --- a/src/i18n/locales/ja-JP.json +++ b/src/i18n/locales/ja-JP.json @@ -134,5 +134,105 @@ "uic.UnitGrid.useColor": { "defaultMessage": "色{index}を使用", "description": "Accessible name of one palette swatch in a unit grid; {index} is its 1-based position" + }, + "uic.Form.optional": { + "defaultMessage": "(オプション)", + "description": "Suffix after the label of an optional field when requiredMark=\"optional\"" + }, + "uic.Form.default": { + "defaultMessage": "{label}のフィールド検証エラー", + "description": "Validation error when a rule failed without a more specific message" + }, + "uic.Form.required": { + "defaultMessage": "{label}を入力してください", + "description": "Validation error for an empty required field" + }, + "uic.Form.whitespace": { + "defaultMessage": "{label}は空白文字にすることはできません", + "description": "Validation error for a required field that holds only whitespace" + }, + "uic.Form.typeString": { + "defaultMessage": "{label}は有効な{type}ではありません", + "description": "Validation error for a value that is not a string; {type} is the type name" + }, + "uic.Form.typeNumber": { + "defaultMessage": "{label}は有効な{type}ではありません", + "description": "Validation error for a value that is not a number; {type} is the type name" + }, + "uic.Form.typeObject": { + "defaultMessage": "{label}は有効な{type}ではありません", + "description": "Validation error for a value that is not an object; {type} is the type name" + }, + "uic.Form.typeEmail": { + "defaultMessage": "{label}は有効な{type}ではありません", + "description": "Validation error for an invalid email address; {type} is the type name" + }, + "uic.Form.typeUrl": { + "defaultMessage": "{label}は有効な{type}ではありません", + "description": "Validation error for an invalid URL; {type} is the type name" + }, + "uic.Form.stringLen": { + "defaultMessage": "{label}は{len}文字である必要があります", + "description": "Validation error for text of the wrong exact length" + }, + "uic.Form.stringMin": { + "defaultMessage": "{label}は{min}文字以上である必要があります", + "description": "Validation error for text shorter than the minimum" + }, + "uic.Form.stringMax": { + "defaultMessage": "{label}は{max}文字以下である必要があります", + "description": "Validation error for text longer than the maximum" + }, + "uic.Form.stringRange": { + "defaultMessage": "{label}は{min}-{max}文字の範囲である必要があります", + "description": "Validation error for text outside a length range" + }, + "uic.Form.numberLen": { + "defaultMessage": "{label}は{len}と等しい必要があります", + "description": "Validation error for a number that must equal a value" + }, + "uic.Form.numberMin": { + "defaultMessage": "{label}の最小値は{min}です", + "description": "Validation error for a number below the minimum" + }, + "uic.Form.numberMax": { + "defaultMessage": "{label}の最大値は{max}です", + "description": "Validation error for a number above the maximum" + }, + "uic.Form.numberRange": { + "defaultMessage": "{label}は{min}-{max}の範囲である必要があります", + "description": "Validation error for a number outside a range" + }, + "uic.Form.arrayLen": { + "defaultMessage": "{label}は{len}である必要があります", + "description": "Validation error for a list with the wrong number of items" + }, + "uic.Form.arrayMin": { + "defaultMessage": "{label}の最小は{min}です", + "description": "Validation error for a list with too few items" + }, + "uic.Form.arrayMax": { + "defaultMessage": "{label}の最大は{max}です", + "description": "Validation error for a list with too many items" + }, + "uic.Form.arrayRange": { + "defaultMessage": "{label}の合計は{min}-{max}の範囲である必要があります", + "description": "Validation error for a list whose item count is outside a range" + }, + "uic.Form.patternMismatch": { + "defaultMessage": "{label}はパターン{pattern}と一致しません", + "description": "Validation error for text that does not match a pattern" + }, + "uic.BulkEditFormItem.keepAsIs": { + "defaultMessage": "そのまま維持", + "description": "Placeholder of a bulk-edit field that leaves every selected item's current value unchanged" + }, + "uic.BulkEditFormItem.clear": { + "defaultMessage": "クリア", + "description": "Link that clears the field on every selected item, and the field's placeholder once cleared" + }, + "uic.BulkEditFormItem.undoChanges": { + "defaultMessage": "変更を元に戻す", + "description": "Link that returns a bulk-edit field to keeping the current values" } } diff --git a/src/i18n/locales/ko-KR.json b/src/i18n/locales/ko-KR.json index f8d43bf..3843ff3 100644 --- a/src/i18n/locales/ko-KR.json +++ b/src/i18n/locales/ko-KR.json @@ -134,5 +134,105 @@ "uic.UnitGrid.useColor": { "defaultMessage": "색상 {index} 사용", "description": "Accessible name of one palette swatch in a unit grid; {index} is its 1-based position" + }, + "uic.Form.optional": { + "defaultMessage": "(선택사항)", + "description": "Suffix after the label of an optional field when requiredMark=\"optional\"" + }, + "uic.Form.default": { + "defaultMessage": "필드 유효성 검사 오류 {label}", + "description": "Validation error when a rule failed without a more specific message" + }, + "uic.Form.required": { + "defaultMessage": "{label} 값을 입력해 주세요", + "description": "Validation error for an empty required field" + }, + "uic.Form.whitespace": { + "defaultMessage": "{label} 비워둘 수 없습니다", + "description": "Validation error for a required field that holds only whitespace" + }, + "uic.Form.typeString": { + "defaultMessage": "{label} 유효하지 않은 {type}", + "description": "Validation error for a value that is not a string; {type} is the type name" + }, + "uic.Form.typeNumber": { + "defaultMessage": "{label} 유효하지 않은 {type}", + "description": "Validation error for a value that is not a number; {type} is the type name" + }, + "uic.Form.typeObject": { + "defaultMessage": "{label} 유효하지 않은 {type}", + "description": "Validation error for a value that is not an object; {type} is the type name" + }, + "uic.Form.typeEmail": { + "defaultMessage": "{label} 유효하지 않은 {type}", + "description": "Validation error for an invalid email address; {type} is the type name" + }, + "uic.Form.typeUrl": { + "defaultMessage": "{label} 유효하지 않은 {type}", + "description": "Validation error for an invalid URL; {type} is the type name" + }, + "uic.Form.stringLen": { + "defaultMessage": "{label} {len}글자여야 합니다", + "description": "Validation error for text of the wrong exact length" + }, + "uic.Form.stringMin": { + "defaultMessage": "{label} 적어도 {min}글자 이상이어야 합니다", + "description": "Validation error for text shorter than the minimum" + }, + "uic.Form.stringMax": { + "defaultMessage": "{label} {max}글자 이하여야 합니다", + "description": "Validation error for text longer than the maximum" + }, + "uic.Form.stringRange": { + "defaultMessage": "{label} {min}-{max}글자 사이어야 합니다", + "description": "Validation error for text outside a length range" + }, + "uic.Form.numberLen": { + "defaultMessage": "{label} 값은 {len}이어야 합니다", + "description": "Validation error for a number that must equal a value" + }, + "uic.Form.numberMin": { + "defaultMessage": "{label} 최솟값은 {min}입니다", + "description": "Validation error for a number below the minimum" + }, + "uic.Form.numberMax": { + "defaultMessage": "{label} 최댓값은 {max}입니다", + "description": "Validation error for a number above the maximum" + }, + "uic.Form.numberRange": { + "defaultMessage": "{label} 값은 {min}-{max} 사이어야 합니다", + "description": "Validation error for a number outside a range" + }, + "uic.Form.arrayLen": { + "defaultMessage": "{len}이어야 합니다 {label}", + "description": "Validation error for a list with the wrong number of items" + }, + "uic.Form.arrayMin": { + "defaultMessage": "최소 {min}이어야 합니다 {label}", + "description": "Validation error for a list with too few items" + }, + "uic.Form.arrayMax": { + "defaultMessage": "최대 {max}이어야 합니다 {label}", + "description": "Validation error for a list with too many items" + }, + "uic.Form.arrayRange": { + "defaultMessage": "{label} {min}-{max} 사이어야 합니다", + "description": "Validation error for a list whose item count is outside a range" + }, + "uic.Form.patternMismatch": { + "defaultMessage": "{label} {pattern} 패턴과 일치하지 않습니다", + "description": "Validation error for text that does not match a pattern" + }, + "uic.BulkEditFormItem.keepAsIs": { + "defaultMessage": "그대로 유지", + "description": "Placeholder of a bulk-edit field that leaves every selected item's current value unchanged" + }, + "uic.BulkEditFormItem.clear": { + "defaultMessage": "지우기", + "description": "Link that clears the field on every selected item, and the field's placeholder once cleared" + }, + "uic.BulkEditFormItem.undoChanges": { + "defaultMessage": "변경 취소", + "description": "Link that returns a bulk-edit field to keeping the current values" } } diff --git a/src/i18n/locales/mn-MN.json b/src/i18n/locales/mn-MN.json index 45dc513..9cedffe 100644 --- a/src/i18n/locales/mn-MN.json +++ b/src/i18n/locales/mn-MN.json @@ -94,5 +94,105 @@ "uic.UnitGrid.useColor": { "defaultMessage": "Өнгө {index} ашиглах", "description": "Accessible name of one palette swatch in a unit grid; {index} is its 1-based position" + }, + "uic.Form.optional": { + "defaultMessage": "(сонголттой)", + "description": "Suffix after the label of an optional field when requiredMark=\"optional\"" + }, + "uic.Form.default": { + "defaultMessage": "{label}-ийн талбарын баталгаажуулалтын алдаа", + "description": "Validation error when a rule failed without a more specific message" + }, + "uic.Form.required": { + "defaultMessage": "{label} оруулна уу", + "description": "Validation error for an empty required field" + }, + "uic.Form.whitespace": { + "defaultMessage": "{label} нь хоосон тэмдэгт байж болохгүй", + "description": "Validation error for a required field that holds only whitespace" + }, + "uic.Form.typeString": { + "defaultMessage": "{label} нь хүчинтэй {type} биш", + "description": "Validation error for a value that is not a string; {type} is the type name" + }, + "uic.Form.typeNumber": { + "defaultMessage": "{label} нь хүчинтэй {type} биш", + "description": "Validation error for a value that is not a number; {type} is the type name" + }, + "uic.Form.typeObject": { + "defaultMessage": "{label} нь хүчинтэй {type} биш", + "description": "Validation error for a value that is not an object; {type} is the type name" + }, + "uic.Form.typeEmail": { + "defaultMessage": "{label} нь хүчинтэй {type} биш", + "description": "Validation error for an invalid email address; {type} is the type name" + }, + "uic.Form.typeUrl": { + "defaultMessage": "{label} нь хүчинтэй {type} биш", + "description": "Validation error for an invalid URL; {type} is the type name" + }, + "uic.Form.stringLen": { + "defaultMessage": "{label} {len} тэмдэгттэй байх ёстой", + "description": "Validation error for text of the wrong exact length" + }, + "uic.Form.stringMin": { + "defaultMessage": "{label} хамгийн багадаа {min} тэмдэгттэй байх ёстой", + "description": "Validation error for text shorter than the minimum" + }, + "uic.Form.stringMax": { + "defaultMessage": "{label} нь {max} хүртэлх тэмдэгттэй байх ёстой", + "description": "Validation error for text longer than the maximum" + }, + "uic.Form.stringRange": { + "defaultMessage": "{label} {min}-{max} тэмдэгтийн хооронд байх ёстой", + "description": "Validation error for text outside a length range" + }, + "uic.Form.numberLen": { + "defaultMessage": "{label} нь {len}-тэй тэнцүү байх ёстой", + "description": "Validation error for a number that must equal a value" + }, + "uic.Form.numberMin": { + "defaultMessage": "{label} хамгийн багадаа {min} байх ёстой", + "description": "Validation error for a number below the minimum" + }, + "uic.Form.numberMax": { + "defaultMessage": "{label} дээд тал нь {max} байх ёстой", + "description": "Validation error for a number above the maximum" + }, + "uic.Form.numberRange": { + "defaultMessage": "{label} {min}-{max} хооронд байх ёстой", + "description": "Validation error for a number outside a range" + }, + "uic.Form.arrayLen": { + "defaultMessage": "{len} {label} байх ёстой", + "description": "Validation error for a list with the wrong number of items" + }, + "uic.Form.arrayMin": { + "defaultMessage": "Дор хаяж {min} {label}", + "description": "Validation error for a list with too few items" + }, + "uic.Form.arrayMax": { + "defaultMessage": "Хамгийн ихдээ {max} {label}", + "description": "Validation error for a list with too many items" + }, + "uic.Form.arrayRange": { + "defaultMessage": "{label}-н хэмжээ {min}-{max} хооронд байх ёстой", + "description": "Validation error for a list whose item count is outside a range" + }, + "uic.Form.patternMismatch": { + "defaultMessage": "{label} нь {pattern} загвартай тохирохгүй байна", + "description": "Validation error for text that does not match a pattern" + }, + "uic.BulkEditFormItem.keepAsIs": { + "defaultMessage": "Хэвээр үлдээх", + "description": "Placeholder of a bulk-edit field that leaves every selected item's current value unchanged" + }, + "uic.BulkEditFormItem.clear": { + "defaultMessage": "Цэвэрлэх", + "description": "Link that clears the field on every selected item, and the field's placeholder once cleared" + }, + "uic.BulkEditFormItem.undoChanges": { + "defaultMessage": "Өөрчлөлтийг буцаах", + "description": "Link that returns a bulk-edit field to keeping the current values" } } diff --git a/src/i18n/locales/ms-MY.json b/src/i18n/locales/ms-MY.json index 62e5261..dc75c95 100644 --- a/src/i18n/locales/ms-MY.json +++ b/src/i18n/locales/ms-MY.json @@ -94,5 +94,105 @@ "uic.UnitGrid.useColor": { "defaultMessage": "Guna warna {index}", "description": "Accessible name of one palette swatch in a unit grid; {index} is its 1-based position" + }, + "uic.Form.optional": { + "defaultMessage": "(Opsional)", + "description": "Suffix after the label of an optional field when requiredMark=\"optional\"" + }, + "uic.Form.default": { + "defaultMessage": "Ralat pengesahan untuk {label}", + "description": "Validation error when a rule failed without a more specific message" + }, + "uic.Form.required": { + "defaultMessage": "Isi {label}", + "description": "Validation error for an empty required field" + }, + "uic.Form.whitespace": { + "defaultMessage": "{label} tidak boleh kosong", + "description": "Validation error for a required field that holds only whitespace" + }, + "uic.Form.typeString": { + "defaultMessage": "{label} bukan {type} jenis yang sah", + "description": "Validation error for a value that is not a string; {type} is the type name" + }, + "uic.Form.typeNumber": { + "defaultMessage": "{label} bukan {type} jenis yang sah", + "description": "Validation error for a value that is not a number; {type} is the type name" + }, + "uic.Form.typeObject": { + "defaultMessage": "{label} bukan {type} jenis yang sah", + "description": "Validation error for a value that is not an object; {type} is the type name" + }, + "uic.Form.typeEmail": { + "defaultMessage": "{label} bukan {type} jenis yang sah", + "description": "Validation error for an invalid email address; {type} is the type name" + }, + "uic.Form.typeUrl": { + "defaultMessage": "{label} bukan {type} jenis yang sah", + "description": "Validation error for an invalid URL; {type} is the type name" + }, + "uic.Form.stringLen": { + "defaultMessage": "{label} mesti {len} aksara", + "description": "Validation error for text of the wrong exact length" + }, + "uic.Form.stringMin": { + "defaultMessage": "{label} mesti sekurang-kurangnya {min} aksara", + "description": "Validation error for text shorter than the minimum" + }, + "uic.Form.stringMax": { + "defaultMessage": "{label} mesti tidak melebihi {max} aksara", + "description": "Validation error for text longer than the maximum" + }, + "uic.Form.stringRange": { + "defaultMessage": "{label} antara {min}-{max} aksara", + "description": "Validation error for text outside a length range" + }, + "uic.Form.numberLen": { + "defaultMessage": "{label} sama dengan {len}", + "description": "Validation error for a number that must equal a value" + }, + "uic.Form.numberMin": { + "defaultMessage": "{label} mesti sekurang-kurangnya {min}", + "description": "Validation error for a number below the minimum" + }, + "uic.Form.numberMax": { + "defaultMessage": "{label} mesti tidak melebihi {max}", + "description": "Validation error for a number above the maximum" + }, + "uic.Form.numberRange": { + "defaultMessage": "{label} antara {min}-{max}", + "description": "Validation error for a number outside a range" + }, + "uic.Form.arrayLen": { + "defaultMessage": "{len} {label}", + "description": "Validation error for a list with the wrong number of items" + }, + "uic.Form.arrayMin": { + "defaultMessage": "Min {min} {label}", + "description": "Validation error for a list with too few items" + }, + "uic.Form.arrayMax": { + "defaultMessage": "Max {max} {label}", + "description": "Validation error for a list with too many items" + }, + "uic.Form.arrayRange": { + "defaultMessage": "{label} antara {min}-{max}", + "description": "Validation error for a list whose item count is outside a range" + }, + "uic.Form.patternMismatch": { + "defaultMessage": "{label} tidak sesuai {pattern}", + "description": "Validation error for text that does not match a pattern" + }, + "uic.BulkEditFormItem.keepAsIs": { + "defaultMessage": "Kekalkan seperti semula", + "description": "Placeholder of a bulk-edit field that leaves every selected item's current value unchanged" + }, + "uic.BulkEditFormItem.clear": { + "defaultMessage": "Kosongkan", + "description": "Link that clears the field on every selected item, and the field's placeholder once cleared" + }, + "uic.BulkEditFormItem.undoChanges": { + "defaultMessage": "Batal perubahan", + "description": "Link that returns a bulk-edit field to keeping the current values" } } diff --git a/src/i18n/locales/pl-PL.json b/src/i18n/locales/pl-PL.json index 83075d9..3b0f891 100644 --- a/src/i18n/locales/pl-PL.json +++ b/src/i18n/locales/pl-PL.json @@ -94,5 +94,105 @@ "uic.UnitGrid.useColor": { "defaultMessage": "Użyj koloru {index}", "description": "Accessible name of one palette swatch in a unit grid; {index} is its 1-based position" + }, + "uic.Form.optional": { + "defaultMessage": "(opcjonalne)", + "description": "Suffix after the label of an optional field when requiredMark=\"optional\"" + }, + "uic.Form.default": { + "defaultMessage": "Błąd walidacji dla pola {label}", + "description": "Validation error when a rule failed without a more specific message" + }, + "uic.Form.required": { + "defaultMessage": "Pole {label} jest wymagane", + "description": "Validation error for an empty required field" + }, + "uic.Form.whitespace": { + "defaultMessage": "Pole {label} nie może być puste", + "description": "Validation error for a required field that holds only whitespace" + }, + "uic.Form.typeString": { + "defaultMessage": "{label} nie posiada poprawnej wartości dla typu {type}", + "description": "Validation error for a value that is not a string; {type} is the type name" + }, + "uic.Form.typeNumber": { + "defaultMessage": "{label} nie posiada poprawnej wartości dla typu {type}", + "description": "Validation error for a value that is not a number; {type} is the type name" + }, + "uic.Form.typeObject": { + "defaultMessage": "{label} nie posiada poprawnej wartości dla typu {type}", + "description": "Validation error for a value that is not an object; {type} is the type name" + }, + "uic.Form.typeEmail": { + "defaultMessage": "{label} nie posiada poprawnej wartości dla typu {type}", + "description": "Validation error for an invalid email address; {type} is the type name" + }, + "uic.Form.typeUrl": { + "defaultMessage": "{label} nie posiada poprawnej wartości dla typu {type}", + "description": "Validation error for an invalid URL; {type} is the type name" + }, + "uic.Form.stringLen": { + "defaultMessage": "{label} musi posiadać {len} znaków", + "description": "Validation error for text of the wrong exact length" + }, + "uic.Form.stringMin": { + "defaultMessage": "{label} musi posiadać co namniej {min} znaków", + "description": "Validation error for text shorter than the minimum" + }, + "uic.Form.stringMax": { + "defaultMessage": "{label} musi posiadać maksymalnie {max} znaków", + "description": "Validation error for text longer than the maximum" + }, + "uic.Form.stringRange": { + "defaultMessage": "{label} musi posiadać między {min} a {max} znaków", + "description": "Validation error for text outside a length range" + }, + "uic.Form.numberLen": { + "defaultMessage": "{label} musi mieć wartość o długości {len}", + "description": "Validation error for a number that must equal a value" + }, + "uic.Form.numberMin": { + "defaultMessage": "{label} musi mieć wartość większą lub równą {min}", + "description": "Validation error for a number below the minimum" + }, + "uic.Form.numberMax": { + "defaultMessage": "{label} musi mieć wartość mniejszą lub równą {max}", + "description": "Validation error for a number above the maximum" + }, + "uic.Form.numberRange": { + "defaultMessage": "{label} musi mieć wartość pomiędzy {min} a {max}", + "description": "Validation error for a number outside a range" + }, + "uic.Form.arrayLen": { + "defaultMessage": "{label} musi posiadać {len} elementów", + "description": "Validation error for a list with the wrong number of items" + }, + "uic.Form.arrayMin": { + "defaultMessage": "{label} musi posiadać co najmniej {min} elementów", + "description": "Validation error for a list with too few items" + }, + "uic.Form.arrayMax": { + "defaultMessage": "{label} musi posiadać maksymalnie {max} elementów", + "description": "Validation error for a list with too many items" + }, + "uic.Form.arrayRange": { + "defaultMessage": "{label} musi posiadać między {min} a {max} elementów", + "description": "Validation error for a list whose item count is outside a range" + }, + "uic.Form.patternMismatch": { + "defaultMessage": "{label} nie posiada wartości zgodnej ze wzorem {pattern}", + "description": "Validation error for text that does not match a pattern" + }, + "uic.BulkEditFormItem.keepAsIs": { + "defaultMessage": "Pozostaw bez zmian", + "description": "Placeholder of a bulk-edit field that leaves every selected item's current value unchanged" + }, + "uic.BulkEditFormItem.clear": { + "defaultMessage": "Wyczyść", + "description": "Link that clears the field on every selected item, and the field's placeholder once cleared" + }, + "uic.BulkEditFormItem.undoChanges": { + "defaultMessage": "Cofnij zmiany", + "description": "Link that returns a bulk-edit field to keeping the current values" } } diff --git a/src/i18n/locales/pt-BR.json b/src/i18n/locales/pt-BR.json index 9e068e7..5e96e79 100644 --- a/src/i18n/locales/pt-BR.json +++ b/src/i18n/locales/pt-BR.json @@ -94,5 +94,105 @@ "uic.UnitGrid.useColor": { "defaultMessage": "Usar a cor {index}", "description": "Accessible name of one palette swatch in a unit grid; {index} is its 1-based position" + }, + "uic.Form.optional": { + "defaultMessage": "(opcional)", + "description": "Suffix after the label of an optional field when requiredMark=\"optional\"" + }, + "uic.Form.default": { + "defaultMessage": "Erro {label} na validação de campo", + "description": "Validation error when a rule failed without a more specific message" + }, + "uic.Form.required": { + "defaultMessage": "Por favor, insira {label}", + "description": "Validation error for an empty required field" + }, + "uic.Form.whitespace": { + "defaultMessage": "{label} não pode ser um carácter vazio", + "description": "Validation error for a required field that holds only whitespace" + }, + "uic.Form.typeString": { + "defaultMessage": "{label} não é um {type} válido", + "description": "Validation error for a value that is not a string; {type} is the type name" + }, + "uic.Form.typeNumber": { + "defaultMessage": "{label} não é um {type} válido", + "description": "Validation error for a value that is not a number; {type} is the type name" + }, + "uic.Form.typeObject": { + "defaultMessage": "{label} não é um {type} válido", + "description": "Validation error for a value that is not an object; {type} is the type name" + }, + "uic.Form.typeEmail": { + "defaultMessage": "{label} não é um {type} válido", + "description": "Validation error for an invalid email address; {type} is the type name" + }, + "uic.Form.typeUrl": { + "defaultMessage": "{label} não é um {type} válido", + "description": "Validation error for an invalid URL; {type} is the type name" + }, + "uic.Form.stringLen": { + "defaultMessage": "{label} deve possuir {len} caracteres", + "description": "Validation error for text of the wrong exact length" + }, + "uic.Form.stringMin": { + "defaultMessage": "{label} deve possuir ao menos {min} caracteres", + "description": "Validation error for text shorter than the minimum" + }, + "uic.Form.stringMax": { + "defaultMessage": "{label} deve possuir no máximo {max} caracteres", + "description": "Validation error for text longer than the maximum" + }, + "uic.Form.stringRange": { + "defaultMessage": "{label} deve possuir entre {min} e {max} caracteres", + "description": "Validation error for text outside a length range" + }, + "uic.Form.numberLen": { + "defaultMessage": "{label} deve ser igual à {len}", + "description": "Validation error for a number that must equal a value" + }, + "uic.Form.numberMin": { + "defaultMessage": "O valor mínimo de {label} é {min}", + "description": "Validation error for a number below the minimum" + }, + "uic.Form.numberMax": { + "defaultMessage": "O valor máximo de {label} é {max}", + "description": "Validation error for a number above the maximum" + }, + "uic.Form.numberRange": { + "defaultMessage": "{label} deve estar entre {min} e {max}", + "description": "Validation error for a number outside a range" + }, + "uic.Form.arrayLen": { + "defaultMessage": "Deve ser {len} {label}", + "description": "Validation error for a list with the wrong number of items" + }, + "uic.Form.arrayMin": { + "defaultMessage": "No mínimo {min} {label}", + "description": "Validation error for a list with too few items" + }, + "uic.Form.arrayMax": { + "defaultMessage": "No máximo {max} {label}", + "description": "Validation error for a list with too many items" + }, + "uic.Form.arrayRange": { + "defaultMessage": "A quantidade de {label} deve estar entre {min} e {max}", + "description": "Validation error for a list whose item count is outside a range" + }, + "uic.Form.patternMismatch": { + "defaultMessage": "{label} não se encaixa no padrão {pattern}", + "description": "Validation error for text that does not match a pattern" + }, + "uic.BulkEditFormItem.keepAsIs": { + "defaultMessage": "Manter como está", + "description": "Placeholder of a bulk-edit field that leaves every selected item's current value unchanged" + }, + "uic.BulkEditFormItem.clear": { + "defaultMessage": "Limpar", + "description": "Link that clears the field on every selected item, and the field's placeholder once cleared" + }, + "uic.BulkEditFormItem.undoChanges": { + "defaultMessage": "Desfazer alterações", + "description": "Link that returns a bulk-edit field to keeping the current values" } } diff --git a/src/i18n/locales/pt-PT.json b/src/i18n/locales/pt-PT.json index 0adea76..36dc7bc 100644 --- a/src/i18n/locales/pt-PT.json +++ b/src/i18n/locales/pt-PT.json @@ -94,5 +94,105 @@ "uic.UnitGrid.useColor": { "defaultMessage": "Usar a cor {index}", "description": "Accessible name of one palette swatch in a unit grid; {index} is its 1-based position" + }, + "uic.Form.optional": { + "defaultMessage": "(opcional)", + "description": "Suffix after the label of an optional field when requiredMark=\"optional\"" + }, + "uic.Form.default": { + "defaultMessage": "Erro de validação no campo {label}", + "description": "Validation error when a rule failed without a more specific message" + }, + "uic.Form.required": { + "defaultMessage": "Por favor, introduza {label}", + "description": "Validation error for an empty required field" + }, + "uic.Form.whitespace": { + "defaultMessage": "{label} não pode ser um carácter em branco", + "description": "Validation error for a required field that holds only whitespace" + }, + "uic.Form.typeString": { + "defaultMessage": "{label} não é um(a) {type} válido(a)", + "description": "Validation error for a value that is not a string; {type} is the type name" + }, + "uic.Form.typeNumber": { + "defaultMessage": "{label} não é um(a) {type} válido(a)", + "description": "Validation error for a value that is not a number; {type} is the type name" + }, + "uic.Form.typeObject": { + "defaultMessage": "{label} não é um(a) {type} válido(a)", + "description": "Validation error for a value that is not an object; {type} is the type name" + }, + "uic.Form.typeEmail": { + "defaultMessage": "{label} não é um(a) {type} válido(a)", + "description": "Validation error for an invalid email address; {type} is the type name" + }, + "uic.Form.typeUrl": { + "defaultMessage": "{label} não é um(a) {type} válido(a)", + "description": "Validation error for an invalid URL; {type} is the type name" + }, + "uic.Form.stringLen": { + "defaultMessage": "{label} deve ter {len} caracteres", + "description": "Validation error for text of the wrong exact length" + }, + "uic.Form.stringMin": { + "defaultMessage": "{label} deve ter pelo menos {min} caracteres", + "description": "Validation error for text shorter than the minimum" + }, + "uic.Form.stringMax": { + "defaultMessage": "{label} deve ter até {max} caracteres", + "description": "Validation error for text longer than the maximum" + }, + "uic.Form.stringRange": { + "defaultMessage": "{label} deve ter entre {min}-{max} caracteres", + "description": "Validation error for text outside a length range" + }, + "uic.Form.numberLen": { + "defaultMessage": "{label} deve ser igual a {len}", + "description": "Validation error for a number that must equal a value" + }, + "uic.Form.numberMin": { + "defaultMessage": "{label} deve ser no mínimo {min}", + "description": "Validation error for a number below the minimum" + }, + "uic.Form.numberMax": { + "defaultMessage": "{label} deve ser no máximo {max}", + "description": "Validation error for a number above the maximum" + }, + "uic.Form.numberRange": { + "defaultMessage": "{label} deve estar entre {min}-{max}", + "description": "Validation error for a number outside a range" + }, + "uic.Form.arrayLen": { + "defaultMessage": "Deve ter {len} {label}", + "description": "Validation error for a list with the wrong number of items" + }, + "uic.Form.arrayMin": { + "defaultMessage": "Pelo menos {min} {label}", + "description": "Validation error for a list with too few items" + }, + "uic.Form.arrayMax": { + "defaultMessage": "No máximo {max} {label}", + "description": "Validation error for a list with too many items" + }, + "uic.Form.arrayRange": { + "defaultMessage": "A quantidade de {label} deve estar entre {min}-{max}", + "description": "Validation error for a list whose item count is outside a range" + }, + "uic.Form.patternMismatch": { + "defaultMessage": "{label} não corresponde ao padrão {pattern}", + "description": "Validation error for text that does not match a pattern" + }, + "uic.BulkEditFormItem.keepAsIs": { + "defaultMessage": "Manter como está", + "description": "Placeholder of a bulk-edit field that leaves every selected item's current value unchanged" + }, + "uic.BulkEditFormItem.clear": { + "defaultMessage": "Limpar", + "description": "Link that clears the field on every selected item, and the field's placeholder once cleared" + }, + "uic.BulkEditFormItem.undoChanges": { + "defaultMessage": "Desfazer alterações", + "description": "Link that returns a bulk-edit field to keeping the current values" } } diff --git a/src/i18n/locales/ru-RU.json b/src/i18n/locales/ru-RU.json index 5917842..87e6e13 100644 --- a/src/i18n/locales/ru-RU.json +++ b/src/i18n/locales/ru-RU.json @@ -94,5 +94,105 @@ "uic.UnitGrid.useColor": { "defaultMessage": "Использовать цвет {index}", "description": "Accessible name of one palette swatch in a unit grid; {index} is its 1-based position" + }, + "uic.Form.optional": { + "defaultMessage": "(необязательно)", + "description": "Suffix after the label of an optional field when requiredMark=\"optional\"" + }, + "uic.Form.default": { + "defaultMessage": "Ошибка проверки поля {label}", + "description": "Validation error when a rule failed without a more specific message" + }, + "uic.Form.required": { + "defaultMessage": "Пожалуйста, введите {label}", + "description": "Validation error for an empty required field" + }, + "uic.Form.whitespace": { + "defaultMessage": "{label} не может быть пустым", + "description": "Validation error for a required field that holds only whitespace" + }, + "uic.Form.typeString": { + "defaultMessage": "{label} не является типом {type}", + "description": "Validation error for a value that is not a string; {type} is the type name" + }, + "uic.Form.typeNumber": { + "defaultMessage": "{label} не является типом {type}", + "description": "Validation error for a value that is not a number; {type} is the type name" + }, + "uic.Form.typeObject": { + "defaultMessage": "{label} не является типом {type}", + "description": "Validation error for a value that is not an object; {type} is the type name" + }, + "uic.Form.typeEmail": { + "defaultMessage": "{label} не является типом {type}", + "description": "Validation error for an invalid email address; {type} is the type name" + }, + "uic.Form.typeUrl": { + "defaultMessage": "{label} не является типом {type}", + "description": "Validation error for an invalid URL; {type} is the type name" + }, + "uic.Form.stringLen": { + "defaultMessage": "{label} должна быть {len} символов", + "description": "Validation error for text of the wrong exact length" + }, + "uic.Form.stringMin": { + "defaultMessage": "{label} должна быть больше или равна {min} символов", + "description": "Validation error for text shorter than the minimum" + }, + "uic.Form.stringMax": { + "defaultMessage": "{label} должна быть меньше или равна {max} символов", + "description": "Validation error for text longer than the maximum" + }, + "uic.Form.stringRange": { + "defaultMessage": "Длина {label} должна быть между {min}-{max} символами", + "description": "Validation error for text outside a length range" + }, + "uic.Form.numberLen": { + "defaultMessage": "{label} должна быть равна {len}", + "description": "Validation error for a number that must equal a value" + }, + "uic.Form.numberMin": { + "defaultMessage": "{label} должна быть больше или равна {min}", + "description": "Validation error for a number below the minimum" + }, + "uic.Form.numberMax": { + "defaultMessage": "{label} должна быть меньше или равна {max}", + "description": "Validation error for a number above the maximum" + }, + "uic.Form.numberRange": { + "defaultMessage": "{label} должна быть между {min}-{max}", + "description": "Validation error for a number outside a range" + }, + "uic.Form.arrayLen": { + "defaultMessage": "Количество элементов {label} должно быть равно {len}", + "description": "Validation error for a list with the wrong number of items" + }, + "uic.Form.arrayMin": { + "defaultMessage": "Количество элементов {label} должно быть больше или равно {min}", + "description": "Validation error for a list with too few items" + }, + "uic.Form.arrayMax": { + "defaultMessage": "Количество элементов {label} должно быть меньше или равно {max}", + "description": "Validation error for a list with too many items" + }, + "uic.Form.arrayRange": { + "defaultMessage": "Количество элементов {label} должно быть между {min} и {max}", + "description": "Validation error for a list whose item count is outside a range" + }, + "uic.Form.patternMismatch": { + "defaultMessage": "{label} не соответствует шаблону {pattern}", + "description": "Validation error for text that does not match a pattern" + }, + "uic.BulkEditFormItem.keepAsIs": { + "defaultMessage": "Оставить как есть", + "description": "Placeholder of a bulk-edit field that leaves every selected item's current value unchanged" + }, + "uic.BulkEditFormItem.clear": { + "defaultMessage": "Очистить", + "description": "Link that clears the field on every selected item, and the field's placeholder once cleared" + }, + "uic.BulkEditFormItem.undoChanges": { + "defaultMessage": "Отменить изменения", + "description": "Link that returns a bulk-edit field to keeping the current values" } } diff --git a/src/i18n/locales/th-TH.json b/src/i18n/locales/th-TH.json index 5d23fe9..e209674 100644 --- a/src/i18n/locales/th-TH.json +++ b/src/i18n/locales/th-TH.json @@ -94,5 +94,105 @@ "uic.UnitGrid.useColor": { "defaultMessage": "ใช้สี {index}", "description": "Accessible name of one palette swatch in a unit grid; {index} is its 1-based position" + }, + "uic.Form.optional": { + "defaultMessage": "(ไม่จำเป็น)", + "description": "Suffix after the label of an optional field when requiredMark=\"optional\"" + }, + "uic.Form.default": { + "defaultMessage": "ฟิลด์ {label} ไม่ผ่านเงื่อนไขการตรวจสอบ", + "description": "Validation error when a rule failed without a more specific message" + }, + "uic.Form.required": { + "defaultMessage": "กรุณากรอก {label}", + "description": "Validation error for an empty required field" + }, + "uic.Form.whitespace": { + "defaultMessage": "{label} ไม่สามารถเป็นช่องว่างได้", + "description": "Validation error for a required field that holds only whitespace" + }, + "uic.Form.typeString": { + "defaultMessage": "{label} ไม่ใช่ {type} ที่ถูกต้อง", + "description": "Validation error for a value that is not a string; {type} is the type name" + }, + "uic.Form.typeNumber": { + "defaultMessage": "{label} ไม่ใช่ {type} ที่ถูกต้อง", + "description": "Validation error for a value that is not a number; {type} is the type name" + }, + "uic.Form.typeObject": { + "defaultMessage": "{label} ไม่ใช่ {type} ที่ถูกต้อง", + "description": "Validation error for a value that is not an object; {type} is the type name" + }, + "uic.Form.typeEmail": { + "defaultMessage": "{label} ไม่ใช่ {type} ที่ถูกต้อง", + "description": "Validation error for an invalid email address; {type} is the type name" + }, + "uic.Form.typeUrl": { + "defaultMessage": "{label} ไม่ใช่ {type} ที่ถูกต้อง", + "description": "Validation error for an invalid URL; {type} is the type name" + }, + "uic.Form.stringLen": { + "defaultMessage": "{label} ต้องมี {len} ตัวอักษร", + "description": "Validation error for text of the wrong exact length" + }, + "uic.Form.stringMin": { + "defaultMessage": "{label} ต้องมีอย่างน้อย {min} ตัวอักษร", + "description": "Validation error for text shorter than the minimum" + }, + "uic.Form.stringMax": { + "defaultMessage": "{label} มีได้สูงสุด {max} ตัวอักษร", + "description": "Validation error for text longer than the maximum" + }, + "uic.Form.stringRange": { + "defaultMessage": "{label} ต้องมี {min}-{max} ตัวอักษร", + "description": "Validation error for text outside a length range" + }, + "uic.Form.numberLen": { + "defaultMessage": "{label} ต้องมี {len} ตัว", + "description": "Validation error for a number that must equal a value" + }, + "uic.Form.numberMin": { + "defaultMessage": "ค่าต่ำสุด {label} คือ {min}", + "description": "Validation error for a number below the minimum" + }, + "uic.Form.numberMax": { + "defaultMessage": "ค่าสูงสุด {label} คือ {max}", + "description": "Validation error for a number above the maximum" + }, + "uic.Form.numberRange": { + "defaultMessage": "{label} ต้องมีค่า {min}-{max}", + "description": "Validation error for a number outside a range" + }, + "uic.Form.arrayLen": { + "defaultMessage": "ต้องมี {len} {label}", + "description": "Validation error for a list with the wrong number of items" + }, + "uic.Form.arrayMin": { + "defaultMessage": "ต้องมีอย่างน้อย {min} {label}", + "description": "Validation error for a list with too few items" + }, + "uic.Form.arrayMax": { + "defaultMessage": "มีได้สูงสุด {max} {label}", + "description": "Validation error for a list with too many items" + }, + "uic.Form.arrayRange": { + "defaultMessage": "จำนวน {label} ต้องอยู่ในช่วง {min}-{max}", + "description": "Validation error for a list whose item count is outside a range" + }, + "uic.Form.patternMismatch": { + "defaultMessage": "{label} ไม่ตรงกับรูปแบบ {pattern}", + "description": "Validation error for text that does not match a pattern" + }, + "uic.BulkEditFormItem.keepAsIs": { + "defaultMessage": "เก็บไว้ตามเดิม", + "description": "Placeholder of a bulk-edit field that leaves every selected item's current value unchanged" + }, + "uic.BulkEditFormItem.clear": { + "defaultMessage": "ล้าง", + "description": "Link that clears the field on every selected item, and the field's placeholder once cleared" + }, + "uic.BulkEditFormItem.undoChanges": { + "defaultMessage": "ยกเลิกการเปลี่ยนแปลง", + "description": "Link that returns a bulk-edit field to keeping the current values" } } diff --git a/src/i18n/locales/tr-TR.json b/src/i18n/locales/tr-TR.json index 632e712..b3c362d 100644 --- a/src/i18n/locales/tr-TR.json +++ b/src/i18n/locales/tr-TR.json @@ -94,5 +94,105 @@ "uic.UnitGrid.useColor": { "defaultMessage": "Renk {index} kullan", "description": "Accessible name of one palette swatch in a unit grid; {index} is its 1-based position" + }, + "uic.Form.optional": { + "defaultMessage": "(opsiyonel)", + "description": "Suffix after the label of an optional field when requiredMark=\"optional\"" + }, + "uic.Form.default": { + "defaultMessage": "Alan doğrulama hatası {label}", + "description": "Validation error when a rule failed without a more specific message" + }, + "uic.Form.required": { + "defaultMessage": "{label} gerekli bir alan", + "description": "Validation error for an empty required field" + }, + "uic.Form.whitespace": { + "defaultMessage": "{label} sadece boşluk olamaz", + "description": "Validation error for a required field that holds only whitespace" + }, + "uic.Form.typeString": { + "defaultMessage": "{label} geçerli bir {type} değil", + "description": "Validation error for a value that is not a string; {type} is the type name" + }, + "uic.Form.typeNumber": { + "defaultMessage": "{label} geçerli bir {type} değil", + "description": "Validation error for a value that is not a number; {type} is the type name" + }, + "uic.Form.typeObject": { + "defaultMessage": "{label} geçerli bir {type} değil", + "description": "Validation error for a value that is not an object; {type} is the type name" + }, + "uic.Form.typeEmail": { + "defaultMessage": "{label} geçerli bir {type} değil", + "description": "Validation error for an invalid email address; {type} is the type name" + }, + "uic.Form.typeUrl": { + "defaultMessage": "{label} geçerli bir {type} değil", + "description": "Validation error for an invalid URL; {type} is the type name" + }, + "uic.Form.stringLen": { + "defaultMessage": "{label} {len} karakter olmalı", + "description": "Validation error for text of the wrong exact length" + }, + "uic.Form.stringMin": { + "defaultMessage": "{label} en az {min} karakter olmalı", + "description": "Validation error for text shorter than the minimum" + }, + "uic.Form.stringMax": { + "defaultMessage": "{label} en çok {max} karakter olmalı", + "description": "Validation error for text longer than the maximum" + }, + "uic.Form.stringRange": { + "defaultMessage": "{label} {min}-{max} karakter arası olmalı", + "description": "Validation error for text outside a length range" + }, + "uic.Form.numberLen": { + "defaultMessage": "{label} {len} olmalı", + "description": "Validation error for a number that must equal a value" + }, + "uic.Form.numberMin": { + "defaultMessage": "{label} en az {min} olmalı", + "description": "Validation error for a number below the minimum" + }, + "uic.Form.numberMax": { + "defaultMessage": "{label} en çok {max} olmalı", + "description": "Validation error for a number above the maximum" + }, + "uic.Form.numberRange": { + "defaultMessage": "{label} {min}-{max} arası olmalı", + "description": "Validation error for a number outside a range" + }, + "uic.Form.arrayLen": { + "defaultMessage": "{label} sayısı {len} olmalı", + "description": "Validation error for a list with the wrong number of items" + }, + "uic.Form.arrayMin": { + "defaultMessage": "{label} sayısı en az {min} olmalı", + "description": "Validation error for a list with too few items" + }, + "uic.Form.arrayMax": { + "defaultMessage": "{label} sayısı en çok {max} olmalı", + "description": "Validation error for a list with too many items" + }, + "uic.Form.arrayRange": { + "defaultMessage": "{label} sayısı {min}-{max} arası olmalı", + "description": "Validation error for a list whose item count is outside a range" + }, + "uic.Form.patternMismatch": { + "defaultMessage": "{label} şu kalıpla eşleşmeli: {pattern}", + "description": "Validation error for text that does not match a pattern" + }, + "uic.BulkEditFormItem.keepAsIs": { + "defaultMessage": "Olduğu gibi bırak", + "description": "Placeholder of a bulk-edit field that leaves every selected item's current value unchanged" + }, + "uic.BulkEditFormItem.clear": { + "defaultMessage": "Temizle", + "description": "Link that clears the field on every selected item, and the field's placeholder once cleared" + }, + "uic.BulkEditFormItem.undoChanges": { + "defaultMessage": "Değişiklikleri geri al", + "description": "Link that returns a bulk-edit field to keeping the current values" } } diff --git a/src/i18n/locales/vi-VN.json b/src/i18n/locales/vi-VN.json index f6004ef..81b6207 100644 --- a/src/i18n/locales/vi-VN.json +++ b/src/i18n/locales/vi-VN.json @@ -94,5 +94,105 @@ "uic.UnitGrid.useColor": { "defaultMessage": "Dùng màu {index}", "description": "Accessible name of one palette swatch in a unit grid; {index} is its 1-based position" + }, + "uic.Form.optional": { + "defaultMessage": "(Tùy chọn)", + "description": "Suffix after the label of an optional field when requiredMark=\"optional\"" + }, + "uic.Form.default": { + "defaultMessage": "{label} không đáp ứng điều kiện quy định", + "description": "Validation error when a rule failed without a more specific message" + }, + "uic.Form.required": { + "defaultMessage": "Hãy nhập thông tin cho trường {label}", + "description": "Validation error for an empty required field" + }, + "uic.Form.whitespace": { + "defaultMessage": "{label} không được chứa khoảng trắng", + "description": "Validation error for a required field that holds only whitespace" + }, + "uic.Form.typeString": { + "defaultMessage": "{label} không phải kiểu {type} hợp lệ", + "description": "Validation error for a value that is not a string; {type} is the type name" + }, + "uic.Form.typeNumber": { + "defaultMessage": "{label} không phải kiểu {type} hợp lệ", + "description": "Validation error for a value that is not a number; {type} is the type name" + }, + "uic.Form.typeObject": { + "defaultMessage": "{label} không phải kiểu {type} hợp lệ", + "description": "Validation error for a value that is not an object; {type} is the type name" + }, + "uic.Form.typeEmail": { + "defaultMessage": "{label} không phải kiểu {type} hợp lệ", + "description": "Validation error for an invalid email address; {type} is the type name" + }, + "uic.Form.typeUrl": { + "defaultMessage": "{label} không phải kiểu {type} hợp lệ", + "description": "Validation error for an invalid URL; {type} is the type name" + }, + "uic.Form.stringLen": { + "defaultMessage": "{label} phải dài đúng {len} ký tự", + "description": "Validation error for text of the wrong exact length" + }, + "uic.Form.stringMin": { + "defaultMessage": "Độ dài tối thiểu trường {label} là {min} ký tự", + "description": "Validation error for text shorter than the minimum" + }, + "uic.Form.stringMax": { + "defaultMessage": "Độ dài tối đa trường {label} là {max} ký tự", + "description": "Validation error for text longer than the maximum" + }, + "uic.Form.stringRange": { + "defaultMessage": "Độ dài trường {label} phải từ {min} đến {max} ký tự", + "description": "Validation error for text outside a length range" + }, + "uic.Form.numberLen": { + "defaultMessage": "{label} phải bằng {len}", + "description": "Validation error for a number that must equal a value" + }, + "uic.Form.numberMin": { + "defaultMessage": "{label} phải lớn hơn hoặc bằng {min}", + "description": "Validation error for a number below the minimum" + }, + "uic.Form.numberMax": { + "defaultMessage": "{label} phải nhỏ hơn hoặc bằng {max}", + "description": "Validation error for a number above the maximum" + }, + "uic.Form.numberRange": { + "defaultMessage": "{label} phải nằm trong khoảng {min}-{max}", + "description": "Validation error for a number outside a range" + }, + "uic.Form.arrayLen": { + "defaultMessage": "Mảng {label} phải có {len} phần tử ", + "description": "Validation error for a list with the wrong number of items" + }, + "uic.Form.arrayMin": { + "defaultMessage": "Mảng {label} phải chứa tối thiểu {min} phần tử ", + "description": "Validation error for a list with too few items" + }, + "uic.Form.arrayMax": { + "defaultMessage": "Mảng {label} phải chứa tối đa {max} phần tử ", + "description": "Validation error for a list with too many items" + }, + "uic.Form.arrayRange": { + "defaultMessage": "Mảng {label} phải chứa từ {min}-{max} phần tử", + "description": "Validation error for a list whose item count is outside a range" + }, + "uic.Form.patternMismatch": { + "defaultMessage": "{label} không thỏa mãn mẫu kiểm tra {pattern}", + "description": "Validation error for text that does not match a pattern" + }, + "uic.BulkEditFormItem.keepAsIs": { + "defaultMessage": "Giữ nguyên", + "description": "Placeholder of a bulk-edit field that leaves every selected item's current value unchanged" + }, + "uic.BulkEditFormItem.clear": { + "defaultMessage": "Xóa", + "description": "Link that clears the field on every selected item, and the field's placeholder once cleared" + }, + "uic.BulkEditFormItem.undoChanges": { + "defaultMessage": "Hoàn tác thay đổi", + "description": "Link that returns a bulk-edit field to keeping the current values" } } diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 9a6c2f6..7dad759 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -94,5 +94,105 @@ "uic.UnitGrid.useColor": { "defaultMessage": "使用颜色 {index}", "description": "Accessible name of one palette swatch in a unit grid; {index} is its 1-based position" + }, + "uic.Form.optional": { + "defaultMessage": "(可选)", + "description": "Suffix after the label of an optional field when requiredMark=\"optional\"" + }, + "uic.Form.default": { + "defaultMessage": "字段验证错误{label}", + "description": "Validation error when a rule failed without a more specific message" + }, + "uic.Form.required": { + "defaultMessage": "请输入{label}", + "description": "Validation error for an empty required field" + }, + "uic.Form.whitespace": { + "defaultMessage": "{label}不能为空字符", + "description": "Validation error for a required field that holds only whitespace" + }, + "uic.Form.typeString": { + "defaultMessage": "{label}不是一个有效的{type}", + "description": "Validation error for a value that is not a string; {type} is the type name" + }, + "uic.Form.typeNumber": { + "defaultMessage": "{label}不是一个有效的{type}", + "description": "Validation error for a value that is not a number; {type} is the type name" + }, + "uic.Form.typeObject": { + "defaultMessage": "{label}不是一个有效的{type}", + "description": "Validation error for a value that is not an object; {type} is the type name" + }, + "uic.Form.typeEmail": { + "defaultMessage": "{label}不是一个有效的{type}", + "description": "Validation error for an invalid email address; {type} is the type name" + }, + "uic.Form.typeUrl": { + "defaultMessage": "{label}不是一个有效的{type}", + "description": "Validation error for an invalid URL; {type} is the type name" + }, + "uic.Form.stringLen": { + "defaultMessage": "{label}须为{len}个字符", + "description": "Validation error for text of the wrong exact length" + }, + "uic.Form.stringMin": { + "defaultMessage": "{label}最少{min}个字符", + "description": "Validation error for text shorter than the minimum" + }, + "uic.Form.stringMax": { + "defaultMessage": "{label}最多{max}个字符", + "description": "Validation error for text longer than the maximum" + }, + "uic.Form.stringRange": { + "defaultMessage": "{label}须在{min}-{max}字符之间", + "description": "Validation error for text outside a length range" + }, + "uic.Form.numberLen": { + "defaultMessage": "{label}必须等于{len}", + "description": "Validation error for a number that must equal a value" + }, + "uic.Form.numberMin": { + "defaultMessage": "{label}最小值为{min}", + "description": "Validation error for a number below the minimum" + }, + "uic.Form.numberMax": { + "defaultMessage": "{label}最大值为{max}", + "description": "Validation error for a number above the maximum" + }, + "uic.Form.numberRange": { + "defaultMessage": "{label}须在{min}-{max}之间", + "description": "Validation error for a number outside a range" + }, + "uic.Form.arrayLen": { + "defaultMessage": "须为{len}个{label}", + "description": "Validation error for a list with the wrong number of items" + }, + "uic.Form.arrayMin": { + "defaultMessage": "最少{min}个{label}", + "description": "Validation error for a list with too few items" + }, + "uic.Form.arrayMax": { + "defaultMessage": "最多{max}个{label}", + "description": "Validation error for a list with too many items" + }, + "uic.Form.arrayRange": { + "defaultMessage": "{label}数量须在{min}-{max}之间", + "description": "Validation error for a list whose item count is outside a range" + }, + "uic.Form.patternMismatch": { + "defaultMessage": "{label}与模式不匹配{pattern}", + "description": "Validation error for text that does not match a pattern" + }, + "uic.BulkEditFormItem.keepAsIs": { + "defaultMessage": "保持原样", + "description": "Placeholder of a bulk-edit field that leaves every selected item's current value unchanged" + }, + "uic.BulkEditFormItem.clear": { + "defaultMessage": "清除", + "description": "Link that clears the field on every selected item, and the field's placeholder once cleared" + }, + "uic.BulkEditFormItem.undoChanges": { + "defaultMessage": "撤销更改", + "description": "Link that returns a bulk-edit field to keeping the current values" } } diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 7ce433b..bfb442f 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -94,5 +94,105 @@ "uic.UnitGrid.useColor": { "defaultMessage": "使用顏色 {index}", "description": "Accessible name of one palette swatch in a unit grid; {index} is its 1-based position" + }, + "uic.Form.optional": { + "defaultMessage": "(可選)", + "description": "Suffix after the label of an optional field when requiredMark=\"optional\"" + }, + "uic.Form.default": { + "defaultMessage": "字段驗證錯誤{label}", + "description": "Validation error when a rule failed without a more specific message" + }, + "uic.Form.required": { + "defaultMessage": "請輸入{label}", + "description": "Validation error for an empty required field" + }, + "uic.Form.whitespace": { + "defaultMessage": "{label}不能為空字符", + "description": "Validation error for a required field that holds only whitespace" + }, + "uic.Form.typeString": { + "defaultMessage": "{label}不是一個有效的{type}", + "description": "Validation error for a value that is not a string; {type} is the type name" + }, + "uic.Form.typeNumber": { + "defaultMessage": "{label}不是一個有效的{type}", + "description": "Validation error for a value that is not a number; {type} is the type name" + }, + "uic.Form.typeObject": { + "defaultMessage": "{label}不是一個有效的{type}", + "description": "Validation error for a value that is not an object; {type} is the type name" + }, + "uic.Form.typeEmail": { + "defaultMessage": "{label}不是一個有效的{type}", + "description": "Validation error for an invalid email address; {type} is the type name" + }, + "uic.Form.typeUrl": { + "defaultMessage": "{label}不是一個有效的{type}", + "description": "Validation error for an invalid URL; {type} is the type name" + }, + "uic.Form.stringLen": { + "defaultMessage": "{label}須為{len}個字符", + "description": "Validation error for text of the wrong exact length" + }, + "uic.Form.stringMin": { + "defaultMessage": "{label}最少{min}個字符", + "description": "Validation error for text shorter than the minimum" + }, + "uic.Form.stringMax": { + "defaultMessage": "{label}最多{max}個字符", + "description": "Validation error for text longer than the maximum" + }, + "uic.Form.stringRange": { + "defaultMessage": "{label}須在{min}-{max}字符之間", + "description": "Validation error for text outside a length range" + }, + "uic.Form.numberLen": { + "defaultMessage": "{label}必須等於{len}", + "description": "Validation error for a number that must equal a value" + }, + "uic.Form.numberMin": { + "defaultMessage": "{label}最小值為{min}", + "description": "Validation error for a number below the minimum" + }, + "uic.Form.numberMax": { + "defaultMessage": "{label}最大值為{max}", + "description": "Validation error for a number above the maximum" + }, + "uic.Form.numberRange": { + "defaultMessage": "{label}須在{min}-{max}之間", + "description": "Validation error for a number outside a range" + }, + "uic.Form.arrayLen": { + "defaultMessage": "須為{len}個{label}", + "description": "Validation error for a list with the wrong number of items" + }, + "uic.Form.arrayMin": { + "defaultMessage": "最少{min}個{label}", + "description": "Validation error for a list with too few items" + }, + "uic.Form.arrayMax": { + "defaultMessage": "最多{max}個{label}", + "description": "Validation error for a list with too many items" + }, + "uic.Form.arrayRange": { + "defaultMessage": "{label}數量須在{min}-{max}之間", + "description": "Validation error for a list whose item count is outside a range" + }, + "uic.Form.patternMismatch": { + "defaultMessage": "{label}與模式不匹配{pattern}", + "description": "Validation error for text that does not match a pattern" + }, + "uic.BulkEditFormItem.keepAsIs": { + "defaultMessage": "保持原樣", + "description": "Placeholder of a bulk-edit field that leaves every selected item's current value unchanged" + }, + "uic.BulkEditFormItem.clear": { + "defaultMessage": "清除", + "description": "Link that clears the field on every selected item, and the field's placeholder once cleared" + }, + "uic.BulkEditFormItem.undoChanges": { + "defaultMessage": "撤銷更改", + "description": "Link that returns a bulk-edit field to keeping the current values" } } diff --git a/src/index.ts b/src/index.ts index b79f64d..e0cf810 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1321,6 +1321,10 @@ export type { BoardItemTitleProps } from "./components/BoardItemTitle"; export { BooleanToken } from "./components/BooleanToken"; export type { BooleanTokenProps } from "./components/BooleanToken"; +// BulkEditFormItem +export { BulkEditFormItem } from "./components/BulkEditFormItem"; +export type { BulkEditFormItemProps } from "./components/BulkEditFormItem"; + // ColorPicker export { ColorPicker, toHexColor } from "./components/ColorPicker"; export type { ColorPickerProps } from "./components/ColorPicker"; @@ -1356,6 +1360,55 @@ export type { DoubleBadgeProps, DoubleBadgeValue } from "./components/DoubleBadg export { ErrorState } from "./components/ErrorState"; export type { ErrorAction, ErrorStateProps, ErrorTone } from "./components/ErrorState"; +// Form +export { + ErrorList, + Form, + FormConfigContext, + FormConfigProvider, + FormItem, + FormItemInputContext, + FormItemVisual, + FormList, + FormProvider, + FormStore, + NoStyleItemContext, + defaultValidateMessages, + useForm, + useFormInstance, + useFormValidateMessages, + useWatch, +} from "./components/Form"; +export type { + ErrorListProps, + FieldData, + FieldError, + FormConfig, + FormInstance, + FormItemProps, + FormItemStatusContextValue, + FormItemVisualProps, + FormListProps, + FormProps, + FormRef, + InternalNamePath, + ListField, + ListOperations, + Meta, + NamePath, + RequiredMark, + Rule, + RuleObject, + RuleRender, + RuleType, + Store, + StoreValue, + ValidateErrorEntity, + ValidateMessages, + ValidatorRule, + WatchOptions, +} from "./components/Form"; + // IconWithTooltip export { IconWithTooltip } from "./components/IconWithTooltip"; export type { IconWithTooltipProps } from "./components/IconWithTooltip"; From 95c4ae670d0039da7303e7598f68faa17f045a66 Mon Sep 17 00:00:00 2001 From: Jongeun <jongeun@lablup.com> Date: Mon, 28 Sep 2026 22:58:08 +0000 Subject: [PATCH 52/87] chore: prepare 0.2.0-alpha.9 --- CHANGELOG.md | 5 +++++ package.json | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 52dce77..457b757 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ Versioning follows the policy in [CONTRIBUTING.md](CONTRIBUTING.md#versioning). ## [Unreleased] +## [0.2.0-alpha.9] + +A form engine with antd's form API, and the bulk-edit form item, from +backend.ai-ui. + ### Added - `@lablup/ui-common/Form` (also at the root): `Form` with `Form.Item`, diff --git a/package.json b/package.json index a49a43c..ef13716 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@lablup/ui-common", - "version": "0.2.0-alpha.8", + "version": "0.2.0-alpha.9", "description": "Lablup's UI layer on Astryx: the Astryx surface re-exported 1:1, the Lablup theme, and shared components", "license": "Apache-2.0", "author": "Lablup Inc.", From d42eccc9c922308b66d098c0d933fe154d4711b0 Mon Sep 17 00:00:00 2001 From: Jongeun <jongeun@lablup.com> Date: Tue, 29 Sep 2026 01:22:21 +0000 Subject: [PATCH 53/87] feat: add DataGrid, BulkErrorModal and five more components from backend.ai-ui backend.ai-ui's table cluster and the singles its theme shim held back move here, with Astryx-shaped props; backend.ai-ui keeps its antd-shaped names as adapters. - DataGrid: backend.ai-ui's table without its antd vocabulary. Astryx Table and its plugins (column settings, sort, selection, resize, sticky, plus scroll-x, scroll-y, per-cell attributes and expansion, in that order), a page bar with a range line, and the empty and invalid-page states. Rows are data/idKey (field, then key, id, position); columns are key, header, renderCell(item, pageIndex), width, minWidth, align, sortKey (sortable), compare (client sort), pin, isAlwaysVisible, isHiddenByDefault, exportKeys, groupHeader, label and getCellProps. Sorting is sort/defaultSort/onSortChange ({ sortKey, direction }); paging is page/pageSize/totalItems/onChange with hasPageSizeSelector, isHiddenOnSinglePage and endContent; columnSettings holds one overrides record ({ hidden, order, width } per key) that resized widths persist into; csvExport, expansion, selection, scrollWidth, maxHeight, isHeaderHidden and isLoading cover the rest. The chrome's styles are uic-data-grid BEM classes reaching Astryx's table by element and role only; the sticky header reads --color-background-card where the origin read Astryx's internal --table-sticky-background, whose fallback it is. The name is not DataTable, which the 0.1 upgrade rewrites to Table. - DataGridSettingsModal and DataGridExportModal: the column settings and CSV export dialogs, exported on their own. Both hold their working set in the modal body and unmount it on close, so every open starts fresh without a wrapper. onApply and onExport do not close the dialog, as Modal's onAction does not. The drag handle reads --color-icon-secondary. Drag-to-reorder uses dnd-kit, now a dependency (exact pins). - BulkErrorModal: the failed items of a bulk operation in a compact, ruled DataGrid under an optional error Banner; no footer. The title's glyph strokes currentColor and CSS sets --color-error. - ProgressWithLabel, TextHighlighter, CountdownBorder, DoubleToken and ListBanner: a labelled usage bar, a keyword marker, a countdown border, welded Tokens and a Banner that lists items. Product values come in through --uic-progress-with-label-color/-radius and --uic-text-highlighter-background, with Astryx defaults; CountdownBorder resolves --radius-inner for the SVG rx. ListBanner keeps its list style inline, since products reset ul unlayered. Tests: the origin's table suites are ported to DataGrid (paging, page-size reports, invalid page, empty state, sorting, selection, expansion widths, scroll modes, column settings, export) with the dialogs, BulkErrorModal and the singles covered; the singles had no tests in the origin and get them here. Strings are uic.DataGrid.* (11), uic.BulkErrorModal.* (2) and the shared uic.common.apply, translated in all 20 locales from backend.ai-ui; the Mongolian range line loses its stray braces. --- CHANGELOG.md | 39 + CONTRIBUTING.md | 16 + README.md | 7 + astryx/components/BulkErrorModal.doc.mjs | 75 ++ astryx/components/BulkErrorModal.tsx | 3 + astryx/components/CountdownBorder.doc.mjs | 57 + astryx/components/CountdownBorder.tsx | 3 + astryx/components/DataGrid.doc.mjs | 166 +++ astryx/components/DataGrid.tsx | 3 + astryx/components/DoubleToken.doc.mjs | 37 + astryx/components/DoubleToken.tsx | 3 + astryx/components/ListBanner.doc.mjs | 50 + astryx/components/ListBanner.tsx | 3 + astryx/components/ProgressWithLabel.doc.mjs | 69 + astryx/components/ProgressWithLabel.tsx | 3 + astryx/components/TextHighlighter.doc.mjs | 47 + astryx/components/TextHighlighter.tsx | 3 + astryx/docs/ui-common.doc.mjs | 5 + exports.customs.json | 7 + package.json | 4 + pnpm-lock.yaml | 72 ++ .../BulkErrorModal/BulkErrorModal.css | 5 + .../BulkErrorModal/BulkErrorModal.messages.ts | 14 + .../BulkErrorModal/BulkErrorModal.test.tsx | 149 +++ .../BulkErrorModal/BulkErrorModal.tsx | 133 ++ src/components/BulkErrorModal/index.ts | 2 + .../CountdownBorder/CountdownBorder.css | 37 + .../CountdownBorder/CountdownBorder.test.tsx | 94 ++ .../CountdownBorder/CountdownBorder.tsx | 129 ++ src/components/CountdownBorder/index.ts | 2 + src/components/DataGrid/DataGrid.css | 147 +++ src/components/DataGrid/DataGrid.messages.ts | 53 + src/components/DataGrid/DataGrid.test.tsx | 598 +++++++++ src/components/DataGrid/DataGrid.tsx | 1110 +++++++++++++++++ .../DataGrid/DataGridExportModal.tsx | 245 ++++ .../DataGrid/DataGridModals.test.tsx | 212 ++++ .../DataGrid/DataGridSettingsModal.tsx | 300 +++++ src/components/DataGrid/index.ts | 26 + src/components/DataGrid/nodeToPlainText.ts | 16 + src/components/DoubleToken/DoubleToken.css | 19 + .../DoubleToken/DoubleToken.test.tsx | 67 + src/components/DoubleToken/DoubleToken.tsx | 74 ++ src/components/DoubleToken/index.ts | 6 + src/components/ListBanner/ListBanner.css | 28 + src/components/ListBanner/ListBanner.test.tsx | 58 + src/components/ListBanner/ListBanner.tsx | 77 ++ src/components/ListBanner/index.ts | 2 + .../ProgressWithLabel/ProgressWithLabel.css | 62 + .../ProgressWithLabel.test.tsx | 79 ++ .../ProgressWithLabel/ProgressWithLabel.tsx | 94 ++ src/components/ProgressWithLabel/index.ts | 2 + .../TextHighlighter/TextHighlighter.css | 8 + .../TextHighlighter/TextHighlighter.test.tsx | 62 + .../TextHighlighter/TextHighlighter.tsx | 63 + src/components/TextHighlighter/index.ts | 2 + src/i18n/catalog.ts | 4 + src/i18n/common.messages.ts | 4 + src/i18n/locales/de-DE.json | 56 + src/i18n/locales/el-GR.json | 56 + src/i18n/locales/es-ES.json | 56 + src/i18n/locales/fi-FI.json | 56 + src/i18n/locales/fr-FR.json | 56 + src/i18n/locales/id-ID.json | 56 + src/i18n/locales/it-IT.json | 56 + src/i18n/locales/ja-JP.json | 56 + src/i18n/locales/ko-KR.json | 56 + src/i18n/locales/mn-MN.json | 56 + src/i18n/locales/ms-MY.json | 56 + src/i18n/locales/pl-PL.json | 56 + src/i18n/locales/pt-BR.json | 56 + src/i18n/locales/pt-PT.json | 56 + src/i18n/locales/ru-RU.json | 56 + src/i18n/locales/th-TH.json | 56 + src/i18n/locales/tr-TR.json | 56 + src/i18n/locales/vi-VN.json | 56 + src/i18n/locales/zh-CN.json | 56 + src/i18n/locales/zh-TW.json | 56 + src/index.ts | 56 + 78 files changed, 5831 insertions(+) create mode 100644 astryx/components/BulkErrorModal.doc.mjs create mode 100644 astryx/components/BulkErrorModal.tsx create mode 100644 astryx/components/CountdownBorder.doc.mjs create mode 100644 astryx/components/CountdownBorder.tsx create mode 100644 astryx/components/DataGrid.doc.mjs create mode 100644 astryx/components/DataGrid.tsx create mode 100644 astryx/components/DoubleToken.doc.mjs create mode 100644 astryx/components/DoubleToken.tsx create mode 100644 astryx/components/ListBanner.doc.mjs create mode 100644 astryx/components/ListBanner.tsx create mode 100644 astryx/components/ProgressWithLabel.doc.mjs create mode 100644 astryx/components/ProgressWithLabel.tsx create mode 100644 astryx/components/TextHighlighter.doc.mjs create mode 100644 astryx/components/TextHighlighter.tsx create mode 100644 src/components/BulkErrorModal/BulkErrorModal.css create mode 100644 src/components/BulkErrorModal/BulkErrorModal.messages.ts create mode 100644 src/components/BulkErrorModal/BulkErrorModal.test.tsx create mode 100644 src/components/BulkErrorModal/BulkErrorModal.tsx create mode 100644 src/components/BulkErrorModal/index.ts create mode 100644 src/components/CountdownBorder/CountdownBorder.css create mode 100644 src/components/CountdownBorder/CountdownBorder.test.tsx create mode 100644 src/components/CountdownBorder/CountdownBorder.tsx create mode 100644 src/components/CountdownBorder/index.ts create mode 100644 src/components/DataGrid/DataGrid.css create mode 100644 src/components/DataGrid/DataGrid.messages.ts create mode 100644 src/components/DataGrid/DataGrid.test.tsx create mode 100644 src/components/DataGrid/DataGrid.tsx create mode 100644 src/components/DataGrid/DataGridExportModal.tsx create mode 100644 src/components/DataGrid/DataGridModals.test.tsx create mode 100644 src/components/DataGrid/DataGridSettingsModal.tsx create mode 100644 src/components/DataGrid/index.ts create mode 100644 src/components/DataGrid/nodeToPlainText.ts create mode 100644 src/components/DoubleToken/DoubleToken.css create mode 100644 src/components/DoubleToken/DoubleToken.test.tsx create mode 100644 src/components/DoubleToken/DoubleToken.tsx create mode 100644 src/components/DoubleToken/index.ts create mode 100644 src/components/ListBanner/ListBanner.css create mode 100644 src/components/ListBanner/ListBanner.test.tsx create mode 100644 src/components/ListBanner/ListBanner.tsx create mode 100644 src/components/ListBanner/index.ts create mode 100644 src/components/ProgressWithLabel/ProgressWithLabel.css create mode 100644 src/components/ProgressWithLabel/ProgressWithLabel.test.tsx create mode 100644 src/components/ProgressWithLabel/ProgressWithLabel.tsx create mode 100644 src/components/ProgressWithLabel/index.ts create mode 100644 src/components/TextHighlighter/TextHighlighter.css create mode 100644 src/components/TextHighlighter/TextHighlighter.test.tsx create mode 100644 src/components/TextHighlighter/TextHighlighter.tsx create mode 100644 src/components/TextHighlighter/index.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 457b757..29c6f09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,45 @@ Versioning follows the policy in [CONTRIBUTING.md](CONTRIBUTING.md#versioning). ## [Unreleased] +### Added + +- **Components moved from backend.ai-ui**, with Astryx-shaped props and + their tests, exported from the root and from + `@lablup/ui-common/components/<Name>`: + - `DataGrid`: Astryx `Table` with a page bar and range line, client + (`compare`) or server (`sort`/`onSortChange`) sorting, row selection by + key, resizable and pinnable columns, expandable rows, per-user column + settings in one overrides record (`hidden`, `order`, `width`) and a CSV + export picker. Rows are paged unless `pagination.totalItems` says they + already are one page; a page past the last shows a way back to page 1. + `DataGridSettingsModal` (visibility and drag-to-reorder) and + `DataGridExportModal` (columns that export the same keys toggle + together) are its dialogs, exported on their own; both start fresh on + every open. Helpers `dataGridColumnLabel` and `isDataGridColumnVisible`. + - `BulkErrorModal`: the failed items of a bulk operation in a `DataGrid` + (compact, column rules, ten rows a page, no page bar on one page) under + an optional error banner; no footer. + - `ProgressWithLabel`: a bar carrying its label and value label over a + fill of `value` percent. `--uic-progress-with-label-color` (default + `--color-success`) and `--uic-progress-with-label-radius` (default + `--radius-inner`). + - `TextHighlighter`: marks every case-insensitive occurrence of a keyword. + `--uic-text-highlighter-background` (default `--color-warning-muted`). + - `CountdownBorder`: a border that fills clockwise over `durationMs`, a + countdown to the next refresh; `rx` resolves the theme's + `--radius-inner`. + - `DoubleToken`: welded Tokens for a settled pair, the Token counterpart + of `DoubleBadge`, with keyword highlighting. + - `ListBanner`: a `Banner` whose description is a keyboard-scrollable + list, capped at `maxHeight`. +- Dependencies `@dnd-kit/core` 6.3.1, `@dnd-kit/sortable` 10.0.0, + `@dnd-kit/modifiers` 9.0.0 and `@dnd-kit/utilities` 3.2.2, exact-pinned, + for the column reorder in `DataGridSettingsModal`. +- Catalog keys `uic.common.apply`, `uic.DataGrid.*` (11) and + `uic.BulkErrorModal.*` (2), translated in every shipped locale from + backend.ai-ui's locale files. The Mongolian range line + (`uic.DataGrid.range`) lost its stray braces on the way. + ## [0.2.0-alpha.9] A form engine with antd's form API, and the bulk-edit form item, from diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0637c0c..563d71b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -108,6 +108,13 @@ Pick a different name, or use the Astryx component. | `ColorPicker` | `src/components/ColorPicker/` | `Popover`, `TextInput`, `Button` | | `Form` (engine, `Form.Item` shell, hooks) | `src/components/Form/` | `Tooltip`, plain CSS | | `BulkEditFormItem` | `src/components/BulkEditFormItem/` | `Form.Item`, `TextInput`, `Link`, `HStack` | +| `DataGrid`, `DataGridSettingsModal`, `DataGridExportModal` | `src/components/DataGrid/` | `Table` + plugins, `Pagination`, `Modal`, dnd-kit | +| `BulkErrorModal` | `src/components/BulkErrorModal/` | `Modal`, `Banner`, `DataGrid` | +| `ProgressWithLabel` | `src/components/ProgressWithLabel/` | `Text`, plain CSS | +| `TextHighlighter` | `src/components/TextHighlighter/` | plain CSS | +| `CountdownBorder` | `src/components/CountdownBorder/` | SVG, plain CSS | +| `DoubleToken` | `src/components/DoubleToken/` | `Token`, `HStack`, `TextHighlighter` | +| `ListBanner` | `src/components/ListBanner/` | `Banner` | Each has tests beside it. `src/components/componentStyles.test.ts` holds every stylesheet to the styling rules below. @@ -172,6 +179,15 @@ written against antd's form moves over with an import rewrite. Its - The feedback glyphs are Ant Design Icons path data (MIT, see NOTICE); the tooltip glyph is `lucide-react`'s, a dependency. +### Dependencies of the customs + +Besides Astryx, the customs depend on `lucide-react` (glyphs), +`intl-messageformat` (catalog fallback formatting) and `@dnd-kit/core`, +`@dnd-kit/sortable`, `@dnd-kit/modifiers` and `@dnd-kit/utilities` +(drag-to-reorder in `DataGridSettingsModal`; Astryx has no sortable list). +The dnd-kit packages are exact-pinned and move together. A new runtime +dependency is recorded here and in `CHANGELOG.md`. + ### Things that are never admitted API clients, endpoints, authentication, application state, stores, routing, diff --git a/README.md b/README.md index 2ad4a14..69add27 100644 --- a/README.md +++ b/README.md @@ -208,6 +208,13 @@ import { Modal } from "@lablup/ui-common/Modal"; | `ColorPicker` | A hex colour field on the platform colour input | `/components/ColorPicker` | | `Form` and its hooks | A form engine with antd's form API. See below. | `/Form` | | `BulkEditFormItem` | A form item that edits one field across many records | `/components/BulkEditFormItem` | +| `DataGrid`, `DataGridSettingsModal`, `DataGridExportModal` | A table with paging, sorting, selection and column settings | `/components/DataGrid` | +| `BulkErrorModal` | The failed items of a bulk operation, in a grid | `/components/BulkErrorModal` | +| `ProgressWithLabel` | A bar that carries its label and value label | `/components/ProgressWithLabel` | +| `TextHighlighter` | Marks a search keyword in a string | `/components/TextHighlighter` | +| `CountdownBorder` | A border that fills as a countdown to a refresh | `/components/CountdownBorder` | +| `DoubleToken` | A run of Tokens welded into one chip | `/components/DoubleToken` | +| `ListBanner` | A Banner that lists items, scrolling past a height | `/components/ListBanner` | | `usePrefersReducedMotion` | The `prefers-reduced-motion` media query, as a hook | root only | Their styles live in `@layer ui-common`, under `uic-` class names. diff --git a/astryx/components/BulkErrorModal.doc.mjs b/astryx/components/BulkErrorModal.doc.mjs new file mode 100644 index 0000000..0ff7b1c --- /dev/null +++ b/astryx/components/BulkErrorModal.doc.mjs @@ -0,0 +1,75 @@ +/** + * `astryx component BulkErrorModal` (and `ui-common component BulkErrorModal`). + * + * @type {import('@astryxdesign/cli/authoring').ComponentDoc} + */ +export default { + type: "component", + name: "BulkErrorModal", + displayName: "BulkErrorModal", + import: "@lablup/ui-common", + category: "Overlay", + keywords: ["bulk", "partial failure", "error report", "failed items", "batch"], + description: + "Reports the failures of a bulk operation: a Modal with a DataGrid of the failed items, in columns the caller describes, under an optional error banner with guidance. It has no footer; the header's close button, the backdrop or Escape close it. The grid is compact with column rules, pages at ten rows and hides its page bar on one page.", + props: [ + { + name: "columns", + type: "ReadonlyArray<DataGridColumn<T>>", + description: "How one failed item renders.", + required: true, + }, + { + name: "data", + type: "ReadonlyArray<T>", + description: "One item per failure.", + required: true, + }, + { + name: "idKey", + type: "string | ((item: T) => Key)", + description: "Row identity.", + default: "'id', then key", + }, + { + name: "description", + type: "ReactNode", + description: + "Guidance in an error banner above the grid. Without it there is no banner.", + }, + { + name: "descriptionTitle", + type: "ReactNode", + description: "The banner's title.", + default: "the catalog's uic.BulkErrorModal.errorOccurred", + }, + { + name: "title", + type: "ReactNode", + description: "The modal's title.", + default: "an error glyph and the catalog's uic.BulkErrorModal.title", + }, + { + name: "isOpen / onOpenChange", + type: "boolean / (isOpen: boolean) => void", + description: "Visibility, as on Modal.", + required: true, + }, + { + name: "width", + type: "number | string", + description: "Modal width.", + default: "720", + }, + ], + usage: { + description: + "Only for a partial failure; a wholly failed operation is one error notice. Keep the caller's form open behind it, so the user can fix the failed items and retry.", + }, + examples: [ + { + label: "Failed folder deletions", + code: '<BulkErrorModal\n isOpen={failures.length > 0}\n onOpenChange={(open) => !open && setFailures([])}\n description="Fix the failed folders and try again."\n columns={[\n { key: "name", header: "Folder", renderCell: (f) => f.name },\n { key: "message", header: "Error", renderCell: (f) => f.message },\n ]}\n data={failures}\n/>', + }, + ], +}; diff --git a/astryx/components/BulkErrorModal.tsx b/astryx/components/BulkErrorModal.tsx new file mode 100644 index 0000000..7f1d239 --- /dev/null +++ b/astryx/components/BulkErrorModal.tsx @@ -0,0 +1,3 @@ +// The Astryx CLI pairs each component doc with a same-stem source file. +// ui-common ships no source, so this names the export instead. +export { BulkErrorModal } from "@lablup/ui-common"; diff --git a/astryx/components/CountdownBorder.doc.mjs b/astryx/components/CountdownBorder.doc.mjs new file mode 100644 index 0000000..e19f66c --- /dev/null +++ b/astryx/components/CountdownBorder.doc.mjs @@ -0,0 +1,57 @@ +/** + * `astryx component CountdownBorder` (and `ui-common component CountdownBorder`). + * + * @type {import('@astryxdesign/cli/authoring').ComponentDoc} + */ +export default { + type: "component", + name: "CountdownBorder", + displayName: "CountdownBorder", + import: "@lablup/ui-common", + category: "Feedback", + keywords: ["countdown", "auto refresh", "border", "timer", "progress border"], + description: + "Wraps its children in a rounded-rect border that fills clockwise over durationMs and starts again: a countdown to the next automatic refresh. It measures its own box, so the outline fits the content; the stroke is centred on the content's edge. No animation under prefers-reduced-motion. style.stroke (default var(--color-accent)), style.strokeWidth (1.5) and style.borderRadius (the theme's --radius-inner) style the border; the rest of style reaches the wrapper.", + props: [ + { + name: "durationMs", + type: "number", + description: "Length of one fill cycle.", + required: true, + }, + { + name: "isAnimated", + type: "boolean", + description: "Whether the border shows and fills.", + default: "true", + }, + { + name: "resetKey", + type: "Key", + description: + "Restarts the fill when it changes; pass the real refresh's trigger.", + }, + { + name: "isPaused", + type: "boolean", + description: "Freezes the fill and hides the border while a refresh runs.", + default: "false", + }, + { + name: "className / style", + type: "string / CSSProperties", + description: + "The wrapper's class and style; see the description for the border keys.", + }, + ], + usage: { + description: + "Wrap an auto-refresh control so the user sees when the next refresh lands. Drive resetKey and isPaused from the same state as the refresh.", + }, + examples: [ + { + label: "Auto-refresh button", + code: '<CountdownBorder durationMs={5000} resetKey={fetchKey} isPaused={isFetching}>\n <IconButton label="Refresh" icon={<RotateCw />} onClick={refetch} />\n</CountdownBorder>', + }, + ], +}; diff --git a/astryx/components/CountdownBorder.tsx b/astryx/components/CountdownBorder.tsx new file mode 100644 index 0000000..396661d --- /dev/null +++ b/astryx/components/CountdownBorder.tsx @@ -0,0 +1,3 @@ +// The Astryx CLI pairs each component doc with a same-stem source file. +// ui-common ships no source, so this names the export instead. +export { CountdownBorder } from "@lablup/ui-common"; diff --git a/astryx/components/DataGrid.doc.mjs b/astryx/components/DataGrid.doc.mjs new file mode 100644 index 0000000..05825b3 --- /dev/null +++ b/astryx/components/DataGrid.doc.mjs @@ -0,0 +1,166 @@ +/** + * `astryx component DataGrid` (and `ui-common component DataGrid`). + * + * @type {import('@astryxdesign/cli/authoring').ComponentDoc} + */ +export default { + type: "component", + name: "DataGrid", + displayName: "DataGrid", + import: "@lablup/ui-common", + category: "Data Display", + keywords: [ + "data grid", + "table", + "pagination", + "sorting", + "row selection", + "column settings", + "csv export", + "expandable rows", + ], + description: + "Astryx Table with what a list page needs around it: a page bar with a range line, client or server sorting, row selection by key, resizable and pinnable columns, expandable rows, per-user column settings (visibility, order, width in one overrides record) and a CSV export picker. Rows are paged unless pagination.totalItems says they already are one page. DataGridSettingsModal and DataGridExportModal, from the same import, are its dialogs on their own. Astryx Table props it does not own (isStriped, verticalAlign, aria-*) pass through.", + props: [ + { + name: "data", + type: "ReadonlyArray<T>", + description: "The rows.", + }, + { + name: "columns", + type: "ReadonlyArray<DataGridColumn<T>>", + description: + "key, header, renderCell(item, index), width (px), minWidth, align, sortKey (sortable), compare (client sort), pin ('start' | 'end'), isAlwaysVisible, isHiddenByDefault, exportKeys, groupHeader, label, getCellProps.", + }, + { + name: "idKey", + type: "string | ((item: T) => Key)", + description: + "Row identity. A missing field falls back to the row's key, then id, then position.", + default: "'id'", + }, + { + name: "sort", + type: "DataGridSort | null", + description: + "Controlled sort, { sortKey, direction }. Columns with compare sort on the client; others only report onSortChange.", + }, + { + name: "defaultSort", + type: "DataGridSort | null", + description: "Initial sort when uncontrolled.", + }, + { + name: "onSortChange", + type: "(sort: DataGridSort | null) => void", + description: "Called when a header changes the sort.", + }, + { + name: "pagination", + type: "false | DataGridPagination", + description: + "page, defaultPage, pageSize, defaultPageSize, totalItems, onChange(page, pageSize), hasPageSizeSelector, isHiddenOnSinglePage, endContent, plus Astryx Pagination props. false shows every row and no bar.", + }, + { + name: "selection", + type: "DataGridSelection<T>", + description: + "selectedKeys (controlled), onChange(keys, items), getIsItemEnabled, getRowLabel, isPreservingOtherPages.", + }, + { + name: "columnSettings", + type: "DataGridColumnSettings", + description: + "Shows the settings button. overrides / defaultOverrides / onOverridesChange hold { hidden, order, width } per column key; resized widths persist there too. isReorderable (default true).", + }, + { + name: "csvExport", + type: "DataGridCsvExport", + description: + "Shows the export button: supportedKeys, onExport(keys) (the dialog closes when it resolves), notice.", + }, + { + name: "expansion", + type: "DataGridExpansion<T>", + description: + "renderExpandedRow, getIsRowExpandable, expandedKeys / defaultExpandedKeys / onExpandedKeysChange, columnHeader, columnWidth.", + }, + { + name: "emptyState", + type: "ReactNode | false", + description: + "Shown without rows. A string gets the default icon and layout, false shows nothing.", + default: "the catalog's uic.DataGrid.noData", + }, + { + name: "isLoading", + type: "boolean", + description: "Dims the rows and blocks input while data is refetched.", + default: "false", + }, + { + name: "isResizable", + type: "boolean", + description: "Drag-to-resize column borders.", + default: "true", + }, + { + name: "scrollWidth", + type: "number | string", + description: + "Scrolls horizontally with the table at this width; width-less columns size to their content.", + }, + { + name: "maxHeight", + type: "number | string", + description: "Caps the rows' height and makes the header sticky.", + }, + { + name: "isHeaderHidden", + type: "boolean", + description: "Hides the header row, and with it sorting and select-all.", + default: "false", + }, + { + name: "getRowProps", + type: "(item: T, index?: number) => HTMLAttributes<HTMLTableRowElement>", + description: "Extra attributes for a body row.", + }, + { + name: "density", + type: "'compact' | 'balanced' | 'spacious'", + description: "Astryx Table density.", + default: "'compact'", + }, + { + name: "dividers", + type: "'rows' | 'columns' | 'grid' | 'none'", + description: + "Astryx Table dividers. Every value but grid adds a split between header cells.", + default: "'rows'", + }, + { + name: "renderRange", + type: "(range: { start: number; end: number; total: number }) => ReactNode", + description: "The range line beside the page navigation.", + default: "the catalog's uic.DataGrid.range", + }, + { + name: "expandRowLabel, invalidPageText, goToFirstPageLabel, paginationLabel, settingsLabel, exportLabel", + type: "string", + description: "Strings of the grid's chrome.", + default: "the catalog's uic.DataGrid.* keys", + }, + ], + usage: { + description: + "A paged list with sorting and per-user columns. Persist columnSettings.overrides where the product keeps user settings. For server paging pass totalItems and the page's rows; for server sorting pass sort and onSortChange and leave compare off. The dialogs: DataGridSettingsModal (isOpen, onOpenChange, columns, visibleColumnKeys, isReorderable, onApply) and DataGridExportModal (isOpen, onOpenChange, columns with exportKeys, supportedKeys, onExport, notice).", + }, + examples: [ + { + label: "Server-paged grid with column settings", + code: '<DataGrid\n data={page.items}\n idKey="id"\n columns={[\n { key: "name", header: "Name", renderCell: (u) => u.name, sortKey: "name", isAlwaysVisible: true },\n { key: "email", header: "Email", renderCell: (u) => u.email, exportKeys: ["email"] },\n ]}\n sort={sort}\n onSortChange={setSort}\n pagination={{ page, pageSize, totalItems: page.total, onChange: setPage }}\n columnSettings={{ overrides, onOverridesChange: setOverrides }}\n/>', + }, + ], +}; diff --git a/astryx/components/DataGrid.tsx b/astryx/components/DataGrid.tsx new file mode 100644 index 0000000..7eac086 --- /dev/null +++ b/astryx/components/DataGrid.tsx @@ -0,0 +1,3 @@ +// The Astryx CLI pairs each component doc with a same-stem source file. +// ui-common ships no source, so this names the export instead. +export { DataGrid } from "@lablup/ui-common"; diff --git a/astryx/components/DoubleToken.doc.mjs b/astryx/components/DoubleToken.doc.mjs new file mode 100644 index 0000000..a70b84e --- /dev/null +++ b/astryx/components/DoubleToken.doc.mjs @@ -0,0 +1,37 @@ +/** + * `astryx component DoubleToken` (and `ui-common component DoubleToken`). + * + * @type {import('@astryxdesign/cli/authoring').ComponentDoc} + */ +export default { + type: "component", + name: "DoubleToken", + displayName: "DoubleToken", + import: "@lablup/ui-common", + category: "Data Display", + keywords: ["token pair", "welded tokens", "key value", "chip", "tag pair"], + description: + "A run of Tokens welded into one chip, for a settled pair (a type and its version, a scope and its name); the live counterpart is DoubleBadge. A string value is a blue Token; empty labels are skipped. highlightKeyword marks the keyword in each label with TextHighlighter and keeps the plain label as the accessible name.", + props: [ + { + name: "values", + type: "Array<string> | Array<{ label: string; color?: TokenColor }>", + description: "The tokens, in order.", + }, + { + name: "highlightKeyword", + type: "string", + description: "Marks this text in every label.", + }, + ], + usage: { + description: + "Values that change only when someone edits them. A status that changes on its own is a DoubleBadge.", + }, + examples: [ + { + label: "A runtime and its version", + code: '<DoubleToken values={[{ label: "CUDA", color: "green" }, "12.4"]} />', + }, + ], +}; diff --git a/astryx/components/DoubleToken.tsx b/astryx/components/DoubleToken.tsx new file mode 100644 index 0000000..e07c2e5 --- /dev/null +++ b/astryx/components/DoubleToken.tsx @@ -0,0 +1,3 @@ +// The Astryx CLI pairs each component doc with a same-stem source file. +// ui-common ships no source, so this names the export instead. +export { DoubleToken } from "@lablup/ui-common"; diff --git a/astryx/components/ListBanner.doc.mjs b/astryx/components/ListBanner.doc.mjs new file mode 100644 index 0000000..bea3e8e --- /dev/null +++ b/astryx/components/ListBanner.doc.mjs @@ -0,0 +1,50 @@ +/** + * `astryx component ListBanner` (and `ui-common component ListBanner`). + * + * @type {import('@astryxdesign/cli/authoring').ComponentDoc} + */ +export default { + type: "component", + name: "ListBanner", + displayName: "ListBanner", + import: "@lablup/ui-common", + category: "Feedback", + keywords: ["banner", "list", "affected items", "warning list", "alert"], + description: + "An Astryx Banner whose description is a list of items, for notices such as the items a dialog will change. The list scrolls past maxHeight and is reachable by keyboard. Without a title the list takes the title slot; without items there is no list. The other Banner props pass through.", + props: [ + { + name: "items", + type: "ReadonlyArray<{ key?: Key; content: ReactNode }>", + description: "The items.", + required: true, + }, + { + name: "title", + type: "ReactNode", + description: "Say how many items there are here.", + }, + { + name: "maxHeight", + type: "number | string", + description: "Height at which the list scrolls.", + default: "165", + }, + { + name: "status", + type: "'info' | 'warning' | 'error' | 'success'", + description: "Banner status.", + required: true, + }, + ], + usage: { + description: + "Inside a dialog that acts on a selection. Name the count in the title rather than in each item.", + }, + examples: [ + { + label: "Users about to change", + code: '<ListBanner\n status="warning"\n title={`${users.length} users will be updated`}\n items={users.map((u) => ({ key: u.id, content: u.email }))}\n/>', + }, + ], +}; diff --git a/astryx/components/ListBanner.tsx b/astryx/components/ListBanner.tsx new file mode 100644 index 0000000..dec97e0 --- /dev/null +++ b/astryx/components/ListBanner.tsx @@ -0,0 +1,3 @@ +// The Astryx CLI pairs each component doc with a same-stem source file. +// ui-common ships no source, so this names the export instead. +export { ListBanner } from "@lablup/ui-common"; diff --git a/astryx/components/ProgressWithLabel.doc.mjs b/astryx/components/ProgressWithLabel.doc.mjs new file mode 100644 index 0000000..75ef539 --- /dev/null +++ b/astryx/components/ProgressWithLabel.doc.mjs @@ -0,0 +1,69 @@ +/** + * `astryx component ProgressWithLabel` (and `ui-common component ProgressWithLabel`). + * + * @type {import('@astryxdesign/cli/authoring').ComponentDoc} + */ +export default { + type: "component", + name: "ProgressWithLabel", + displayName: "ProgressWithLabel", + import: "@lablup/ui-common", + category: "Data Display", + keywords: ["progress", "usage bar", "resource", "labelled progress", "meter"], + description: + "A bar that carries its labels: label at the start and valueLabel at the end, over a fill of value percent, for compact resource readouts in cells and cards. A missing or NaN value draws no fill and greys the value label. The fill is color or --uic-progress-with-label-color (default --color-success); the frame's corner is --uic-progress-with-label-radius (default --radius-inner).", + props: [ + { + name: "label", + type: "ReactNode", + description: "Start label.", + }, + { + name: "valueLabel", + type: "ReactNode", + description: "End label.", + }, + { + name: "value", + type: "number", + description: "Fill, in percent; above 100 fills the bar.", + }, + { + name: "hasValueLabel", + type: "boolean", + description: "Whether the end label shows; its space stays reserved.", + default: "true", + }, + { + name: "color", + type: "string", + description: "Fill colour, any CSS colour or var().", + }, + { + name: "width", + type: "CSSProperties['width']", + description: "Bar width. Without it the bar grows in its flex container.", + }, + { + name: "size", + type: "'sm' | 'md' | 'lg'", + description: "Label size.", + default: "'sm'", + }, + { + name: "style / labelStyle", + type: "CSSProperties", + description: "Inline style of the frame / of both labels.", + }, + ], + usage: { + description: + "Use Astryx ProgressBar for a plain progress indicator; this one is a readout whose labels sit on the bar.", + }, + examples: [ + { + label: "CPU usage", + code: '<ProgressWithLabel label="CPU" valueLabel="3 / 8" value={37.5} width={160} />', + }, + ], +}; diff --git a/astryx/components/ProgressWithLabel.tsx b/astryx/components/ProgressWithLabel.tsx new file mode 100644 index 0000000..53d3a55 --- /dev/null +++ b/astryx/components/ProgressWithLabel.tsx @@ -0,0 +1,3 @@ +// The Astryx CLI pairs each component doc with a same-stem source file. +// ui-common ships no source, so this names the export instead. +export { ProgressWithLabel } from "@lablup/ui-common"; diff --git a/astryx/components/TextHighlighter.doc.mjs b/astryx/components/TextHighlighter.doc.mjs new file mode 100644 index 0000000..95aaebb --- /dev/null +++ b/astryx/components/TextHighlighter.doc.mjs @@ -0,0 +1,47 @@ +/** + * `astryx component TextHighlighter` (and `ui-common component TextHighlighter`). + * + * @type {import('@astryxdesign/cli/authoring').ComponentDoc} + */ +export default { + type: "component", + name: "TextHighlighter", + displayName: "TextHighlighter", + import: "@lablup/ui-common", + category: "Data Display", + keywords: ["highlight", "search", "mark", "keyword", "match"], + description: + "Marks every case-insensitive occurrence of keyword in a string, for search results. The keyword matches literally. Without a keyword the text renders plain; without text nothing renders. The mark is --uic-text-highlighter-background (default --color-warning-muted), set on the highlighter or any ancestor.", + props: [ + { + name: "children", + type: "string | null", + description: "The text to search in.", + }, + { + name: "keyword", + type: "string", + description: "The text to mark.", + }, + { + name: "highlightStyle", + type: "CSSProperties", + description: "Inline style of each mark.", + }, + { + name: "className", + type: "string", + description: "Class on the outer span.", + }, + ], + usage: { + description: + "Inside a Token, which takes a string label, pass label={text} isLabelHidden and the highlighter as endContent, so the plain text stays the accessible name.", + }, + examples: [ + { + label: "A search result", + code: "<TextHighlighter keyword={search}>{row.name}</TextHighlighter>", + }, + ], +}; diff --git a/astryx/components/TextHighlighter.tsx b/astryx/components/TextHighlighter.tsx new file mode 100644 index 0000000..a0d40e2 --- /dev/null +++ b/astryx/components/TextHighlighter.tsx @@ -0,0 +1,3 @@ +// The Astryx CLI pairs each component doc with a same-stem source file. +// ui-common ships no source, so this names the export instead. +export { TextHighlighter } from "@lablup/ui-common"; diff --git a/astryx/docs/ui-common.doc.mjs b/astryx/docs/ui-common.doc.mjs index 0ed9b44..54d6236 100644 --- a/astryx/docs/ui-common.doc.mjs +++ b/astryx/docs/ui-common.doc.mjs @@ -71,6 +71,11 @@ export const docs = { "`UnitGrid`: groups of unit squares packed on one lattice, each group a tinted plate with a hover card and an optional palette picker; `UnitGridSkeleton` is its loading stand-in.", "`ColorPicker`: a hex colour field on the platform colour input, reporting the settled colour.", "`Form` (`@lablup/ui-common/Form`): a form engine with antd's form API (Form.Item, Form.List, Form.useForm, Form.useWatch, rules), its messages from the catalog, its item shell on Astryx tokens. `BulkEditFormItem`: a Form.Item that edits one field across many records (keep / edit / clear).", + "`DataGrid`: Astryx Table with a page bar, client or server sorting, row selection, resizable and pinnable columns, expandable rows, per-user column settings and a CSV export picker; `DataGridSettingsModal` and `DataGridExportModal` are its dialogs.", + "`BulkErrorModal`: the failed items of a bulk operation in a DataGrid, under an optional error banner.", + "`ProgressWithLabel`: a bar carrying its label and value label. `TextHighlighter`: marks a search keyword in a string.", + "`CountdownBorder`: a border that fills clockwise as a countdown to the next refresh.", + "`DoubleToken`: welded Tokens for a settled pair. `ListBanner`: a Banner listing items, scrolling past a height.", ], }, { diff --git a/exports.customs.json b/exports.customs.json index 7018dc6..756e3c0 100644 --- a/exports.customs.json +++ b/exports.customs.json @@ -7,29 +7,36 @@ { "name": "BoardItemTitle", "source": "components/BoardItemTitle/index.ts" }, { "name": "BooleanToken", "source": "components/BooleanToken/index.ts" }, { "name": "BulkEditFormItem", "source": "components/BulkEditFormItem/index.ts" }, + { "name": "BulkErrorModal", "source": "components/BulkErrorModal/index.ts" }, { "name": "ColorPicker", "source": "components/ColorPicker/index.ts" }, { "name": "ConfirmPopover", "source": "components/ConfirmPopover/index.ts" }, { "name": "CountBadge", "source": "components/CountBadge/index.ts" }, + { "name": "CountdownBorder", "source": "components/CountdownBorder/index.ts" }, + { "name": "DataGrid", "source": "components/DataGrid/index.ts" }, { "name": "DeleteConfirmModal", "source": "components/DeleteConfirmModal/index.ts" }, { "name": "DigitPopIn", "source": "components/DigitPopIn/index.ts" }, { "name": "DividedRow", "source": "components/DividedRow/index.ts" }, { "name": "DoubleBadge", "source": "components/DoubleBadge/index.ts" }, + { "name": "DoubleToken", "source": "components/DoubleToken/index.ts" }, { "name": "ErrorState", "source": "components/ErrorState/index.ts" }, { "name": "Form", "source": "components/Form/index.ts", "subpath": "Form" }, { "name": "IconWithTooltip", "source": "components/IconWithTooltip/index.ts" }, { "name": "ImageWithFallback", "source": "components/ImageWithFallback/index.ts" }, + { "name": "ListBanner", "source": "components/ListBanner/index.ts" }, { "name": "Modal", "source": "components/Modal/index.ts", "subpath": "Modal" }, { "name": "NotificationItem", "source": "components/NotificationItem/index.ts" }, { "name": "NotificationStack", "source": "components/NotificationStack/index.ts" }, { "name": "OverlayScrollbar", "source": "components/OverlayScrollbar/index.ts" }, { "name": "PageHeader", "source": "components/PageHeader/index.ts" }, { "name": "PageLayout", "source": "components/PageLayout/index.ts" }, + { "name": "ProgressWithLabel", "source": "components/ProgressWithLabel/index.ts" }, { "name": "SelectionLabel", "source": "components/SelectionLabel/index.ts" }, { "name": "Skeleton composites", "source": "components/Skeleton/index.ts" }, { "name": "SmoothHeight", "source": "components/SmoothHeight/index.ts" }, { "name": "StatCard", "source": "components/StatCard/index.ts" }, { "name": "Statistic", "source": "components/Statistic/index.ts" }, { "name": "StepNumberInput", "source": "components/StepNumberInput/index.ts" }, + { "name": "TextHighlighter", "source": "components/TextHighlighter/index.ts" }, { "name": "TokenList", "source": "components/TokenList/index.ts" }, { "name": "TokenRow", "source": "components/TokenRow/index.ts" }, { "name": "UncontrolledInput", "source": "components/UncontrolledInput/index.ts" }, diff --git a/package.json b/package.json index ef13716..9185fc5 100644 --- a/package.json +++ b/package.json @@ -591,6 +591,10 @@ "@astryxdesign/cli": "0.6.2", "@astryxdesign/core": "0.6.2", "@astryxdesign/theme-neutral": "0.6.2", + "@dnd-kit/core": "6.3.1", + "@dnd-kit/modifiers": "9.0.0", + "@dnd-kit/sortable": "10.0.0", + "@dnd-kit/utilities": "3.2.2", "intl-messageformat": "^11.2.9", "jscodeshift": "^17.4.0", "lucide-react": "^1.18.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cfc215d..1f638bd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,18 @@ importers: '@astryxdesign/theme-neutral': specifier: 0.6.2 version: 0.6.2(@astryxdesign/core@0.6.2(@stylexjs/stylex@0.19.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8) + '@dnd-kit/core': + specifier: 6.3.1 + version: 6.3.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@dnd-kit/modifiers': + specifier: 9.0.0 + version: 9.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8) + '@dnd-kit/sortable': + specifier: 10.0.0 + version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8) + '@dnd-kit/utilities': + specifier: 3.2.2 + version: 3.2.2(react@19.2.8) intl-messageformat: specifier: ^11.2.9 version: 11.2.15 @@ -388,6 +400,34 @@ packages: resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} engines: {node: '>=20.19.0'} + '@dnd-kit/accessibility@3.1.1': + resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} + peerDependencies: + react: '>=16.8.0' + + '@dnd-kit/core@6.3.1': + resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@dnd-kit/modifiers@9.0.0': + resolution: {integrity: sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw==} + peerDependencies: + '@dnd-kit/core': ^6.3.0 + react: '>=16.8.0' + + '@dnd-kit/sortable@10.0.0': + resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==} + peerDependencies: + '@dnd-kit/core': ^6.3.0 + react: '>=16.8.0' + + '@dnd-kit/utilities@3.2.2': + resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==} + peerDependencies: + react: '>=16.8.0' + '@eslint-community/eslint-utils@4.10.1': resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -2509,6 +2549,38 @@ snapshots: '@csstools/css-tokenizer@4.0.0': {} + '@dnd-kit/accessibility@3.1.1(react@19.2.8)': + dependencies: + react: 19.2.8 + tslib: 2.8.1 + + '@dnd-kit/core@6.3.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@dnd-kit/accessibility': 3.1.1(react@19.2.8) + '@dnd-kit/utilities': 3.2.2(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + tslib: 2.8.1 + + '@dnd-kit/modifiers@9.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)': + dependencies: + '@dnd-kit/core': 6.3.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@dnd-kit/utilities': 3.2.2(react@19.2.8) + react: 19.2.8 + tslib: 2.8.1 + + '@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)': + dependencies: + '@dnd-kit/core': 6.3.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@dnd-kit/utilities': 3.2.2(react@19.2.8) + react: 19.2.8 + tslib: 2.8.1 + + '@dnd-kit/utilities@3.2.2(react@19.2.8)': + dependencies: + react: 19.2.8 + tslib: 2.8.1 + '@eslint-community/eslint-utils@4.10.1(eslint@9.39.5(jiti@2.7.0)(supports-color@8.1.1))': dependencies: eslint: 9.39.5(jiti@2.7.0)(supports-color@8.1.1) diff --git a/src/components/BulkErrorModal/BulkErrorModal.css b/src/components/BulkErrorModal/BulkErrorModal.css new file mode 100644 index 0000000..aa6d832 --- /dev/null +++ b/src/components/BulkErrorModal/BulkErrorModal.css @@ -0,0 +1,5 @@ +@layer ui-common { + .uic-bulk-error-modal__icon { + color: var(--color-error); + } +} diff --git a/src/components/BulkErrorModal/BulkErrorModal.messages.ts b/src/components/BulkErrorModal/BulkErrorModal.messages.ts new file mode 100644 index 0000000..4289179 --- /dev/null +++ b/src/components/BulkErrorModal/BulkErrorModal.messages.ts @@ -0,0 +1,14 @@ +import { defineMessages } from "../../i18n/catalog"; + +export const bulkErrorModalMessages = defineMessages({ + "uic.BulkErrorModal.title": { + defaultMessage: "Action execution failed", + description: + "Default title of the dialog that lists the failed items of a bulk operation", + }, + "uic.BulkErrorModal.errorOccurred": { + defaultMessage: "Error Occurred", + description: + "Title of the error banner above the failed items, over the caller's guidance", + }, +}); diff --git a/src/components/BulkErrorModal/BulkErrorModal.test.tsx b/src/components/BulkErrorModal/BulkErrorModal.test.tsx new file mode 100644 index 0000000..c6c909b --- /dev/null +++ b/src/components/BulkErrorModal/BulkErrorModal.test.tsx @@ -0,0 +1,149 @@ +import { describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { InternationalizationProvider } from "@astryxdesign/core/i18n"; + +import { uiCommonMessages } from "../../i18n/messages"; +import type { DataGridColumn } from "../DataGrid/DataGrid"; +import { BulkErrorModal } from "./BulkErrorModal"; + +interface Failure { + key: string; + target: string; + reason: string; +} + +const FAILURES: Failure[] = [ + { key: "row-1", target: "project-alpha", reason: "Permission denied" }, + { key: "row-2", target: "project-beta", reason: "Not found" }, +]; + +const COLUMNS: DataGridColumn<Failure>[] = [ + { key: "target", header: "Target", renderCell: (row) => row.target }, + { key: "reason", header: "Error Message", renderCell: (row) => row.reason }, +]; + +const manyFailures = (count: number): Failure[] => + Array.from({ length: count }, (_unused, index) => ({ + key: `row-${index}`, + target: `target-${index}`, + reason: "Failed", + })); + +describe("BulkErrorModal", () => { + it("renders one row per failure with the caller's columns", () => { + render( + <BulkErrorModal + isOpen + onOpenChange={() => {}} + columns={COLUMNS} + data={FAILURES} + />, + ); + + expect(screen.getByRole("columnheader", { name: "Target" })).toBeInTheDocument(); + expect(screen.getByText("project-alpha")).toBeInTheDocument(); + expect(screen.getByText("Not found")).toBeInTheDocument(); + }); + + it("titles itself with the catalog default and has no footer", () => { + render( + <BulkErrorModal + isOpen + onOpenChange={() => {}} + columns={COLUMNS} + data={FAILURES} + />, + ); + + expect(screen.getByRole("dialog")).toHaveAccessibleName("Action execution failed"); + expect(screen.queryByRole("button", { name: "OK" })).toBeNull(); + expect(screen.queryByRole("button", { name: "Cancel" })).toBeNull(); + expect(document.querySelector(".uic-bulk-error-modal__icon")).not.toBeNull(); + }); + + it("shows the description in an error banner, titled by the catalog", () => { + render( + <BulkErrorModal + isOpen + onOpenChange={() => {}} + title="2 changes failed" + description="Fix the failed items and retry." + columns={COLUMNS} + data={FAILURES} + />, + ); + + expect(screen.getByRole("dialog")).toHaveAccessibleName("2 changes failed"); + expect(screen.getByText("Error Occurred")).toBeInTheDocument(); + expect(screen.getByText("Fix the failed items and retry.")).toBeInTheDocument(); + }); + + it("renders no banner without a description", () => { + render( + <BulkErrorModal + isOpen + onOpenChange={() => {}} + columns={COLUMNS} + data={FAILURES} + />, + ); + expect(screen.queryByText("Error Occurred")).toBeNull(); + }); + + it("hides the page bar on one page and pages at ten rows", () => { + const { unmount } = render( + <BulkErrorModal + isOpen + onOpenChange={() => {}} + columns={COLUMNS} + data={FAILURES} + />, + ); + expect(screen.queryByRole("navigation")).toBeNull(); + unmount(); + + render( + <BulkErrorModal + isOpen + onOpenChange={() => {}} + columns={COLUMNS} + data={manyFailures(12)} + />, + ); + expect(screen.getByRole("navigation")).toBeInTheDocument(); + expect(screen.getByText("target-9")).toBeInTheDocument(); + expect(screen.queryByText("target-10")).toBeNull(); + expect(screen.queryByRole("combobox", { name: "Items per page" })).toBeNull(); + }); + + it("closes through the header's close button", async () => { + const onOpenChange = vi.fn(); + render( + <BulkErrorModal + isOpen + onOpenChange={onOpenChange} + columns={COLUMNS} + data={FAILURES} + />, + ); + await userEvent.click(screen.getByRole("button", { name: "Close" })); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + + it("translates its strings", () => { + render( + <InternationalizationProvider locale="ko-KR" messages={uiCommonMessages}> + <BulkErrorModal + isOpen + onOpenChange={() => {}} + description="retry" + columns={COLUMNS} + data={FAILURES} + /> + </InternationalizationProvider>, + ); + expect(screen.getByRole("dialog")).toHaveAccessibleName("실행 오류"); + expect(screen.getByText("문제가 발생했습니다.")).toBeInTheDocument(); + }); +}); diff --git a/src/components/BulkErrorModal/BulkErrorModal.tsx b/src/components/BulkErrorModal/BulkErrorModal.tsx new file mode 100644 index 0000000..bfa04f3 --- /dev/null +++ b/src/components/BulkErrorModal/BulkErrorModal.tsx @@ -0,0 +1,133 @@ +/** + * BulkErrorModal + * + * Reports the failures of a bulk operation: one row per failed item, in a + * `DataGrid` the caller describes with its own columns, since every bulk + * operation reports a different shape. An optional error banner above it + * carries guidance, such as how to retry. The modal is a report: it has no + * footer, and closing goes through the header's close button, the backdrop + * or Escape (`onOpenChange(false)`). + * + * The grid is compact with column rules, pages at ten rows and hides its page + * bar while every row fits on one page. + * + * @example + * <BulkErrorModal + * isOpen={failures.length > 0} + * onOpenChange={(open) => !open && setFailures([])} + * columns={[ + * { key: "name", header: "Folder", renderCell: (row) => row.name }, + * { key: "message", header: "Error", renderCell: (row) => row.message }, + * ]} + * data={failures} + * description="Fix the failed folders and try again." + * /> + */ +import type { ReactElement, ReactNode } from "react"; +import { Banner } from "@astryxdesign/core/Banner"; +import { HStack, VStack } from "@astryxdesign/core/Stack"; +import { TriangleAlert } from "lucide-react"; + +import { useUicTranslator } from "../../i18n/useUicTranslator"; +import { + DataGrid, + type DataGridColumn, + type DataGridProps, +} from "../DataGrid/DataGrid"; +import { Modal, type ModalProps } from "../Modal/Modal"; +import "./BulkErrorModal.css"; + +export interface BulkErrorModalProps<T extends object> extends Omit< + ModalProps, + | "children" + | "title" + | "footer" + | "onAction" + | "actionLabel" + | "actionVariant" + | "isActionLoading" + | "isActionDisabled" + | "actionButtonProps" + | "cancelLabel" + | "hasCancelButton" +> { + /** How one failed item renders, per column. */ + columns: ReadonlyArray<DataGridColumn<T>>; + /** One item per failure. */ + data: ReadonlyArray<T>; + /** Row identity. Default: the item's `id`, then its `key`. */ + idKey?: DataGridProps<T>["idKey"]; + /** Guidance in an error banner above the grid. Without it there is no banner. */ + description?: ReactNode; + /** + * Title of the banner. Default: the catalog's `uic.BulkErrorModal.errorOccurred` + */ + descriptionTitle?: ReactNode; + /** + * Modal title. Default: an error glyph and the catalog's + * `uic.BulkErrorModal.title` ("Action execution failed"). + */ + title?: ReactNode; +} + +const WIDTH = 720; +const PAGE_SIZE = 10; + +export function BulkErrorModal<T extends object>({ + columns, + data, + idKey, + description, + descriptionTitle, + title, + width = WIDTH, + ...modalProps +}: BulkErrorModalProps<T>): ReactElement { + const t = useUicTranslator(); + return ( + <Modal + {...modalProps} + width={width} + title={ + title ?? ( + <HStack gap={2} align="center"> + {/* `var()` does not resolve in the stroke attribute, so the glyph + strokes currentColor and the class sets the colour. */} + <TriangleAlert + color="currentColor" + size="1em" + className="uic-bulk-error-modal__icon" + aria-hidden + /> + {t("uic.BulkErrorModal.title")} + </HStack> + ) + } + footer={null} + > + <VStack gap={3} align="stretch"> + {description ? ( + <Banner + status="error" + title={descriptionTitle ?? t("uic.BulkErrorModal.errorOccurred")} + description={description} + /> + ) : null} + <DataGrid<T> + columns={columns} + data={data} + idKey={idKey} + scrollWidth="max-content" + pagination={{ + pageSize: PAGE_SIZE, + hasPageSizeSelector: false, + isHiddenOnSinglePage: true, + }} + isResizable={false} + density="compact" + dividers="grid" + /> + </VStack> + </Modal> + ); +} diff --git a/src/components/BulkErrorModal/index.ts b/src/components/BulkErrorModal/index.ts new file mode 100644 index 0000000..ba34307 --- /dev/null +++ b/src/components/BulkErrorModal/index.ts @@ -0,0 +1,2 @@ +export { BulkErrorModal } from "./BulkErrorModal"; +export type { BulkErrorModalProps } from "./BulkErrorModal"; diff --git a/src/components/CountdownBorder/CountdownBorder.css b/src/components/CountdownBorder/CountdownBorder.css new file mode 100644 index 0000000..8c584da --- /dev/null +++ b/src/components/CountdownBorder/CountdownBorder.css @@ -0,0 +1,37 @@ +/* + * CountdownBorder. `pathLength={100}` normalises the outline's length, so the + * offset animation draws it clockwise from the top-left in one cycle at any + * size. + */ +@layer ui-common { + @keyframes uic-countdown-border-fill { + from { + stroke-dashoffset: 100; + } + to { + stroke-dashoffset: 0; + } + } + + /* `overflow: visible` with the rect filling the box centres the stroke on + the content's edge. */ + .uic-countdown-border__overlay { + position: absolute; + inset: 0; + z-index: 1; + overflow: visible; + pointer-events: none; + } + + .uic-countdown-border__fill { + animation-name: uic-countdown-border-fill; + animation-timing-function: linear; + animation-iteration-count: infinite; + } + + @media (prefers-reduced-motion: reduce) { + .uic-countdown-border__fill { + animation: none; + } + } +} diff --git a/src/components/CountdownBorder/CountdownBorder.test.tsx b/src/components/CountdownBorder/CountdownBorder.test.tsx new file mode 100644 index 0000000..8cb745b --- /dev/null +++ b/src/components/CountdownBorder/CountdownBorder.test.tsx @@ -0,0 +1,94 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; + +import { CountdownBorder } from "./CountdownBorder"; + +// jsdom has no layout: give every element a box so the overlay renders. +beforeEach(() => { + vi.spyOn(HTMLElement.prototype, "clientWidth", "get").mockReturnValue(80); + vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(32); +}); +afterEach(() => vi.restoreAllMocks()); + +const rectOf = (container: HTMLElement) => + container.querySelector<SVGRectElement>(".uic-countdown-border__fill"); + +describe("CountdownBorder", () => { + it("draws a rect the size of the content that fills over durationMs", () => { + const { container } = render( + <CountdownBorder durationMs={5000}> + <button type="button">Refresh</button> + </CountdownBorder>, + ); + expect(screen.getByRole("button", { name: "Refresh" })).toBeInTheDocument(); + const rect = rectOf(container)!; + expect(rect.getAttribute("width")).toBe("80"); + expect(rect.getAttribute("height")).toBe("32"); + expect(rect.getAttribute("pathLength")).toBe("100"); + expect(rect.style.animationDuration).toBe("5000ms"); + expect(rect.style.animationPlayState).toBe("running"); + expect(rect.style.stroke).toBe("var(--color-accent)"); + expect(container.querySelector("svg")).toHaveAttribute("aria-hidden", "true"); + }); + + it("hides and freezes the border while paused", () => { + const { container } = render( + <CountdownBorder durationMs={1000} isPaused> + x + </CountdownBorder>, + ); + expect(container.querySelector("svg")?.style.visibility).toBe("hidden"); + expect(rectOf(container)?.style.animationPlayState).toBe("paused"); + }); + + it("draws nothing when not animated", () => { + const { container } = render( + <CountdownBorder durationMs={1000} isAnimated={false}> + x + </CountdownBorder>, + ); + expect(container.querySelector("svg")).toBeNull(); + }); + + it("styles the border from style and passes the rest to the wrapper", () => { + const { container } = render( + <CountdownBorder + durationMs={1000} + className="host" + style={{ + stroke: "red", + strokeWidth: 3, + borderRadius: 2, + margin: 4, + position: "static", + }} + > + x + </CountdownBorder>, + ); + const wrapper = container.firstElementChild as HTMLElement; + expect(wrapper).toHaveClass("uic-countdown-border", "host"); + expect(wrapper.style.margin).toBe("4px"); + expect(wrapper.style.position).toBe("relative"); + expect(wrapper.style.stroke).toBe(""); + const rect = rectOf(container)!; + expect(rect.style.stroke).toBe("red"); + expect(rect.getAttribute("stroke-width")).toBe("3"); + expect(rect.getAttribute("rx")).toBe("2"); + }); + + it("restarts the fill when resetKey changes", () => { + const { container, rerender } = render( + <CountdownBorder durationMs={1000} resetKey={1}> + x + </CountdownBorder>, + ); + const first = rectOf(container); + rerender( + <CountdownBorder durationMs={1000} resetKey={2}> + x + </CountdownBorder>, + ); + expect(rectOf(container)).not.toBe(first); + }); +}); diff --git a/src/components/CountdownBorder/CountdownBorder.tsx b/src/components/CountdownBorder/CountdownBorder.tsx new file mode 100644 index 0000000..32af7de --- /dev/null +++ b/src/components/CountdownBorder/CountdownBorder.tsx @@ -0,0 +1,129 @@ +/** + * CountdownBorder + * + * Wraps its children in a rounded-rect border that fills clockwise over + * `durationMs` and starts again: a countdown to the next automatic refresh. + * The wrapper measures its own box, so the outline fits the content at any + * size; the stroke is centred on the content's edge, so half of it stays + * visible over opaque children. Under `prefers-reduced-motion` the border + * does not animate. + * + * The border's look comes from `style`: `stroke` (default + * `var(--color-accent)`), `strokeWidth` (default 1.5) and `borderRadius` + * (default the theme's `--radius-inner`). The rest of `style` reaches the + * wrapper. + * + * @example + * <CountdownBorder durationMs={5000} resetKey={fetchKey} isPaused={isFetching}> + * <IconButton label="Refresh" icon={<RotateCw />} onClick={refetch} /> + * </CountdownBorder> + */ +import { + useEffect, + useRef, + useState, + type CSSProperties, + type Key, + type ReactElement, + type ReactNode, +} from "react"; +import { useTheme } from "@astryxdesign/core/theme"; + +import "./CountdownBorder.css"; + +export interface CountdownBorderProps { + children?: ReactNode; + /** Length of one fill cycle, in milliseconds. */ + durationMs: number; + /** Whether the border shows and fills. Default: true */ + isAnimated?: boolean; + /** + * Restarts the fill on this render when it changes. Pass the trigger of the + * real refresh, so the countdown never drifts from it. + */ + resetKey?: Key; + /** Freezes the fill and hides the border, e.g. while a refresh runs. Default: false */ + isPaused?: boolean; + className?: string; + /** Wrapper style; `stroke`, `strokeWidth` and `borderRadius` style the border. */ + style?: CSSProperties; +} + +export function CountdownBorder({ + children, + durationMs, + isAnimated = true, + resetKey, + isPaused = false, + className, + style, +}: CountdownBorderProps): ReactElement { + const { token } = useTheme(); + const ref = useRef<HTMLDivElement>(null); + const [size, setSize] = useState({ w: 0, h: 0 }); + + useEffect(() => { + const el = ref.current; + if (!el) return; + const measure = () => setSize({ w: el.clientWidth, h: el.clientHeight }); + measure(); + const observer = new ResizeObserver(measure); + observer.observe(el); + return () => observer.disconnect(); + }, []); + + const { + // A var(), so a nested Astryx Theme's accent applies. + stroke = "var(--color-accent)", + strokeWidth = 1.5, + // `rx` takes a length, not a var(): resolved from the theme. + borderRadius = token("--radius-inner"), + ...wrapperStyle + } = style ?? {}; + const { w, h } = size; + + return ( + <div + ref={ref} + className={["uic-countdown-border", className].filter(Boolean).join(" ")} + // `position: relative` stays last: the overlay depends on it. + style={{ display: "inline-flex", ...wrapperStyle, position: "relative" }} + > + {children} + {isAnimated && w > 0 && h > 0 && ( + <svg + aria-hidden + width={w} + height={h} + className="uic-countdown-border__overlay" + // Hidden, not just paused: at offset 100 the dash edge can still + // paint a sliver at the path start. + style={{ visibility: isPaused ? "hidden" : "visible" }} + > + <rect + key={resetKey} + x={0} + y={0} + width={w} + height={h} + rx={borderRadius} + ry={borderRadius} + fill="none" + strokeWidth={strokeWidth} + pathLength={100} + strokeDasharray={100} + className="uic-countdown-border__fill" + style={{ + // A CSS property: var() does not resolve in the stroke attribute. + stroke, + animationDuration: `${durationMs}ms`, + animationPlayState: isPaused ? "paused" : "running", + }} + /> + </svg> + )} + </div> + ); +} + +CountdownBorder.displayName = "CountdownBorder"; diff --git a/src/components/CountdownBorder/index.ts b/src/components/CountdownBorder/index.ts new file mode 100644 index 0000000..051d149 --- /dev/null +++ b/src/components/CountdownBorder/index.ts @@ -0,0 +1,2 @@ +export { CountdownBorder } from "./CountdownBorder"; +export type { CountdownBorderProps } from "./CountdownBorder"; diff --git a/src/components/DataGrid/DataGrid.css b/src/components/DataGrid/DataGrid.css new file mode 100644 index 0000000..04f448c --- /dev/null +++ b/src/components/DataGrid/DataGrid.css @@ -0,0 +1,147 @@ +/* + * DataGrid. Every selector starts at a uic- class of ours and reaches into + * Astryx's Table by element and role only, never through an astryx- class. + * `:not(table table)` / `:not(table div)` keep each rule off grids nested in + * a cell or a detail row. + */ +@layer ui-common { + .uic-data-grid__body { + /* Astryx's Table bleeds into its container's block padding when it is the + first or last child. The table is this wrapper's only child, so both + would fire wherever the grid sits and pull the rows over the content + above and the footer below. Zero the block axis; the inline bleed + stays, so rows still run edge to edge in a Card. */ + --container-padding-block-start: 0px; + --container-padding-block-end: 0px; + transition: opacity var(--duration-fast) var(--ease-standard); + } + + .uic-data-grid__body--loading { + opacity: 0.5; + pointer-events: none; + } + + .uic-data-grid__body--no-header thead { + display: none; + } + + /* Header and body content clip here: the resize and sticky-column plugins + set `overflow: visible` on the cell so their handle and shadow can bleed + out, which cancels the cell's own ellipsis. */ + .uic-data-grid__clip { + display: block; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + /* A sortable header nests the clip inside the sort button's label, a flex + item whose automatic minimum width would floor it at the full label. */ + .uic-data-grid__body thead th button > span { + min-width: 0; + } + + /* The header band: card base under a muted wash, so it stays opaque under + the sticky header of a height-capped grid. */ + .uic-data-grid__body thead th { + background-color: var(--color-background-card); + background-image: linear-gradient( + var(--color-background-muted), + var(--color-background-muted) + ); + } + + /* A split between header cells, drawn only where a resize handle exists. + A background layer, not a pseudo-element: the sticky-column plugin owns + the cell's pseudo-elements. */ + .uic-data-grid__body--header-split + table:not(table table) + > thead + th:has([role="separator"]) { + background-image: + linear-gradient(var(--color-border-emphasized), var(--color-border-emphasized)), + linear-gradient(var(--color-background-muted), var(--color-background-muted)); + background-repeat: no-repeat; + background-position: + right center, + 0 0; + /* 1.6em: the split's height, relative to the header text; no token. */ + background-size: + var(--border-width) 1.6em, + 100% 100%; + } + + /* On a sortable header the resize handle anchors to the wrapper around the + sort button, one cell padding inside the cell. Re-anchor it to the cell. */ + .uic-data-grid__body thead th > div:has(> [role="separator"]) { + position: static; + } + + /* `scrollWidth`: the table takes that width, at least its container's, and + columns without a width size to their content. `!important` beats the + inline min-width the table writes once any column has a pixel width. */ + .uic-data-grid__body--scroll-x table:not(table table) { + table-layout: auto; + width: var(--uic-data-grid-scroll-width); + min-width: 100% !important; + } + + /* `maxHeight`: caps the table's scroll wrapper, found as the element around + the table (the resize plugin adds a boxless wrapper, so its depth varies). */ + .uic-data-grid__body--scroll-y div:has(> table):not(table div) { + max-height: var(--uic-data-grid-max-height); + overflow-y: auto; + } + + /* Sticky header: z 2 clears pinned body cells (z 1); pinned header cells + are lifted to 3 inline. */ + .uic-data-grid__body--scroll-y table:not(table table) > thead th { + position: sticky; + top: 0; + z-index: 2; + background-color: var(--color-background-card); + } + + .uic-data-grid__detail { + padding: var(--spacing-3); + } + + .uic-data-grid__footer { + margin-top: var(--spacing-3); + } + + /* Column settings and CSV export dialogs. */ + .uic-data-grid-dialog { + min-width: 0; + } + + .uic-data-grid-dialog__list { + max-height: var(--uic-data-grid-dialog-list-height); + overflow-y: auto; + } + + .uic-data-grid-dialog__row { + display: flex; + align-items: center; + gap: var(--spacing-2); + padding-block: var(--spacing-1); + } + + .uic-data-grid-dialog__option { + padding-block: var(--spacing-1); + } + + .uic-data-grid-dialog__handle { + display: inline-flex; + cursor: grab; + color: var(--color-icon-secondary); + } + + /* Keeps the checkbox of a row that cannot be dragged aligned with the + others: the width of the grip glyph. */ + .uic-data-grid-dialog__handle-space { + width: var(--spacing-4); + flex: none; + } +} diff --git a/src/components/DataGrid/DataGrid.messages.ts b/src/components/DataGrid/DataGrid.messages.ts new file mode 100644 index 0000000..ff600b2 --- /dev/null +++ b/src/components/DataGrid/DataGrid.messages.ts @@ -0,0 +1,53 @@ +import { defineMessages } from "../../i18n/catalog"; + +export const dataGridMessages = defineMessages({ + "uic.DataGrid.noData": { + defaultMessage: "No data to display", + description: "Shown in place of a data grid's rows when there are none", + }, + "uic.DataGrid.invalidPage": { + defaultMessage: "Invalid page number", + description: + "Shown in place of a data grid's rows when the current page lies past the last page", + }, + "uic.DataGrid.goToFirstPage": { + defaultMessage: "Go to first page", + description: "Button under the invalid page message that returns to page 1", + }, + "uic.DataGrid.expandRow": { + defaultMessage: "Expand row", + description: "Accessible name of the button that shows or hides a row's details", + }, + "uic.DataGrid.pagination": { + defaultMessage: "Pagination", + description: "Accessible name of a data grid's page navigation", + }, + "uic.DataGrid.range": { + defaultMessage: "{start} - {end} of {total} items", + description: + "Beside a data grid's page navigation: the rows on screen out of the total. {start} and {end} are 1-based row numbers", + }, + "uic.DataGrid.settings": { + defaultMessage: "Table Settings", + description: + "Button under a data grid that opens its column settings, and the title of that dialog", + }, + "uic.DataGrid.exportCsv": { + defaultMessage: "Export CSV", + description: + "Button under a data grid that opens the CSV export dialog, and the title of that dialog", + }, + "uic.DataGrid.selectColumns": { + defaultMessage: "Select columns to display", + description: "Subtitle of a data grid's column settings and CSV export dialogs", + }, + "uic.DataGrid.searchColumns": { + defaultMessage: "Search table columns", + description: + "Placeholder of the column search field in a data grid's dialogs, also shown when no column matches", + }, + "uic.DataGrid.export": { + defaultMessage: "Export", + description: "Primary button of a data grid's CSV export dialog", + }, +}); diff --git a/src/components/DataGrid/DataGrid.test.tsx b/src/components/DataGrid/DataGrid.test.tsx new file mode 100644 index 0000000..c7640e8 --- /dev/null +++ b/src/components/DataGrid/DataGrid.test.tsx @@ -0,0 +1,598 @@ +import { useState, type ComponentProps } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { InternationalizationProvider } from "@astryxdesign/core/i18n"; + +import { uiCommonMessages } from "../../i18n/messages"; +import { DataGrid, type DataGridColumn } from "./DataGrid"; + +interface Row { + id: string; + name: string; + size: number; +} + +const makeRows = (count: number): Row[] => + Array.from({ length: count }, (_unused, index) => ({ + id: String(index + 1), + name: `row-${index + 1}`, + size: index + 1, + })); + +const COLUMNS: DataGridColumn<Row>[] = [ + { key: "name", header: "Name", renderCell: (row) => row.name }, +]; + +type Props = ComponentProps<typeof DataGrid<Row>>; + +const renderGrid = (props: Partial<Props> = {}) => + render(<DataGrid<Row> idKey="id" columns={COLUMNS} {...props} />); + +const bodyOf = (container: HTMLElement) => + container.querySelector<HTMLElement>(".uic-data-grid__body")!; + +const pickPageSize = async (size: number) => { + await userEvent.click(screen.getByRole("combobox", { name: "Items per page" })); + await userEvent.click(screen.getByRole("option", { name: String(size) })); +}; + +describe("DataGrid pagination", () => { + it("slices the rows to the default page size and shows the range", () => { + renderGrid({ data: makeRows(25) }); + + expect(screen.getByText("row-10")).toBeInTheDocument(); + expect(screen.queryByText("row-11")).not.toBeInTheDocument(); + expect(screen.getByText("1 - 10 of 25 items")).toBeInTheDocument(); + expect(screen.getByRole("navigation", { name: "Pagination" })).toBeInTheDocument(); + }); + + it("renders the requested page", () => { + renderGrid({ data: makeRows(25), pagination: { page: 3, pageSize: 10 } }); + + expect(screen.queryByText("row-20")).not.toBeInTheDocument(); + expect(screen.getByText("row-21")).toBeInTheDocument(); + expect(screen.getByText("21 - 25 of 25 items")).toBeInTheDocument(); + }); + + it("leaves rows alone when totalItems says they are already one page", () => { + renderGrid({ + data: makeRows(20), + pagination: { page: 5, pageSize: 10, totalItems: 250 }, + }); + + expect(screen.getByText("row-1")).toBeInTheDocument(); + expect(screen.getByText("row-20")).toBeInTheDocument(); + }); + + it("renders every row and no bar when pagination is false", () => { + renderGrid({ data: makeRows(25), pagination: false }); + + expect(screen.getByText("row-25")).toBeInTheDocument(); + expect(screen.queryByRole("navigation")).not.toBeInTheDocument(); + }); + + it("hides the bar on a single page when asked", () => { + renderGrid({ data: makeRows(3), pagination: { isHiddenOnSinglePage: true } }); + expect(screen.queryByRole("navigation")).not.toBeInTheDocument(); + }); + + it("withholds the page-size choice when hasPageSizeSelector is false", () => { + renderGrid({ data: makeRows(25), pagination: { hasPageSizeSelector: false } }); + expect(screen.queryByRole("combobox", { name: "Items per page" })).toBeNull(); + }); + + it("reports a plain page change once, with the current size", async () => { + const onChange = vi.fn(); + renderGrid({ data: makeRows(100), pagination: { onChange } }); + + await userEvent.click(screen.getByRole("button", { name: "Go to page 2" })); + + expect(onChange.mock.calls).toEqual([[2, 10]]); + expect(screen.getByText("row-11")).toBeInTheDocument(); + }); + + it("reports a page-size pick once, with the new size", async () => { + const onChange = vi.fn(); + renderGrid({ + data: makeRows(10), + pagination: { page: 3, pageSize: 10, totalItems: 100, onChange }, + }); + + await pickPageSize(20); + + expect(onChange.mock.calls).toEqual([[1, 20]]); + }); + + it("keeps the picked size on a controlled grid", async () => { + const Controlled = () => { + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(10); + return ( + <DataGrid<Row> + idKey="id" + columns={COLUMNS} + data={makeRows(25)} + pagination={{ + page, + pageSize, + onChange: (nextPage, nextSize) => { + setPage(nextPage); + setPageSize(nextSize); + }, + }} + /> + ); + }; + render(<Controlled />); + + await pickPageSize(20); + + expect(screen.getByText("row-20")).toBeInTheDocument(); + expect(screen.queryByText("row-21")).not.toBeInTheDocument(); + }); + + it("still reports the page changes that follow a size pick", async () => { + const onChange = vi.fn(); + renderGrid({ data: makeRows(100), pagination: { onChange } }); + + await pickPageSize(20); + await userEvent.click(screen.getByRole("button", { name: "Go to page 2" })); + + expect(onChange).toHaveBeenLastCalledWith(2, 20); + }); + + it("renders endContent at the end of the bar", () => { + renderGrid({ data: makeRows(3), pagination: { endContent: <span>extra</span> } }); + expect(screen.getByText("extra")).toBeInTheDocument(); + }); +}); + +describe("DataGrid invalid page", () => { + const OUT_OF_RANGE = { page: 20, pageSize: 10, totalItems: 177 }; + + it("offers a way back when the page is past the last one", async () => { + const onChange = vi.fn(); + renderGrid({ data: [], pagination: { ...OUT_OF_RANGE, onChange } }); + + expect(screen.getByText("Invalid page number")).toBeInTheDocument(); + expect(screen.queryByText("No data to display")).not.toBeInTheDocument(); + + await userEvent.click(screen.getByRole("button", { name: "Go to first page" })); + expect(onChange).toHaveBeenCalledWith(1, 10); + }); + + it.each([ + ["already sliced", { data: makeRows(10), totalItems: 177 }], + ["client-side", { data: makeRows(25), totalItems: undefined }], + ])("hides a %s list while the page is below 1", (_label, { data, totalItems }) => { + renderGrid({ data, pagination: { page: 0, pageSize: 10, totalItems } }); + + expect(screen.getByText("Invalid page number")).toBeInTheDocument(); + expect(screen.queryByText("row-1")).not.toBeInTheDocument(); + }); + + it.each([ + ["a string", "Custom empty"], + ["false", false], + ] as const)("wins over an empty state of %s", (_label, emptyState) => { + renderGrid({ data: [], emptyState, pagination: OUT_OF_RANGE }); + + expect(screen.getByText("Invalid page number")).toBeInTheDocument(); + expect(screen.queryByText("Custom empty")).not.toBeInTheDocument(); + }); + + it("treats an empty result as no data, not an invalid page", () => { + renderGrid({ data: [], pagination: { page: 3, pageSize: 10, totalItems: 0 } }); + + expect(screen.getByText("No data to display")).toBeInTheDocument(); + expect(screen.queryByText("Invalid page number")).not.toBeInTheDocument(); + }); +}); + +describe("DataGrid empty state", () => { + it("renders the catalog default", () => { + renderGrid({ data: [] }); + expect(screen.getByText("No data to display")).toBeInTheDocument(); + }); + + it("wraps a string", () => { + renderGrid({ data: [], emptyState: "Nothing yet" }); + expect(screen.getByText("Nothing yet")).toBeInTheDocument(); + }); + + it("passes a node through", () => { + renderGrid({ data: [], emptyState: <div data-testid="custom">custom</div> }); + expect(screen.getByTestId("custom")).toBeInTheDocument(); + expect(screen.queryByText("No data to display")).not.toBeInTheDocument(); + }); + + it("renders nothing for false", () => { + renderGrid({ data: [], emptyState: false }); + expect(screen.queryByText("No data to display")).not.toBeInTheDocument(); + }); +}); + +describe("DataGrid sorting", () => { + const SIZE_COLUMNS: DataGridColumn<Row>[] = [ + { key: "name", header: "Name", renderCell: (row) => row.name }, + { + key: "size", + header: "Size", + renderCell: (row) => String(row.size), + sortKey: "size", + compare: (a, b) => a.size - b.size, + }, + ]; + + const namesInOrder = (container: HTMLElement) => + [...container.querySelectorAll("tbody tr")].map( + (row) => row.querySelector("td")?.textContent, + ); + + it("sorts on the client by the column's compare, descending reversed", () => { + const { container } = renderGrid({ + data: makeRows(3), + columns: SIZE_COLUMNS, + defaultSort: { sortKey: "size", direction: "descending" }, + }); + expect(namesInOrder(container)).toEqual(["row-3", "row-2", "row-1"]); + }); + + it("changes the uncontrolled sort from the header and reports it", async () => { + const onSortChange = vi.fn(); + const { container } = renderGrid({ + data: makeRows(3), + columns: SIZE_COLUMNS, + defaultSort: { sortKey: "size", direction: "descending" }, + onSortChange, + }); + + await userEvent.click(screen.getByText("Size")); + + expect(onSortChange).toHaveBeenCalledTimes(1); + const next = onSortChange.mock.calls[0]?.[0]; + expect(next === null || next.sortKey === "size").toBe(true); + if (next?.direction === "ascending") { + expect(namesInOrder(container)).toEqual(["row-1", "row-2", "row-3"]); + } + }); + + it("only reports a controlled sort, leaving rows without compare as given", async () => { + const onSortChange = vi.fn(); + const { container } = renderGrid({ + data: makeRows(3), + columns: [ + { key: "name", header: "Name", renderCell: (row) => row.name }, + { + key: "size", + header: "Size", + renderCell: (row) => String(row.size), + sortKey: "bytes", + }, + ], + sort: null, + onSortChange, + }); + + await userEvent.click(screen.getByText("Size")); + + expect(onSortChange).toHaveBeenCalledTimes(1); + expect(onSortChange.mock.calls[0]?.[0]?.sortKey).toBe("bytes"); + expect(namesInOrder(container)).toEqual(["row-1", "row-2", "row-3"]); + }); +}); + +describe("DataGrid selection", () => { + it("reports selected keys and items; select-all covers the page", async () => { + const onChange = vi.fn(); + renderGrid({ + data: makeRows(3), + selection: { selectedKeys: [], onChange, getRowLabel: (row) => row.name }, + }); + + await userEvent.click(screen.getByRole("checkbox", { name: "Select row-2" })); + expect(onChange).toHaveBeenLastCalledWith( + ["2"], + [expect.objectContaining({ id: "2" })], + ); + + await userEvent.click(screen.getByRole("checkbox", { name: "Select all rows" })); + expect(onChange.mock.lastCall?.[0]).toEqual(["1", "2", "3"]); + }); + + it("disables rows getIsItemEnabled rejects", () => { + renderGrid({ + data: makeRows(2), + selection: { + selectedKeys: [], + getRowLabel: (row) => row.name, + getIsItemEnabled: (row) => row.id !== "1", + }, + }); + expect(screen.getByRole("checkbox", { name: "Select row-1" })).toBeDisabled(); + expect(screen.getByRole("checkbox", { name: "Select row-2" })).toBeEnabled(); + }); + + it("keeps keys from other pages on select-all only with isPreservingOtherPages", async () => { + const onChange = vi.fn(); + renderGrid({ + data: makeRows(2), + selection: { + selectedKeys: ["elsewhere"], + onChange, + getRowLabel: (row) => row.name, + isPreservingOtherPages: true, + }, + }); + await userEvent.click(screen.getByRole("checkbox", { name: "Select all rows" })); + expect(onChange.mock.lastCall?.[0]).toEqual(["elsewhere", "1", "2"]); + }); +}); + +describe("DataGrid expansion", () => { + const expansion = { + renderExpandedRow: (row: Row) => <span>detail for {row.name}</span>, + }; + const headerWidth = (container: HTMLElement, index: number) => + container.querySelectorAll<HTMLTableCellElement>("thead th")[index]?.style.width; + + it("toggles a detail row under the row", async () => { + renderGrid({ data: makeRows(1), expansion }); + + expect(screen.queryByText("detail for row-1")).not.toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Expand row" })); + expect(screen.getByText("detail for row-1")).toBeInTheDocument(); + }); + + it("sizes the button column 56px first, 40px behind a selection column", () => { + const first = renderGrid({ data: makeRows(1), expansion }); + expect(headerWidth(first.container, 0)).toBe("56px"); + first.unmount(); + + const behind = renderGrid({ + data: makeRows(1), + expansion, + selection: { selectedKeys: [] }, + }); + expect(headerWidth(behind.container, 1)).toBe("40px"); + }); + + it("honours columnWidth", () => { + const { container } = renderGrid({ + data: makeRows(1), + expansion: { ...expansion, columnWidth: 72 }, + }); + expect(headerWidth(container, 0)).toBe("72px"); + }); +}); + +describe("DataGrid scrolling", () => { + const WIDTH_COLUMNS: DataGridColumn<Row>[] = [ + { key: "name", header: "Name", renderCell: (row) => row.name, width: 120 }, + { key: "size", header: "Size", renderCell: (row) => String(row.size) }, + ]; + + it.each([ + ["max-content", "max-content"], + [800, "800px"], + ] as const)("maps scrollWidth=%s onto the custom property", (value, expected) => { + const { container } = renderGrid({ data: makeRows(1), scrollWidth: value }); + const body = bodyOf(container); + expect(body).toHaveClass("uic-data-grid__body--scroll-x"); + expect(body.style.getPropertyValue("--uic-data-grid-scroll-width")).toBe(expected); + }); + + it("releases max-width on width-less columns only", () => { + const { container } = renderGrid({ + data: makeRows(1), + columns: WIDTH_COLUMNS, + scrollWidth: "max-content", + }); + const [nameCell, sizeCell] = + container.querySelectorAll<HTMLTableCellElement>("tbody td"); + expect(nameCell?.style.maxWidth).toBe(""); + expect(sizeCell?.style.maxWidth).toBe("none"); + }); + + it("maps maxHeight and lifts a pinned header above the sticky ones", () => { + const { container } = renderGrid({ + data: makeRows(1), + columns: [{ ...WIDTH_COLUMNS[0]!, pin: "start" }, WIDTH_COLUMNS[1]!], + maxHeight: 500, + }); + const body = bodyOf(container); + expect(body).toHaveClass("uic-data-grid__body--scroll-y"); + expect(body.style.getPropertyValue("--uic-data-grid-max-height")).toBe("500px"); + const [pinned, plain] = + container.querySelectorAll<HTMLTableCellElement>("thead th"); + expect(pinned?.style.zIndex).toBe("3"); + expect(plain?.style.zIndex).toBe(""); + }); + + it("stays off without scrollWidth or maxHeight", () => { + const { container } = renderGrid({ data: makeRows(1) }); + const body = bodyOf(container); + expect(body).not.toHaveClass("uic-data-grid__body--scroll-x"); + expect(body).not.toHaveClass("uic-data-grid__body--scroll-y"); + }); +}); + +describe("DataGrid chrome", () => { + it("dims and marks busy while loading", () => { + const { container } = renderGrid({ data: makeRows(1), isLoading: true }); + const body = bodyOf(container); + expect(body).toHaveAttribute("aria-busy", "true"); + expect(body).toHaveClass("uic-data-grid__body--loading"); + }); + + it("hides the header row with isHeaderHidden", () => { + const { container } = renderGrid({ data: makeRows(1), isHeaderHidden: true }); + expect(bodyOf(container)).toHaveClass("uic-data-grid__body--no-header"); + }); + + it("splits header cells unless dividers is grid", () => { + const rows = renderGrid({ data: makeRows(1) }); + expect(bodyOf(rows.container)).toHaveClass("uic-data-grid__body--header-split"); + rows.unmount(); + const grid = renderGrid({ data: makeRows(1), dividers: "grid" }); + expect(bodyOf(grid.container)).not.toHaveClass("uic-data-grid__body--header-split"); + }); + + it("passes the row's page index to renderCell and getRowProps", () => { + const getRowProps = vi.fn(() => ({ title: "row" })); + renderGrid({ + data: makeRows(12), + pagination: { page: 2, pageSize: 10 }, + columns: [ + { + key: "name", + header: "Name", + renderCell: (row, index) => `${row.name}@${index}`, + }, + ], + getRowProps, + }); + expect(screen.getByText("row-12@1")).toBeInTheDocument(); + expect(getRowProps).toHaveBeenCalledWith(expect.objectContaining({ id: "12" }), 1); + }); + + it("applies a column's getCellProps to its body cells", () => { + const { container } = renderGrid({ + data: makeRows(1), + columns: [ + { + key: "name", + header: "Name", + renderCell: (row) => row.name, + getCellProps: () => ({ className: "marked", style: { color: "inherit" } }), + }, + ], + }); + expect(container.querySelector("tbody td")).toHaveClass("marked"); + }); + + it("falls back from idKey to key, id and position", () => { + const { container } = render( + <DataGrid + columns={[{ key: "label", header: "Label" }]} + idKey="missing" + data={[{ label: "a", key: "k1" }, { label: "b", id: "i2" }, { label: "c" }]} + />, + ); + expect(container.querySelectorAll("tbody tr")).toHaveLength(3); + expect(screen.getByText("c")).toBeInTheDocument(); + }); + + it("renders a group caption above the header", () => { + renderGrid({ + data: makeRows(1), + columns: [{ ...COLUMNS[0]!, groupHeader: "Identity" }], + }); + const header = screen.getAllByRole("columnheader")[0]!; + expect(within(header).getByText("Identity")).toBeInTheDocument(); + expect(within(header).getByText("Name")).toBeInTheDocument(); + }); +}); + +describe("DataGrid column settings", () => { + const THREE: DataGridColumn<Row>[] = [ + { + key: "name", + header: "Name", + renderCell: (row) => row.name, + isAlwaysVisible: true, + }, + { key: "size", header: "Size", renderCell: (row) => String(row.size) }, + { key: "extra", header: "Extra", renderCell: () => "x", isHiddenByDefault: true }, + ]; + const headers = () => screen.getAllByRole("columnheader").map((th) => th.textContent); + + it("applies visibility and order from the overrides record", () => { + renderGrid({ + data: makeRows(1), + columns: THREE, + columnSettings: { + overrides: { + size: { order: 0 }, + name: { order: 1 }, + extra: { hidden: false, order: 2 }, + }, + }, + }); + expect(headers()).toEqual(["Size", "Name", "Extra"]); + }); + + it("writes the dialog's result back as overrides, keeping widths", async () => { + const onOverridesChange = vi.fn(); + renderGrid({ + data: makeRows(1), + columns: THREE, + columnSettings: { + defaultOverrides: { size: { width: 140 } }, + onOverridesChange, + }, + }); + expect(headers()).toEqual(["Name", "Size"]); + + await userEvent.click(screen.getByRole("button", { name: "Table Settings" })); + const dialog = await screen.findByRole("dialog"); + await userEvent.click(within(dialog).getByRole("checkbox", { name: "Extra" })); + await userEvent.click(within(dialog).getByRole("checkbox", { name: "Size" })); + await userEvent.click(within(dialog).getByRole("button", { name: "Apply" })); + + expect(onOverridesChange).toHaveBeenLastCalledWith({ + size: { hidden: true, width: 140 }, + extra: { hidden: false }, + }); + expect(headers()).toEqual(["Name", "Extra"]); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); +}); + +describe("DataGrid CSV export", () => { + it("opens the export dialog with its notice and closes after the export", async () => { + const onExport = vi.fn(async () => {}); + renderGrid({ + data: makeRows(1), + columns: [{ ...COLUMNS[0]!, exportKeys: ["name"] }], + csvExport: { + supportedKeys: ["name"], + onExport, + notice: "Only the first 1000 rows.", + }, + }); + + await userEvent.click(screen.getByRole("button", { name: "Export CSV" })); + const dialog = await screen.findByRole("dialog"); + expect(within(dialog).getByTestId("uic-data-grid-export-notice")).toHaveTextContent( + "Only the first 1000 rows.", + ); + await userEvent.click(within(dialog).getByRole("button", { name: "Export" })); + + expect(onExport).toHaveBeenCalledWith(["name"]); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); +}); + +describe("DataGrid strings", () => { + it("translates its chrome through the Astryx provider", () => { + render( + <InternationalizationProvider locale="ko-KR" messages={uiCommonMessages}> + <DataGrid<Row> idKey="id" columns={COLUMNS} data={[]} /> + </InternationalizationProvider>, + ); + expect(screen.getByText("표시할 데이터가 없습니다")).toBeInTheDocument(); + }); + + it("lets string props win over the catalog", () => { + renderGrid({ + data: makeRows(1), + columnSettings: {}, + settingsLabel: "Columns", + renderRange: ({ total }) => `${total} total`, + }); + expect(screen.getByRole("button", { name: "Columns" })).toBeInTheDocument(); + expect(screen.getByText("1 total")).toBeInTheDocument(); + }); +}); diff --git a/src/components/DataGrid/DataGrid.tsx b/src/components/DataGrid/DataGrid.tsx new file mode 100644 index 0000000..905d46c --- /dev/null +++ b/src/components/DataGrid/DataGrid.tsx @@ -0,0 +1,1110 @@ +/** + * DataGrid + * + * Astryx `Table` with the pieces a list page needs around it: a page bar with + * a range line, client or server sorting, row selection by key, resizable + * and pinnable columns, expandable rows, per-user column settings (visibility, + * order, width) and a CSV export picker. + * + * Rows are sliced to the page unless `pagination.totalItems` says the caller + * already sliced them. Sorting is client-side for columns with `compare`, + * otherwise it only reports `onSortChange`. Column settings live in one + * overrides record (`hidden`, `order`, `width` per column key) that a product + * persists as it likes. + * + * Plugin order matters: Astryx runs columnSettings, sort, selection, then + * unknown names in insertion order. `resize` / `sticky` / `scrollX` / + * `scrollY` / `cellRow` / `expansion` read the final column list, and + * `scrollY` runs before `cellRow` so a column's `getCellProps` wins. + * + * @example + * <DataGrid + * data={users} + * idKey="id" + * columns={[ + * { key: "name", header: "Name", renderCell: (u) => u.name, sortKey: "name" }, + * { key: "email", header: "Email", renderCell: (u) => u.email }, + * ]} + * sort={sort} + * onSortChange={setSort} + * pagination={{ page, pageSize, totalItems, onChange: setPage }} + * /> + */ +import { + isValidElement, + type Key, + useRef, + useState, + type CSSProperties, + type HTMLAttributes, + type ReactElement, + type ReactNode, + type TdHTMLAttributes, +} from "react"; +import { EmptyState } from "@astryxdesign/core/EmptyState"; +import { Button } from "@astryxdesign/core/Button"; +import { Icon } from "@astryxdesign/core/Icon"; +import { IconButton } from "@astryxdesign/core/IconButton"; +import { Pagination, type PaginationProps } from "@astryxdesign/core/Pagination"; +import { HStack, VStack } from "@astryxdesign/core/Stack"; +import { + Table, + pixel, + proportional, + useTableColumnResize, + useTableColumnSettings, + useTableSelection, + useTableSortable, + useTableStickyColumns, + type TableColumn, + type TablePlugin, + type TableProps, + type TableSortState, +} from "@astryxdesign/core/Table"; +import { Text } from "@astryxdesign/core/Text"; +import { ChevronDown, ChevronRight, FileDown, Inbox, Settings } from "lucide-react"; + +import { useUicTranslator } from "../../i18n/useUicTranslator"; +import { + DataGridExportModal, + type DataGridExportModalProps, +} from "./DataGridExportModal"; +import { + DataGridSettingsModal, + type DataGridSettingsModalProps, + type DataGridSettingsResult, +} from "./DataGridSettingsModal"; +import { nodeToPlainText } from "./nodeToPlainText"; +import "./DataGrid.css"; + +/** A row identity, as `idKey` returns it. */ +export type DataGridKey = Key; + +export type DataGridSortDirection = "ascending" | "descending"; + +/** The active sort: one column, by its `sortKey`. */ +export interface DataGridSort { + sortKey: string; + direction: DataGridSortDirection; +} + +export interface DataGridColumn<T> { + /** Stable identity: column settings, widths and pinning use it. */ + key: string; + /** Header content. It is clipped to the column width. */ + header?: ReactNode; + /** Caption above the header, for a column that belongs to a group. */ + groupHeader?: ReactNode; + /** + * Plain-text name in the settings and export dialogs. Default: the text of + * `groupHeader` and `header` joined with " / ", else the key. + */ + label?: string; + /** Cell content. `index` is the row's position on the current page. */ + renderCell?: (item: T, index: number) => ReactNode; + /** Width in pixels. A resized width replaces it. Without one the column flexes. */ + width?: number; + /** Lower bound, in pixels, of a flexing column. */ + minWidth?: number; + align?: "start" | "center" | "end"; + /** Makes the column sortable. The sort state names it by this key. */ + sortKey?: string; + /** + * Sorts the rows on the client when this column is sorted. Compare in + * ascending order; the grid reverses the result for descending. + */ + compare?: (a: T, b: T, direction: DataGridSortDirection) => number; + /** + * Pins the column to an edge. Only the contiguous run of pinned columns at + * each edge of the displayed order is pinned. + */ + pin?: "start" | "end"; + /** Column settings cannot hide it. */ + isAlwaysVisible?: boolean; + /** Hidden until the user shows it in the column settings. */ + isHiddenByDefault?: boolean; + /** Fields this column exports as. Without them it cannot be exported. */ + exportKeys?: ReadonlyArray<string>; + /** Extra attributes for this column's body cells. */ + getCellProps?: (item: T, index: number) => TdHTMLAttributes<HTMLTableCellElement>; +} + +/** Per-column settings a user changed, keyed by column key. */ +export interface DataGridColumnOverride { + hidden?: boolean; + /** Display position. Lower comes first. Set only after a reorder. */ + order?: number; + /** Resized width in pixels. */ + width?: number; +} + +export type DataGridColumnOverrides = Record<string, DataGridColumnOverride>; + +export interface DataGridColumnSettings { + /** Controlled overrides. */ + overrides?: DataGridColumnOverrides; + /** Initial overrides when uncontrolled; always merged under `overrides`. */ + defaultOverrides?: DataGridColumnOverrides; + onOverridesChange?: (overrides: DataGridColumnOverrides) => void; + /** Whether the settings dialog lets the user reorder columns. Default: true */ + isReorderable?: boolean; + /** Strings of the settings dialog. */ + modalProps?: Pick< + DataGridSettingsModalProps, + "title" | "subtitle" | "searchLabel" | "noMatchText" | "applyLabel" | "cancelLabel" + >; +} + +export interface DataGridCsvExport { + /** Export keys the backend can produce. Columns asking for others are disabled. */ + supportedKeys: ReadonlyArray<string>; + /** Runs the export with the chosen keys. The dialog closes when it resolves. */ + onExport: (keys: string[]) => Promise<void>; + /** A warning above the column list, e.g. that the export is truncated. */ + notice?: ReactNode; + /** Strings of the export dialog. */ + modalProps?: Pick< + DataGridExportModalProps, + "title" | "subtitle" | "searchLabel" | "noMatchText" | "exportLabel" | "cancelLabel" + >; +} + +export interface DataGridSelection<T> { + /** The selected row keys. Selection is controlled. */ + selectedKeys?: ReadonlyArray<DataGridKey>; + onChange?: (keys: string[], items: T[]) => void; + /** Whether a row's checkbox is enabled. */ + getIsItemEnabled?: (item: T) => boolean; + /** Accessible name of a row's checkbox. Default: the row key. */ + getRowLabel?: (item: T) => string; + /** Select-all keeps keys of rows on other pages. Default: false */ + isPreservingOtherPages?: boolean; +} + +export interface DataGridExpansion<T> { + renderExpandedRow: (item: T, index: number) => ReactNode; + getIsRowExpandable?: (item: T) => boolean; + /** Controlled expanded row keys. */ + expandedKeys?: ReadonlyArray<DataGridKey>; + defaultExpandedKeys?: ReadonlyArray<DataGridKey>; + onExpandedKeysChange?: (keys: ReadonlyArray<DataGridKey>) => void; + /** Header content of the expand-button column. */ + columnHeader?: ReactNode; + /** Width in pixels of the expand-button column. */ + columnWidth?: number; +} + +export interface DataGridPagination extends Omit< + PaginationProps, + | "page" + | "totalItems" + | "onChange" + | "onPageSizeChange" + | "label" + | "ref" + | "totalPages" + | "hasMore" +> { + /** Controlled page, 1-based. */ + page?: number; + defaultPage?: number; + defaultPageSize?: number; + /** + * Total row count. Without it the grid pages `data` itself; a total larger + * than `data` declares the rows already sliced to the page. + */ + totalItems?: number; + onChange?: (page: number, pageSize: number) => void; + /** Whether the bar offers a page-size choice. Default: true */ + hasPageSizeSelector?: boolean; + /** Hides the page bar while every row fits on one page. Default: false */ + isHiddenOnSinglePage?: boolean; + /** Content at the end of the bar. */ + endContent?: ReactNode; +} + +type AnyRow = Record<string, unknown>; + +export interface DataGridProps<T extends object = AnyRow> extends Omit< + TableProps<AnyRow>, + | "data" + | "columns" + | "idKey" + | "rowIndexStart" + | "rowCount" + | "children" + | "scrollWrapper" + | "ref" + | "emptyState" + | "className" + | "style" + | "plugins" + | "onChange" +> { + data?: ReadonlyArray<T>; + columns?: ReadonlyArray<DataGridColumn<T>>; + /** + * Row identity: a field name or a function. A missing field falls back to + * the row's `key`, then its `id`, then its position. + */ + idKey?: string | ((item: T) => DataGridKey); + /** Dims the rows and blocks input while data is refetched. */ + isLoading?: boolean; + /** Drag-to-resize column borders. Default: true */ + isResizable?: boolean; + /** Controlled sort; `null` for none. */ + sort?: DataGridSort | null; + defaultSort?: DataGridSort | null; + onSortChange?: (sort: DataGridSort | null) => void; + selection?: DataGridSelection<T>; + /** The page bar. `false` shows every row and no bar. */ + pagination?: false | DataGridPagination; + /** Enables the column settings button and persists resized widths. */ + columnSettings?: DataGridColumnSettings; + /** Enables the CSV export button. */ + csvExport?: DataGridCsvExport; + expansion?: DataGridExpansion<T>; + /** + * Shown in place of the rows when there are none. A string gets the default + * icon and layout, a node is rendered as is, `false` shows nothing. Default: + * the catalog's `uic.DataGrid.noData`. + */ + emptyState?: ReactNode | false; + /** Extra attributes for a body row. */ + getRowProps?: ( + item: T, + index: number | undefined, + ) => HTMLAttributes<HTMLTableRowElement>; + /** + * Scrolls horizontally with the table at this width (at least the + * container's). Columns without a width then size to their content. + */ + scrollWidth?: number | string; + /** Caps the rows' height and makes the header sticky. */ + maxHeight?: number | string; + /** Hides the header row, and with it sorting and select-all. */ + isHeaderHidden?: boolean; + /** Accessible name of the expand buttons. Default: the catalog's `uic.DataGrid.expandRow` */ + expandRowLabel?: string; + /** Shown when the page lies past the last one. Default: the catalog's `uic.DataGrid.invalidPage` */ + invalidPageText?: string; + /** Default: the catalog's `uic.DataGrid.goToFirstPage` */ + goToFirstPageLabel?: string; + /** Accessible name of the page navigation. Default: the catalog's `uic.DataGrid.pagination` */ + paginationLabel?: string; + /** The range line beside the page navigation. Default: the catalog's `uic.DataGrid.range` */ + renderRange?: (range: { start: number; end: number; total: number }) => ReactNode; + /** Default: the catalog's `uic.DataGrid.settings` */ + settingsLabel?: string; + /** Default: the catalog's `uic.DataGrid.exportCsv` */ + exportLabel?: string; + className?: string; + style?: CSSProperties; +} + +const EXPAND_COLUMN_KEY = "__uic_expand__"; +/** Key of the checkbox column Astryx's selection plugin injects; not exported by Astryx. */ +const SELECTION_COLUMN_KEY = "__xds_selection"; +/** + * Measured column widths, in pixels. Selection: Astryx's 24px first-column + * inset + the 20px checkbox + an 8px trailing pad (the plugin's own 36 lets + * the checkbox overhang its cell). Expand: the same inset + the 24px button + + * 8px, or an 8px lead-in instead of the inset behind a selection column. + */ +const SELECTION_COLUMN_WIDTH = 52; +const EXPAND_COLUMN_WIDTH_FIRST = 56; +const EXPAND_COLUMN_WIDTH_AFTER_SELECTION = 40; +const MIN_COLUMN_WIDTH = 60; +const DETAIL_ROW_MARKER = "__uic_detail_for__"; +const DEFAULT_PAGE_SIZE = 10; +const DEFAULT_PAGE_SIZE_OPTIONS = [10, 20, 50]; + +const EMPTY_STATE_ICON = <Icon icon={Inbox} size="lg" color="secondary" />; + +const X_HEADER_RELEASE: CSSProperties = { width: "auto", maxWidth: "none" }; +const X_BODY_RELEASE: CSSProperties = { maxWidth: "none" }; +// Inline beats the sticky-header rule's z 2 for pinned header cells. +const Y_PINNED_HEADER_STACK: CSSProperties = { zIndex: 3 }; + +const isDetailRow = (item: unknown): item is AnyRow => + !!item && typeof item === "object" && DETAIL_ROW_MARKER in item; + +const toCssLength = (value: number | string): string => + typeof value === "number" ? `${value}px` : value; + +function mergeCellStyle<P extends { htmlProps: { style?: CSSProperties } }>( + props: P, + extra: CSSProperties, +): P { + return { + ...props, + htmlProps: { + ...props.htmlProps, + style: props.htmlProps.style ? { ...props.htmlProps.style, ...extra } : extra, + }, + }; +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max); +} + +/** The label a column carries in the settings and export dialogs. */ +export function dataGridColumnLabel<T>(column: DataGridColumn<T>): string { + if (column.label) return column.label; + const own = nodeToPlainText(column.header).trim(); + const group = nodeToPlainText(column.groupHeader).trim(); + const label = group && own ? `${group} / ${own}` : own || group; + return label || column.key; +} + +/** Whether a column shows, given the user's overrides. */ +export function isDataGridColumnVisible<T>( + column: Pick<DataGridColumn<T>, "key" | "isAlwaysVisible" | "isHiddenByDefault">, + overrides?: DataGridColumnOverrides, +): boolean { + if (column.isAlwaysVisible) return true; + const hidden = overrides?.[column.key]?.hidden; + return hidden !== undefined ? !hidden : !column.isHiddenByDefault; +} + +/** Controlled when `value` is not undefined; the setter writes state only when uncontrolled. */ +function useControllableState<V>(value: V | undefined, initial: () => V) { + const [state, setState] = useState<V>(initial); + const isControlled = value !== undefined; + const current = isControlled ? value : state; + const set = (next: V) => { + if (!isControlled) setState(next); + }; + return [current, set] as const; +} + +export function DataGrid<T extends object = AnyRow>({ + data, + columns = [], + idKey = "id", + isLoading = false, + isResizable = true, + sort: sortProp, + defaultSort, + onSortChange, + selection, + pagination, + columnSettings, + csvExport, + expansion, + emptyState, + getRowProps, + scrollWidth, + maxHeight, + isHeaderHidden = false, + expandRowLabel, + invalidPageText, + goToFirstPageLabel, + paginationLabel, + renderRange, + settingsLabel, + exportLabel, + density = "compact", + dividers = "rows", + hasHover = true, + textOverflow = "truncate", + className, + style, + ...tableProps +}: DataGridProps<T>): ReactElement { + const t = useUicTranslator(); + + const isScrollX = scrollWidth !== undefined; + const isScrollY = maxHeight !== undefined; + + const [isSettingsOpen, setIsSettingsOpen] = useState(false); + const [isExportOpen, setIsExportOpen] = useState(false); + + /* ---- column overrides (visibility / order / width) ------------------- */ + + const [storedOverrides, setStoredOverrides] = useState<DataGridColumnOverrides>( + () => columnSettings?.defaultOverrides ?? {}, + ); + const isOverridesControlled = columnSettings?.overrides !== undefined; + const ownOverrides = isOverridesControlled + ? (columnSettings?.overrides ?? {}) + : storedOverrides; + const setOverrides = (next: DataGridColumnOverrides) => { + if (!isOverridesControlled) setStoredOverrides(next); + columnSettings?.onOverridesChange?.(next); + }; + const overrides: DataGridColumnOverrides = { + ...(columnSettings?.defaultOverrides ?? {}), + ...ownOverrides, + }; + const isReorderable = !!columnSettings && columnSettings.isReorderable !== false; + + /* ---- resized widths ---------------------------------------------------- */ + + // With columnSettings a width rides in the overrides record, so a resize + // persists like a visibility change; without it the width is local state. + const [localWidths, setLocalWidths] = useState<Record<string, number>>({}); + const columnWidths: Record<string, number> = {}; + if (columnSettings) { + for (const [key, override] of Object.entries(overrides)) { + if (typeof override?.width === "number") columnWidths[key] = override.width; + } + } else { + Object.assign(columnWidths, localWidths); + } + + const handleResizeEnd = (updates: Record<string, number>) => { + if (!columnSettings) { + setLocalWidths((prev) => ({ ...prev, ...updates })); + return; + } + const next: DataGridColumnOverrides = { ...ownOverrides }; + for (const [key, width] of Object.entries(updates)) { + if (key === EXPAND_COLUMN_KEY) continue; + next[key] = { ...next[key], width }; + } + setOverrides(next); + }; + + /* ---- row keys ---------------------------------------------------------- */ + + const getRowKey = (item: T): string => { + if (typeof idKey === "function") return String(idKey(item)); + const row = item as AnyRow; + const direct = row[idKey]; + if (direct != null) return String(direct); + const fallback = row.key ?? row.id; + if (fallback != null) return String(fallback); + return `__row_${data ? data.indexOf(item) : -1}`; + }; + + /* ---- sort -------------------------------------------------------------- */ + + const [activeSort, setActiveSort] = useControllableState<DataGridSort | null>( + sortProp, + () => defaultSort ?? null, + ); + + const sortedRows = ((): T[] => { + const source = data ? [...data] : []; + if (!activeSort) return source; + const compare = columns.find( + (column) => column.sortKey === activeSort.sortKey && column.compare, + )?.compare; + if (!compare) return source; + const { direction } = activeSort; + return source.sort((a, b) => + direction === "descending" ? -compare(a, b, direction) : compare(a, b, direction), + ); + })(); + + /* ---- pagination -------------------------------------------------------- */ + + const pager = pagination === false ? undefined : (pagination ?? {}); + const [currentPage, setCurrentPage] = useControllableState<number>( + pager?.page, + () => pager?.defaultPage ?? 1, + ); + const [pageSize, setPageSize] = useControllableState<number>( + pager?.pageSize, + () => pager?.defaultPageSize ?? DEFAULT_PAGE_SIZE, + ); + + // Astryx's Pagination answers a page-size pick with onPageSizeChange(size) + // and then onChange(1) in the same event, still closing over the old size. + // Forwarding the second call would undo the pick on a controlled grid, so it + // is dropped; a microtask clears the flag. + const isPageSizeChangingRef = useRef(false); + + const total = pager?.totalItems ?? sortedRows.length; + const lastPage = Math.max(1, Math.ceil(total / pageSize)); + const activePage = clamp(currentPage, 1, lastPage); + const isPageOutOfRange = + !!pager && total > 0 && (currentPage < 1 || currentPage > lastPage); + // A total above the rows in hand means they are already one page. + const isServerSliced = total > sortedRows.length; + const pagedRows = + pager && !isServerSliced && sortedRows.length > pageSize + ? sortedRows.slice((activePage - 1) * pageSize, activePage * pageSize) + : sortedRows; + const rows = isPageOutOfRange ? [] : pagedRows; + + /* ---- expansion --------------------------------------------------------- */ + + const [storedExpandedKeys, setStoredExpandedKeys] = useState<DataGridKey[]>(() => [ + ...(expansion?.defaultExpandedKeys ?? []), + ]); + const expandedKeys = expansion?.expandedKeys ?? storedExpandedKeys; + const expandedKeySet = new Set(expandedKeys.map(String)); + const hasExpansion = !!expansion; + const expandColumnWidth = + expansion?.columnWidth ?? + (selection ? EXPAND_COLUMN_WIDTH_AFTER_SELECTION : EXPAND_COLUMN_WIDTH_FIRST); + const detailInsetStart = (selection ? SELECTION_COLUMN_WIDTH : 0) + expandColumnWidth; + + const toggleExpanded = (key: string) => { + const next = expandedKeySet.has(key) + ? expandedKeys.filter((k) => String(k) !== key) + : [...expandedKeys, key]; + if (!expansion?.expandedKeys) setStoredExpandedKeys(next); + expansion?.onExpandedKeysChange?.(next); + }; + + const rowIndexByKey = new Map<string, number>(); + rows.forEach((item, index) => rowIndexByKey.set(getRowKey(item), index)); + + // Each expanded row is followed by a marker row the expansion plugin turns + // into one full-width cell. + const tableData: AnyRow[] = []; + for (const item of rows) { + tableData.push(item as AnyRow); + if (!hasExpansion) continue; + const key = getRowKey(item); + if (expandedKeySet.has(key) && (expansion?.getIsRowExpandable?.(item) ?? true)) { + tableData.push({ [DETAIL_ROW_MARKER]: key, id: `${key}__detail` }); + } + } + + // From every row, not the page: selection resolves keys on other pages too. + const itemByKey = new Map<string, T>(); + sortedRows.forEach((item) => itemByKey.set(getRowKey(item), item)); + + /* ---- Astryx columns ---------------------------------------------------- */ + + const tableColumns: TableColumn<AnyRow>[] = []; + if (hasExpansion) { + tableColumns.push({ + key: EXPAND_COLUMN_KEY, + header: expansion?.columnHeader ?? "", + width: pixel(expandColumnWidth), + resizable: false, + renderCell: (row) => { + if (isDetailRow(row)) return null; + const item = row as T; + if (!(expansion?.getIsRowExpandable?.(item) ?? true)) return null; + const key = getRowKey(item); + return ( + <IconButton + label={expandRowLabel ?? t("uic.DataGrid.expandRow")} + icon={expandedKeySet.has(key) ? <ChevronDown /> : <ChevronRight />} + variant="ghost" + size="sm" + onClick={() => toggleExpanded(key)} + /> + ); + }, + }); + } + + for (const column of columns) { + const width = columnWidths[column.key] ?? column.width; + const header = ( + <span className="uic-data-grid__clip"> + {column.groupHeader == null ? ( + column.header + ) : ( + <VStack gap={0} align="start"> + <Text type="supporting" color="secondary"> + {column.groupHeader} + </Text> + <span>{column.header}</span> + </VStack> + )} + </span> + ); + tableColumns.push({ + key: column.key, + header, + align: column.align === "start" ? undefined : column.align, + sortable: column.sortKey ? { sortKey: column.sortKey } : false, + resizable: isResizable, + // In scroll-x mode a width-less column takes no width, so its content sizes it. + width: + width != null + ? pixel(width) + : isScrollX + ? undefined + : proportional( + 1, + typeof column.minWidth === "number" + ? { minWidth: column.minWidth } + : undefined, + ), + renderCell: (row) => { + if (isDetailRow(row)) return null; + const item = row as T; + const content = column.renderCell + ? column.renderCell(item, rowIndexByKey.get(getRowKey(item)) ?? 0) + : defaultCell(row[column.key]); + if (textOverflow !== "truncate" || content == null) return content; + return <span className="uic-data-grid__clip">{content}</span>; + }, + }); + } + + /* ---- visibility + order ------------------------------------------------ */ + + const visibleColumns = columnSettings + ? columns.filter((column) => isDataGridColumnVisible(column, overrides)) + : [...columns]; + const orderedColumns = isReorderable + ? visibleColumns + .map((column, index) => ({ column, index })) + .sort( + (a, b) => + (overrides[a.column.key]?.order ?? Number.MAX_SAFE_INTEGER) - + (overrides[b.column.key]?.order ?? Number.MAX_SAFE_INTEGER) || + a.index - b.index, + ) + .map(({ column }) => column) + : visibleColumns; + const activeColumnKeys = [ + ...(hasExpansion ? [EXPAND_COLUMN_KEY] : []), + ...orderedColumns.map((column) => column.key), + ]; + + const columnSettingsPlugin = useTableColumnSettings<AnyRow>({ + columns: [ + ...(hasExpansion + ? [{ key: EXPAND_COLUMN_KEY, label: "", isAlwaysVisible: true }] + : []), + ...columns.map((column) => ({ + key: column.key, + label: dataGridColumnLabel(column), + isAlwaysVisible: !!column.isAlwaysVisible, + })), + ], + activeColumnKeys, + // The settings dialog owns the write path (it keeps `order` and `width`). + onChangeActiveColumnKeys: () => {}, + }); + + /* ---- pinning ----------------------------------------------------------- */ + + const startKeys: string[] = []; + for (const column of orderedColumns) { + if (column.pin === "start") startKeys.push(column.key); + else break; + } + const endKeys: string[] = []; + for (const column of [...orderedColumns].reverse()) { + if (column.pin === "end") endKeys.unshift(column.key); + else break; + } + const stickyConfig = { + startKeys: + startKeys.length === 0 + ? undefined + : [ + ...(selection ? [SELECTION_COLUMN_KEY] : []), + ...(hasExpansion ? [EXPAND_COLUMN_KEY] : []), + ...startKeys, + ], + endKeys: endKeys.length === 0 ? undefined : endKeys, + }; + const stickyPlugin = useTableStickyColumns<AnyRow>( + stickyConfig.startKeys || stickyConfig.endKeys ? stickyConfig : {}, + ); + + /* ---- per-cell / per-row attributes -------------------------------------- */ + + const columnByKey = new Map(columns.map((column) => [column.key, column])); + + const cellRowPlugin: TablePlugin<AnyRow> = { + transformBodyCell: (props, column, row) => { + if (isDetailRow(row)) return props; + const extra = columnByKey.get(column.key)?.getCellProps?.(row as T, 0); + if (!extra) return props; + return { + ...props, + htmlProps: { + ...props.htmlProps, + ...extra, + style: { ...props.htmlProps.style, ...extra.style }, + }, + }; + }, + transformBodyRow: (props, row) => { + if (!getRowProps || isDetailRow(row)) return props; + const item = row as T; + const extra = getRowProps(item, rowIndexByKey.get(getRowKey(item))); + if (!extra) return props; + return { + ...props, + htmlProps: { + ...props.htmlProps, + ...extra, + style: { ...props.htmlProps.style, ...extra.style }, + }, + }; + }, + }; + + // Astryx clips cells with `max-width: 0`; scroll-x releases width-less columns only. + const scrollXPlugin: TablePlugin<AnyRow> = { + transformHeaderCell: (props, column) => + column.width != null ? props : mergeCellStyle(props, X_HEADER_RELEASE), + transformBodyCell: (props, column) => + column.width != null ? props : mergeCellStyle(props, X_BODY_RELEASE), + }; + + const pinnedKeys = new Set([ + ...(stickyConfig.startKeys ?? []), + ...(stickyConfig.endKeys ?? []), + ]); + const scrollYPlugin: TablePlugin<AnyRow> = { + transformHeaderCell: (props, column) => + pinnedKeys.has(column.key) ? mergeCellStyle(props, Y_PINNED_HEADER_STACK) : props, + }; + + /* ---- sorting ----------------------------------------------------------- */ + + const sortState: TableSortState = activeSort + ? [{ sortKey: activeSort.sortKey, direction: activeSort.direction }] + : []; + + const sortPlugin = useTableSortable<AnyRow>({ + sort: sortState, + allowUnsortedState: true, + onSortChange: (next) => { + const first = next[0]; + const nextSort: DataGridSort | null = first + ? { sortKey: first.sortKey, direction: first.direction } + : null; + setActiveSort(nextSort); + onSortChange?.(nextSort); + }, + }); + + /* ---- selection --------------------------------------------------------- */ + + const selectedKeySet = new Set((selection?.selectedKeys ?? []).map(String)); + const emitSelection = (keys: string[]) => { + const items = keys + .map((key) => itemByKey.get(key)) + .filter((item) => item !== undefined); + selection?.onChange?.(keys, items); + }; + const isRowSelected = (item: T) => selectedKeySet.has(getRowKey(item)); + + const selectionPlugin = useTableSelection<AnyRow>({ + getIsItemSelectable: (row) => !isDetailRow(row), + getIsItemSelected: (row) => !isDetailRow(row) && isRowSelected(row as T), + getIsItemEnabled: (row) => + isDetailRow(row) ? false : (selection?.getIsItemEnabled?.(row as T) ?? true), + getRowLabel: (row) => + isDetailRow(row) + ? "" + : (selection?.getRowLabel?.(row as T) ?? getRowKey(row as T)), + getIsAllSelected: () => rows.length > 0 && rows.every(isRowSelected), + getIsIndeterminate: () => rows.some(isRowSelected) && !rows.every(isRowSelected), + onSelectItem: ({ item, isSelected }) => { + if (isDetailRow(item)) return; + const key = getRowKey(item as T); + const next = new Set(selectedKeySet); + if (isSelected) next.add(key); + else next.delete(key); + emitSelection([...next]); + }, + onSelectAll: ({ isAllSelected }) => { + const next = new Set(selection?.isPreservingOtherPages ? selectedKeySet : []); + for (const item of rows) { + const key = getRowKey(item); + if (isAllSelected) next.add(key); + else next.delete(key); + } + emitSelection([...next]); + }, + }); + + const selectionWidthPlugin: TablePlugin<AnyRow> = { + transformColumns: (cols) => + cols.map((column) => + column.key === SELECTION_COLUMN_KEY + ? { ...column, width: pixel(SELECTION_COLUMN_WIDTH) } + : column, + ), + }; + + /* ---- resize ------------------------------------------------------------ */ + + const resizePlugin = useTableColumnResize<AnyRow>({ + columns: tableColumns, + columnWidths, + minWidth: MIN_COLUMN_WIDTH, + onColumnResizeEnd: handleResizeEnd, + }); + + /* ---- expansion --------------------------------------------------------- */ + + const renderedColumnCount = activeColumnKeys.length + (selection ? 1 : 0); + const expansionPlugin: TablePlugin<AnyRow> = { + transformBodyRow: (props, row) => { + if (!isDetailRow(row)) return props; + const parentKey = String(row[DETAIL_ROW_MARKER]); + const item = itemByKey.get(parentKey); + if (!item) return props; + return { + ...props, + children: ( + <td + colSpan={renderedColumnCount} + className="uic-data-grid__detail" + style={{ paddingInlineStart: detailInsetStart }} + > + {expansion?.renderExpandedRow(item, rowIndexByKey.get(parentKey) ?? 0)} + </td> + ), + }; + }, + }; + + const plugins: Record<string, TablePlugin<AnyRow>> = { + columnSettings: columnSettingsPlugin, + sort: sortPlugin, + }; + if (selection) { + plugins.selection = selectionPlugin; + plugins.selectionWidth = selectionWidthPlugin; + } + if (isResizable) plugins.resize = resizePlugin; + plugins.sticky = stickyPlugin; + if (isScrollX) plugins.scrollX = scrollXPlugin; + // Without pinned columns the sticky header is pure CSS. + if (isScrollY && pinnedKeys.size > 0) plugins.scrollY = scrollYPlugin; + plugins.cellRow = cellRowPlugin; + if (hasExpansion) plugins.expansion = expansionPlugin; + + /* ---- bottom bar -------------------------------------------------------- */ + + const { + page: _page, + defaultPage: _defaultPage, + defaultPageSize: _defaultPageSize, + totalItems: _totalItems, + onChange: _onChange, + hasPageSizeSelector = true, + isHiddenOnSinglePage = false, + endContent, + pageSizeOptions, + size: pagerSize = "sm", + ...paginationRest + } = pager ?? {}; + + const rangeStart = total === 0 ? 0 : (activePage - 1) * pageSize + 1; + const rangeEnd = Math.min(activePage * pageSize, total); + const isPagerVisible = !!pager && !(isHiddenOnSinglePage && total <= pageSize); + const hasBottomBar = isPagerVisible || !!columnSettings || !!csvExport; + + const emptyStateNode = isPageOutOfRange ? ( + // The way back outranks any empty state the caller gave. + <EmptyState + isCompact + icon={EMPTY_STATE_ICON} + title={invalidPageText ?? t("uic.DataGrid.invalidPage")} + actions={ + <Button + variant="primary" + label={goToFirstPageLabel ?? t("uic.DataGrid.goToFirstPage")} + onClick={() => { + setCurrentPage(1); + pager?.onChange?.(1, pageSize); + }} + /> + } + /> + ) : emptyState === false ? ( + false + ) : emptyState == null || typeof emptyState === "string" ? ( + <EmptyState + isCompact + icon={EMPTY_STATE_ICON} + title={emptyState ?? t("uic.DataGrid.noData")} + /> + ) : ( + emptyState + ); + + const applySettings = (result: DataGridSettingsResult) => { + setIsSettingsOpen(false); + const naturalOrder = columns.map((column) => column.key); + const isReordered = + isReorderable && + (result.columnOrder.length !== naturalOrder.length || + result.columnOrder.some((key, index) => key !== naturalOrder[index])); + const next: DataGridColumnOverrides = {}; + for (const column of columns) { + const override: DataGridColumnOverride = {}; + const shouldBeVisible = result.selectedColumnKeys.includes(column.key); + if (shouldBeVisible === !!column.isHiddenByDefault) + override.hidden = !shouldBeVisible; + if (isReordered) { + const orderIndex = result.columnOrder.indexOf(column.key); + if (orderIndex !== -1) override.order = orderIndex; + } + // A settings change must not reset resized widths. + const persistedWidth = overrides[column.key]?.width; + if (typeof persistedWidth === "number") override.width = persistedWidth; + if (Object.keys(override).length > 0) next[column.key] = override; + } + setOverrides(next); + }; + + return ( + <div + className={["uic-data-grid", className].filter(Boolean).join(" ")} + style={style} + > + <div + aria-busy={isLoading || undefined} + className={[ + "uic-data-grid__body", + isLoading && "uic-data-grid__body--loading", + isHeaderHidden && "uic-data-grid__body--no-header", + dividers !== "grid" && "uic-data-grid__body--header-split", + isScrollX && "uic-data-grid__body--scroll-x", + isScrollY && "uic-data-grid__body--scroll-y", + ] + .filter(Boolean) + .join(" ")} + style={ + { + ...(isScrollX + ? { "--uic-data-grid-scroll-width": toCssLength(scrollWidth) } + : null), + ...(isScrollY + ? { "--uic-data-grid-max-height": toCssLength(maxHeight) } + : null), + } as CSSProperties + } + > + <Table<AnyRow> + {...tableProps} + data={tableData} + columns={tableColumns} + idKey={(row: AnyRow) => + isDetailRow(row) + ? `${String(row[DETAIL_ROW_MARKER])}__detail` + : getRowKey(row as T) + } + density={density} + dividers={dividers} + hasHover={hasHover} + textOverflow={textOverflow} + emptyState={emptyStateNode} + rowCount={total || undefined} + rowIndexStart={pager ? (activePage - 1) * pageSize + 1 : undefined} + plugins={plugins} + /> + </div> + + {hasBottomBar ? ( + <HStack justify="end" align="center" gap={2} className="uic-data-grid__footer"> + {isPagerVisible ? ( + <> + <Text type="supporting" color="secondary"> + {renderRange + ? renderRange({ start: rangeStart, end: rangeEnd, total }) + : t("uic.DataGrid.range", { + start: rangeStart, + end: rangeEnd, + total, + })} + </Text> + <Pagination + variant="pages" + {...paginationRest} + page={activePage} + pageSize={pageSize} + totalItems={total} + // Astryx shows the size choice exactly when options are passed. + pageSizeOptions={ + hasPageSizeSelector + ? (pageSizeOptions ?? DEFAULT_PAGE_SIZE_OPTIONS) + : undefined + } + size={pagerSize} + label={paginationLabel ?? t("uic.DataGrid.pagination")} + onChange={(page) => { + if (isPageSizeChangingRef.current) return; + setCurrentPage(page); + pager?.onChange?.(page, pageSize); + }} + onPageSizeChange={(nextSize) => { + isPageSizeChangingRef.current = true; + queueMicrotask(() => { + isPageSizeChangingRef.current = false; + }); + setCurrentPage(1); + setPageSize(nextSize); + pager?.onChange?.(1, nextSize); + }} + /> + </> + ) : null} + {columnSettings ? ( + <IconButton + label={settingsLabel ?? t("uic.DataGrid.settings")} + icon={<Settings />} + variant="ghost" + size="sm" + onClick={() => setIsSettingsOpen(true)} + /> + ) : null} + {csvExport ? ( + <IconButton + label={exportLabel ?? t("uic.DataGrid.exportCsv")} + icon={<FileDown />} + variant="ghost" + size="sm" + onClick={() => setIsExportOpen(true)} + /> + ) : null} + {pager ? endContent : null} + </HStack> + ) : null} + + {columnSettings ? ( + <DataGridSettingsModal + {...columnSettings.modalProps} + isOpen={isSettingsOpen} + onOpenChange={setIsSettingsOpen} + columns={columns.map((column) => ({ + key: column.key, + label: dataGridColumnLabel(column), + isAlwaysVisible: !!column.isAlwaysVisible, + }))} + visibleColumnKeys={orderedColumns.map((column) => column.key)} + isReorderable={isReorderable} + onApply={applySettings} + /> + ) : null} + + {csvExport ? ( + <DataGridExportModal + {...csvExport.modalProps} + isOpen={isExportOpen} + onOpenChange={setIsExportOpen} + columns={columns.map((column) => ({ + key: column.key, + label: dataGridColumnLabel(column), + exportKeys: column.exportKeys ?? [], + }))} + supportedKeys={csvExport.supportedKeys} + notice={csvExport.notice} + onExport={async (keys) => { + await csvExport.onExport(keys); + setIsExportOpen(false); + }} + /> + ) : null} + </div> + ); +} + +function defaultCell(value: unknown): ReactNode { + if (value == null || value === "") return null; + if (isValidElement(value)) return value; + return String(value); +} diff --git a/src/components/DataGrid/DataGridExportModal.tsx b/src/components/DataGrid/DataGridExportModal.tsx new file mode 100644 index 0000000..1f4051c --- /dev/null +++ b/src/components/DataGrid/DataGridExportModal.tsx @@ -0,0 +1,245 @@ +/** + * DataGridExportModal + * + * Picks the columns a CSV export includes: a searchable list of checkboxes, + * all exportable columns checked on open. A column whose export keys are not + * all in `supportedKeys` is shown disabled, so the user sees why it is + * missing. Columns that export the same set of keys toggle together; the + * emitted keys must not depend on which of two equivalent rows was clicked. + * + * Export awaits `onExport` with the chosen keys, deduplicated. It does not + * close the dialog. + * + * @example + * <DataGridExportModal + * isOpen={isOpen} + * onOpenChange={setIsOpen} + * columns={[{ key: "name", label: "Name", exportKeys: ["name"] }]} + * supportedKeys={["name"]} + * onExport={async (keys) => { + * await download(keys); + * setIsOpen(false); + * }} + * /> + */ +import { useState, type CSSProperties, type ReactElement, type ReactNode } from "react"; +import { Banner } from "@astryxdesign/core/Banner"; +import { Button } from "@astryxdesign/core/Button"; +import { CheckboxInput } from "@astryxdesign/core/CheckboxInput"; +import { DialogHeader } from "@astryxdesign/core/Dialog"; +import { Layout, LayoutContent, LayoutFooter } from "@astryxdesign/core/Layout"; +import { HStack, VStack } from "@astryxdesign/core/Stack"; +import { Text } from "@astryxdesign/core/Text"; +import { TextInput } from "@astryxdesign/core/TextInput"; + +import { useUicTranslator } from "../../i18n/useUicTranslator"; +import { Modal, type ModalProps } from "../Modal/Modal"; +import "./DataGrid.css"; + +export interface DataGridExportColumn { + key: string; + label: string; + /** Fields the column exports as. None: the column cannot be exported. */ + exportKeys: ReadonlyArray<string>; +} + +export interface DataGridExportModalProps extends Pick< + ModalProps, + "isOpen" | "onOpenChange" | "afterOpenChange" +> { + columns: ReadonlyArray<DataGridExportColumn>; + /** Export keys that can be produced. */ + supportedKeys: ReadonlyArray<string>; + /** Runs the export. The Export button stays pending until it settles. */ + onExport: (keys: string[]) => Promise<void> | void; + /** A warning above the list, e.g. that the export is truncated. */ + notice?: ReactNode; + /** Default: the catalog's `uic.DataGrid.exportCsv` */ + title?: string; + /** Default: the catalog's `uic.DataGrid.selectColumns` */ + subtitle?: string; + /** Label and placeholder of the search field. Default: the catalog's `uic.DataGrid.searchColumns` */ + searchLabel?: string; + /** Shown when no column matches the search. Default: the catalog's `uic.DataGrid.searchColumns` */ + noMatchText?: ReactNode; + /** Default: the catalog's `uic.DataGrid.export` */ + exportLabel?: string; + /** Default: the catalog's `uic.common.cancel` */ + cancelLabel?: string; +} + +const WIDTH = 500; +const LIST_HEIGHT = "330px"; + +interface Option { + key: string; + label: string; + exportKeys: ReadonlyArray<string>; + isSelectable: boolean; +} + +const groupIdOf = (keys: ReadonlyArray<string>) => [...keys].sort().join(","); + +function ExportBody({ + columns, + supportedKeys, + onExport, + onOpenChange, + notice, + title, + subtitle, + searchLabel, + noMatchText, + exportLabel, + cancelLabel, +}: Omit<DataGridExportModalProps, "isOpen" | "afterOpenChange">) { + const t = useUicTranslator(); + const options: Option[] = columns.map((column) => ({ + key: column.key, + label: column.label, + exportKeys: column.exportKeys, + isSelectable: + column.exportKeys.length > 0 && + column.exportKeys.every((key) => supportedKeys.includes(key)), + })); + + const membersByGroup = new Map<string, string[]>(); + for (const option of options) { + if (option.exportKeys.length === 0 || !option.isSelectable) continue; + const groupId = groupIdOf(option.exportKeys); + membersByGroup.set(groupId, [...(membersByGroup.get(groupId) ?? []), option.key]); + } + const membersOf = (option: Option) => + option.exportKeys.length === 0 + ? [option.key] + : (membersByGroup.get(groupIdOf(option.exportKeys)) ?? [option.key]); + + const [selectedKeys, setSelectedKeys] = useState<Set<string>>( + () => + new Set( + options.filter((option) => option.isSelectable).map((option) => option.key), + ), + ); + const [search, setSearch] = useState(""); + + const shownOptions = search + ? options.filter((option) => + option.label.toLowerCase().includes(search.toLowerCase()), + ) + : options; + const searchText = searchLabel ?? t("uic.DataGrid.searchColumns"); + const close = () => onOpenChange?.(false); + + const toggle = (option: Option, checked: boolean) => { + setSelectedKeys((prev) => { + const next = new Set(prev); + for (const key of membersOf(option)) { + if (checked) next.add(key); + else next.delete(key); + } + return next; + }); + }; + + return ( + <Layout + header={ + <DialogHeader + title={title ?? t("uic.DataGrid.exportCsv")} + subtitle={subtitle ?? t("uic.DataGrid.selectColumns")} + onOpenChange={(next) => { + if (!next) close(); + }} + /> + } + content={ + <LayoutContent> + <VStack gap={2} align="stretch"> + {notice ? ( + <Banner + status="warning" + title={notice} + data-testid="uic-data-grid-export-notice" + /> + ) : null} + <TextInput + label={searchText} + isLabelHidden + placeholder={searchText} + value={search} + onChange={(value) => setSearch(value ?? "")} + size="sm" + /> + <div + className="uic-data-grid-dialog__list" + style={ + { "--uic-data-grid-dialog-list-height": LIST_HEIGHT } as CSSProperties + } + > + <VStack gap={0} align="stretch"> + {shownOptions.map((option) => ( + <div key={option.key} className="uic-data-grid-dialog__option"> + <CheckboxInput + label={option.label || option.key} + size="sm" + value={selectedKeys.has(option.key)} + isDisabled={!option.isSelectable} + onChange={(checked) => toggle(option, checked)} + /> + </div> + ))} + {shownOptions.length === 0 ? ( + <Text type="supporting" color="secondary"> + {noMatchText ?? searchText} + </Text> + ) : null} + </VStack> + </div> + </VStack> + </LayoutContent> + } + footer={ + <LayoutFooter hasDivider> + <HStack justify="end" gap={2} align="center"> + <Button + label={cancelLabel ?? t("uic.common.cancel")} + variant="secondary" + onClick={close} + /> + <Button + label={exportLabel ?? t("uic.DataGrid.export")} + variant="primary" + clickAction={async () => { + const keys = options + .filter((option) => selectedKeys.has(option.key)) + .flatMap((option) => option.exportKeys); + await onExport([...new Set(keys)]); + }} + /> + </HStack> + </LayoutFooter> + } + className="uic-data-grid-dialog" + /> + ); +} + +export function DataGridExportModal({ + isOpen, + onOpenChange, + afterOpenChange, + ...bodyProps +}: DataGridExportModalProps): ReactElement { + return ( + <Modal + isOpen={isOpen} + onOpenChange={onOpenChange} + afterOpenChange={afterOpenChange} + width={WIDTH} + purpose="form" + unmountOnClose + > + <ExportBody {...bodyProps} onOpenChange={onOpenChange} /> + </Modal> + ); +} diff --git a/src/components/DataGrid/DataGridModals.test.tsx b/src/components/DataGrid/DataGridModals.test.tsx new file mode 100644 index 0000000..6b9d24d --- /dev/null +++ b/src/components/DataGrid/DataGridModals.test.tsx @@ -0,0 +1,212 @@ +import { describe, expect, it, vi } from "vitest"; +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { InternationalizationProvider } from "@astryxdesign/core/i18n"; + +import { uiCommonMessages } from "../../i18n/messages"; +import { DataGridExportModal } from "./DataGridExportModal"; +import { DataGridSettingsModal } from "./DataGridSettingsModal"; + +const SETTINGS_COLUMNS = [ + { key: "name", label: "Name", isAlwaysVisible: true }, + { key: "size", label: "Size" }, + { key: "owner", label: "Owner" }, +]; + +const renderSettings = ( + props: Partial<Parameters<typeof DataGridSettingsModal>[0]> = {}, +) => { + const onApply = vi.fn(); + const onOpenChange = vi.fn(); + const result = render( + <DataGridSettingsModal + isOpen + onOpenChange={onOpenChange} + columns={SETTINGS_COLUMNS} + visibleColumnKeys={["name", "owner"]} + onApply={onApply} + {...props} + />, + ); + return { ...result, onApply, onOpenChange }; +}; + +const handles = (dialog: HTMLElement) => + dialog.querySelectorAll(".uic-data-grid-dialog__handle"); + +describe("DataGridSettingsModal", () => { + it("lists visible columns first, in display order, then the rest", () => { + renderSettings(); + const dialog = screen.getByRole("dialog"); + expect(dialog).toHaveAccessibleName("Table Settings"); + const labels = within(dialog) + .getAllByRole("checkbox") + .map((box) => (box as HTMLInputElement).labels?.[0]?.textContent ?? ""); + expect(labels).toEqual(["Name", "Owner", "Size"]); + }); + + it("locks always-visible columns checked", () => { + renderSettings(); + const name = screen.getByRole("checkbox", { name: "Name" }); + expect(name).toBeChecked(); + expect(name).toBeDisabled(); + }); + + it("applies the checked keys, always-visible included, and the order", async () => { + const { onApply, onOpenChange } = renderSettings({ visibleColumnKeys: ["owner"] }); + + await userEvent.click(screen.getByRole("checkbox", { name: "Size" })); + await userEvent.click(screen.getByRole("button", { name: "Apply" })); + + expect(onApply).toHaveBeenCalledWith({ + selectedColumnKeys: ["owner", "size", "name"], + columnOrder: ["owner", "name", "size"], + }); + expect(onOpenChange).not.toHaveBeenCalled(); + }); + + it("cancels through onOpenChange without applying", async () => { + const { onApply, onOpenChange } = renderSettings(); + await userEvent.click(screen.getByRole("button", { name: "Cancel" })); + expect(onOpenChange).toHaveBeenCalledWith(false); + expect(onApply).not.toHaveBeenCalled(); + }); + + it("filters by search and turns dragging off while searching", async () => { + renderSettings(); + const dialog = screen.getByRole("dialog"); + expect(handles(dialog)).toHaveLength(3); + + await userEvent.type( + screen.getByRole("textbox", { name: "Search table columns" }), + "siz", + ); + + expect(within(dialog).getAllByRole("checkbox")).toHaveLength(1); + expect(handles(dialog)).toHaveLength(0); + + await userEvent.clear( + screen.getByRole("textbox", { name: "Search table columns" }), + ); + await userEvent.type( + screen.getByRole("textbox", { name: "Search table columns" }), + "zzz", + ); + expect(within(dialog).queryAllByRole("checkbox")).toHaveLength(0); + expect(dialog.querySelector(".uic-data-grid-dialog__list")).toHaveTextContent( + "Search table columns", + ); + }); + + it("renders no drag handles when not reorderable", () => { + renderSettings({ isReorderable: false }); + expect(handles(screen.getByRole("dialog"))).toHaveLength(0); + }); + + it("starts fresh on every open", async () => { + const { rerender, onApply, onOpenChange } = renderSettings(); + await userEvent.click(screen.getByRole("checkbox", { name: "Size" })); + + const props = { + onOpenChange, + onApply, + columns: SETTINGS_COLUMNS, + visibleColumnKeys: ["name", "owner"], + }; + rerender(<DataGridSettingsModal {...props} isOpen={false} />); + rerender(<DataGridSettingsModal {...props} isOpen />); + + expect(screen.getByRole("checkbox", { name: "Size" })).not.toBeChecked(); + }); + + it("translates its strings", () => { + render( + <InternationalizationProvider locale="ko-KR" messages={uiCommonMessages}> + <DataGridSettingsModal + isOpen + onOpenChange={() => {}} + columns={SETTINGS_COLUMNS} + visibleColumnKeys={["name"]} + onApply={() => {}} + /> + </InternationalizationProvider>, + ); + expect(screen.getByRole("dialog")).toHaveAccessibleName("표 설정"); + expect(screen.getByRole("button", { name: "적용" })).toBeInTheDocument(); + }); +}); + +const EXPORT_COLUMNS = [ + { key: "name", label: "Name", exportKeys: ["name"] }, + { key: "created", label: "Created", exportKeys: ["created_at"] }, + { key: "createdAgo", label: "Created (relative)", exportKeys: ["created_at"] }, + { key: "secret", label: "Secret", exportKeys: ["secret"] }, + { key: "actions", label: "Actions", exportKeys: [] }, +]; + +const renderExport = ( + props: Partial<Parameters<typeof DataGridExportModal>[0]> = {}, +) => { + const onExport = vi.fn(async () => {}); + const onOpenChange = vi.fn(); + const result = render( + <DataGridExportModal + isOpen + onOpenChange={onOpenChange} + columns={EXPORT_COLUMNS} + supportedKeys={["name", "created_at"]} + onExport={onExport} + {...props} + />, + ); + return { ...result, onExport, onOpenChange }; +}; + +describe("DataGridExportModal", () => { + it("checks every exportable column and disables the rest", () => { + renderExport(); + expect(screen.getByRole("checkbox", { name: "Name" })).toBeChecked(); + expect(screen.getByRole("checkbox", { name: "Secret" })).toBeDisabled(); + expect(screen.getByRole("checkbox", { name: "Secret" })).not.toBeChecked(); + expect(screen.getByRole("checkbox", { name: "Actions" })).toBeDisabled(); + }); + + it("toggles columns that export the same keys together", async () => { + renderExport(); + await userEvent.click(screen.getByRole("checkbox", { name: "Created (relative)" })); + expect(screen.getByRole("checkbox", { name: "Created" })).not.toBeChecked(); + }); + + it("exports each chosen key once and leaves closing to the caller", async () => { + const { onExport, onOpenChange } = renderExport(); + await userEvent.click(screen.getByRole("button", { name: "Export" })); + expect(onExport).toHaveBeenCalledWith(["name", "created_at"]); + expect(onOpenChange).not.toHaveBeenCalled(); + }); + + it("shows a notice only when one is given", () => { + const { unmount } = renderExport({ notice: "Only the first 1000 rows." }); + expect(screen.getByTestId("uic-data-grid-export-notice")).toHaveTextContent( + "Only the first 1000 rows.", + ); + unmount(); + renderExport(); + expect(screen.queryByTestId("uic-data-grid-export-notice")).toBeNull(); + }); + + it("filters by search", async () => { + renderExport(); + await userEvent.type( + screen.getByRole("textbox", { name: "Search table columns" }), + "creat", + ); + expect(within(screen.getByRole("dialog")).getAllByRole("checkbox")).toHaveLength(2); + }); + + it("cancels through onOpenChange", async () => { + const { onExport, onOpenChange } = renderExport(); + await userEvent.click(screen.getByRole("button", { name: "Cancel" })); + expect(onOpenChange).toHaveBeenCalledWith(false); + expect(onExport).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/DataGrid/DataGridSettingsModal.tsx b/src/components/DataGrid/DataGridSettingsModal.tsx new file mode 100644 index 0000000..58b5f17 --- /dev/null +++ b/src/components/DataGrid/DataGridSettingsModal.tsx @@ -0,0 +1,300 @@ +/** + * DataGridSettingsModal + * + * Chooses which columns a grid shows, and in what order: a searchable list of + * checkboxes with drag handles. Columns marked `isAlwaysVisible` stay checked. + * Dragging is off while a search narrows the list. The working set is fresh + * on every open; Apply reports it and does not close the dialog. + * + * @example + * <DataGridSettingsModal + * isOpen={isOpen} + * onOpenChange={setIsOpen} + * columns={[{ key: "name", label: "Name", isAlwaysVisible: true }, { key: "email", label: "Email" }]} + * visibleColumnKeys={["name"]} + * onApply={(result) => { + * save(result); + * setIsOpen(false); + * }} + * /> + */ +import { useState, type CSSProperties, type ReactElement, type ReactNode } from "react"; +import { DndContext, type DragEndEvent } from "@dnd-kit/core"; +import { restrictToVerticalAxis } from "@dnd-kit/modifiers"; +import { + SortableContext, + arrayMove, + useSortable, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { Button } from "@astryxdesign/core/Button"; +import { CheckboxInput } from "@astryxdesign/core/CheckboxInput"; +import { DialogHeader } from "@astryxdesign/core/Dialog"; +import { Layout, LayoutContent, LayoutFooter } from "@astryxdesign/core/Layout"; +import { HStack, VStack } from "@astryxdesign/core/Stack"; +import { Text } from "@astryxdesign/core/Text"; +import { TextInput } from "@astryxdesign/core/TextInput"; +import { GripVertical } from "lucide-react"; + +import { useUicTranslator } from "../../i18n/useUicTranslator"; +import { Modal, type ModalProps } from "../Modal/Modal"; +import "./DataGrid.css"; + +export interface DataGridSettingsColumn { + key: string; + label: string; + /** Always shown; its checkbox is locked. */ + isAlwaysVisible?: boolean; +} + +export interface DataGridSettingsResult { + /** The checked keys, always-visible columns included. */ + selectedColumnKeys: string[]; + /** Every column key, in the order the user left them. */ + columnOrder: string[]; +} + +export interface DataGridSettingsModalProps extends Pick< + ModalProps, + "isOpen" | "onOpenChange" | "afterOpenChange" +> { + columns: ReadonlyArray<DataGridSettingsColumn>; + /** The keys shown now, in display order. */ + visibleColumnKeys: ReadonlyArray<string>; + /** Whether rows can be dragged into a new order. Default: true */ + isReorderable?: boolean; + /** Called with the new settings on Apply. It does not close the dialog. */ + onApply: (result: DataGridSettingsResult) => void; + /** Default: the catalog's `uic.DataGrid.settings` */ + title?: string; + /** Default: the catalog's `uic.DataGrid.selectColumns` */ + subtitle?: string; + /** Label and placeholder of the search field. Default: the catalog's `uic.DataGrid.searchColumns` */ + searchLabel?: string; + /** Shown when no column matches the search. Default: the catalog's `uic.DataGrid.searchColumns` */ + noMatchText?: ReactNode; + /** Default: the catalog's `uic.common.apply` */ + applyLabel?: string; + /** Default: the catalog's `uic.common.cancel` */ + cancelLabel?: string; +} + +const WIDTH = 420; +const LIST_HEIGHT = "360px"; + +function SortableRow({ + id, + isDragDisabled, + children, +}: { + id: string; + isDragDisabled: boolean; + children: ReactNode; +}) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = + useSortable({ id, disabled: isDragDisabled }); + return ( + <div + ref={setNodeRef} + className="uic-data-grid-dialog__row" + style={{ + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.6 : 1, + }} + > + {isDragDisabled ? ( + <span className="uic-data-grid-dialog__handle-space" /> + ) : ( + <span + {...attributes} + {...listeners} + className="uic-data-grid-dialog__handle" + aria-hidden + > + <GripVertical size={16} /> + </span> + )} + {children} + </div> + ); +} + +function SettingsBody({ + columns, + visibleColumnKeys, + isReorderable, + onApply, + onOpenChange, + title, + subtitle, + searchLabel, + noMatchText, + applyLabel, + cancelLabel, +}: Omit<DataGridSettingsModalProps, "isOpen" | "afterOpenChange"> & { + isReorderable: boolean; +}) { + const t = useUicTranslator(); + const [order, setOrder] = useState<string[]>(() => { + const known = new Set(columns.map((column) => column.key)); + const visible = visibleColumnKeys.filter((key) => known.has(key)); + const rest = columns + .map((column) => column.key) + .filter((key) => !visible.includes(key)); + return [...visible, ...rest]; + }); + const [selected, setSelected] = useState<string[]>(() => [...visibleColumnKeys]); + const [search, setSearch] = useState(""); + + const columnByKey = new Map(columns.map((column) => [column.key, column])); + const shownKeys = order.filter((key) => { + const column = columnByKey.get(key); + if (!column) return false; + return !search || column.label.toLowerCase().includes(search.toLowerCase()); + }); + const isDragDisabled = !isReorderable || !!search; + const searchText = searchLabel ?? t("uic.DataGrid.searchColumns"); + const close = () => onOpenChange?.(false); + + const onDragEnd = ({ active, over }: DragEndEvent) => { + if (!over || active.id === over.id) return; + const from = order.indexOf(String(active.id)); + const to = order.indexOf(String(over.id)); + if (from === -1 || to === -1) return; + setOrder(arrayMove(order, from, to)); + }; + + const list = ( + <VStack gap={0} align="stretch"> + {shownKeys.map((key) => { + const column = columnByKey.get(key); + if (!column) return null; + return ( + <SortableRow key={key} id={key} isDragDisabled={isDragDisabled}> + <CheckboxInput + label={column.label || key} + size="sm" + value={selected.includes(key) || !!column.isAlwaysVisible} + isDisabled={!!column.isAlwaysVisible} + onChange={(checked) => + setSelected((prev) => + checked + ? prev.includes(key) + ? prev + : [...prev, key] + : prev.filter((k) => k !== key), + ) + } + /> + </SortableRow> + ); + })} + {shownKeys.length === 0 ? ( + <Text type="supporting" color="secondary"> + {noMatchText ?? searchText} + </Text> + ) : null} + </VStack> + ); + + return ( + <Layout + header={ + <DialogHeader + title={title ?? t("uic.DataGrid.settings")} + subtitle={subtitle ?? t("uic.DataGrid.selectColumns")} + onOpenChange={(next) => { + if (!next) close(); + }} + /> + } + content={ + <LayoutContent> + <VStack gap={2} align="stretch"> + <TextInput + label={searchText} + isLabelHidden + placeholder={searchText} + value={search} + onChange={(value) => setSearch(value ?? "")} + /> + <div + className="uic-data-grid-dialog__list" + style={ + { "--uic-data-grid-dialog-list-height": LIST_HEIGHT } as CSSProperties + } + > + {isDragDisabled ? ( + list + ) : ( + <DndContext modifiers={[restrictToVerticalAxis]} onDragEnd={onDragEnd}> + <SortableContext + items={shownKeys} + strategy={verticalListSortingStrategy} + > + {list} + </SortableContext> + </DndContext> + )} + </div> + </VStack> + </LayoutContent> + } + footer={ + <LayoutFooter hasDivider> + <HStack justify="end" gap={2} align="center"> + <Button + label={cancelLabel ?? t("uic.common.cancel")} + variant="secondary" + onClick={close} + /> + <Button + label={applyLabel ?? t("uic.common.apply")} + variant="primary" + onClick={() => { + const required = columns + .filter((column) => column.isAlwaysVisible) + .map((column) => column.key); + onApply({ + selectedColumnKeys: [ + ...selected, + ...required.filter((key) => !selected.includes(key)), + ], + columnOrder: order, + }); + }} + /> + </HStack> + </LayoutFooter> + } + className="uic-data-grid-dialog" + /> + ); +} + +export function DataGridSettingsModal({ + isOpen, + onOpenChange, + afterOpenChange, + isReorderable = true, + ...bodyProps +}: DataGridSettingsModalProps): ReactElement { + return ( + <Modal + isOpen={isOpen} + onOpenChange={onOpenChange} + afterOpenChange={afterOpenChange} + width={WIDTH} + purpose="form" + unmountOnClose + > + <SettingsBody + {...bodyProps} + onOpenChange={onOpenChange} + isReorderable={isReorderable} + /> + </Modal> + ); +} diff --git a/src/components/DataGrid/index.ts b/src/components/DataGrid/index.ts new file mode 100644 index 0000000..f68ac7a --- /dev/null +++ b/src/components/DataGrid/index.ts @@ -0,0 +1,26 @@ +export { DataGrid, dataGridColumnLabel, isDataGridColumnVisible } from "./DataGrid"; +export type { + DataGridColumn, + DataGridColumnOverride, + DataGridColumnOverrides, + DataGridColumnSettings, + DataGridCsvExport, + DataGridExpansion, + DataGridKey, + DataGridPagination, + DataGridProps, + DataGridSelection, + DataGridSort, + DataGridSortDirection, +} from "./DataGrid"; +export { DataGridSettingsModal } from "./DataGridSettingsModal"; +export type { + DataGridSettingsColumn, + DataGridSettingsModalProps, + DataGridSettingsResult, +} from "./DataGridSettingsModal"; +export { DataGridExportModal } from "./DataGridExportModal"; +export type { + DataGridExportColumn, + DataGridExportModalProps, +} from "./DataGridExportModal"; diff --git a/src/components/DataGrid/nodeToPlainText.ts b/src/components/DataGrid/nodeToPlainText.ts new file mode 100644 index 0000000..bf3045a --- /dev/null +++ b/src/components/DataGrid/nodeToPlainText.ts @@ -0,0 +1,16 @@ +import { isValidElement, type ReactNode } from "react"; + +/** + * The text of a node: strings and numbers, through elements, fragments and + * arrays. A textless node (an icon) gives "". + */ +export function nodeToPlainText(node: ReactNode): string { + if (node == null || typeof node === "boolean") return ""; + if (typeof node === "string") return node; + if (typeof node === "number") return String(node); + if (Array.isArray(node)) return node.map(nodeToPlainText).join(""); + if (isValidElement(node)) { + return nodeToPlainText((node.props as { children?: ReactNode }).children); + } + return ""; +} diff --git a/src/components/DoubleToken/DoubleToken.css b/src/components/DoubleToken/DoubleToken.css new file mode 100644 index 0000000..e449c7a --- /dev/null +++ b/src/components/DoubleToken/DoubleToken.css @@ -0,0 +1,19 @@ +/* + * DoubleToken: the weld between neighbouring tokens. Only the inner corners + * are squared; restating the outer ones would pin the run to the Token's + * current radius. + */ +@layer ui-common { + /* Each token carries a 1px outline, so neighbours overlap by that width and + the two strokes read as one seam. */ + .uic-double-token__item:not(:first-child) { + margin-inline-start: calc(var(--border-width) * -1); + border-start-start-radius: 0; + border-end-start-radius: 0; + } + + .uic-double-token__item:not(:last-child) { + border-start-end-radius: 0; + border-end-end-radius: 0; + } +} diff --git a/src/components/DoubleToken/DoubleToken.test.tsx b/src/components/DoubleToken/DoubleToken.test.tsx new file mode 100644 index 0000000..fd675f3 --- /dev/null +++ b/src/components/DoubleToken/DoubleToken.test.tsx @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; + +import { DoubleToken } from "./DoubleToken"; + +// The weld is CSS (jsdom does not lay it out), so these pin the DOM contract +// DoubleToken.css is written against: the item class on direct-child tokens, +// in source order, with no gap between them. +const root = (container: HTMLElement) => + container.querySelector(".uic-double-token") as HTMLElement; + +describe("DoubleToken", () => { + it("renders the run with gap 0", () => { + const { container } = render(<DoubleToken values={["User", "admin"]} />); + expect(root(container)).toHaveAttribute("data-gap", "0"); + }); + + it("renders each value as a direct token child, in source order", () => { + const { container } = render( + <DoubleToken + values={[ + { label: "R", color: "green" }, + { label: "W", color: "blue" }, + { label: "D", color: "red" }, + ]} + />, + ); + const tokens = Array.from(root(container).children); + expect(tokens).toHaveLength(3); + tokens.forEach((el) => expect(el).toHaveClass("uic-double-token__item")); + expect(tokens.map((el) => el.textContent)).toEqual(["R", "W", "D"]); + expect(tokens.map((el) => el.getAttribute("data-color"))).toEqual([ + "green", + "blue", + "red", + ]); + }); + + it("colours the string shorthand blue", () => { + const { container } = render(<DoubleToken values={["only"]} />); + expect(root(container).children[0]).toHaveAttribute("data-color", "blue"); + }); + + it("skips empty labels and renders nothing for no values", () => { + const { container } = render( + <DoubleToken + values={[ + { label: "User", color: "blue" }, + { label: "", color: "default" }, + ]} + />, + ); + expect(root(container).children).toHaveLength(1); + + const empty = render(<DoubleToken values={[]} />); + expect(empty.container).toBeEmptyDOMElement(); + }); + + it("keeps the plain label as the accessible name when highlighting", () => { + const { container } = render( + <DoubleToken values={["python", "3.11"]} highlightKeyword="py" />, + ); + const tokens = Array.from(root(container).children); + expect(tokens[0]).toHaveAttribute("aria-label", "python"); + expect(screen.getByText("py")).toHaveClass("uic-text-highlighter__match"); + }); +}); diff --git a/src/components/DoubleToken/DoubleToken.tsx b/src/components/DoubleToken/DoubleToken.tsx new file mode 100644 index 0000000..65153f8 --- /dev/null +++ b/src/components/DoubleToken/DoubleToken.tsx @@ -0,0 +1,74 @@ +/** + * DoubleToken + * + * A run of Tokens welded into one chip, for a settled pair such as a type and + * its version, or a scope and its name. Its live counterpart is `DoubleBadge`. + * Neighbours overlap by one border width and square their inner corners. + * + * A string value is a blue Token. Values with an empty label are skipped, and + * an empty list renders nothing. With `highlightKeyword` each label marks the + * keyword (`TextHighlighter`), and the plain label stays the accessible name. + * + * @example + * <DoubleToken values={[{ label: "CUDA", color: "green" }, "12.4"]} /> + */ +import type { ReactElement } from "react"; +import { HStack } from "@astryxdesign/core/Stack"; +import { Token, type TokenProps } from "@astryxdesign/core/Token"; + +import { TextHighlighter } from "../TextHighlighter/TextHighlighter"; +import "./DoubleToken.css"; + +export type DoubleTokenColor = NonNullable<TokenProps["color"]>; + +export interface DoubleTokenValue { + label: string; + /** @default 'blue' */ + color?: DoubleTokenColor; +} + +export interface DoubleTokenProps { + /** The tokens, in order. */ + values?: Array<string> | Array<DoubleTokenValue>; + /** Marks this text in every label. */ + highlightKeyword?: string; +} + +export function DoubleToken({ + values = [], + highlightKeyword, +}: DoubleTokenProps): ReactElement | null { + if (values.length === 0) return null; + const objectValues = (values as Array<string | DoubleTokenValue>).map( + (value): DoubleTokenValue => + typeof value === "string" ? { label: value, color: "blue" } : value, + ); + const isHighlighting = highlightKeyword !== undefined; + + return ( + <HStack gap={0} align="center" className="uic-double-token"> + {objectValues.map((value, idx) => + value.label ? ( + // Token.label is a string: a highlighted label is a hidden label + // plus endContent, so the plain string stays the accessible name. + <Token + key={idx} + className="uic-double-token__item" + color={value.color ?? "blue"} + label={value.label} + isLabelHidden={isHighlighting} + endContent={ + isHighlighting ? ( + <TextHighlighter keyword={highlightKeyword}> + {value.label} + </TextHighlighter> + ) : undefined + } + /> + ) : null, + )} + </HStack> + ); +} + +DoubleToken.displayName = "DoubleToken"; diff --git a/src/components/DoubleToken/index.ts b/src/components/DoubleToken/index.ts new file mode 100644 index 0000000..10cd56c --- /dev/null +++ b/src/components/DoubleToken/index.ts @@ -0,0 +1,6 @@ +export { DoubleToken } from "./DoubleToken"; +export type { + DoubleTokenColor, + DoubleTokenProps, + DoubleTokenValue, +} from "./DoubleToken"; diff --git a/src/components/ListBanner/ListBanner.css b/src/components/ListBanner/ListBanner.css new file mode 100644 index 0000000..625813d --- /dev/null +++ b/src/components/ListBanner/ListBanner.css @@ -0,0 +1,28 @@ +@layer ui-common { + .uic-list-banner__list { + max-height: var(--uic-list-banner-max-height); + margin: 0; + padding: 0; + padding-top: var(--spacing-1); + overflow-y: auto; + /* A thumb with no track, floating over the content. */ + scrollbar-color: var(--color-text-disabled) transparent; + scrollbar-width: thin; + } + + /* Safari and older WebKit; ignored where scrollbar-color is supported. */ + .uic-list-banner__list::-webkit-scrollbar, + .uic-list-banner__list::-webkit-scrollbar-track { + background: transparent; + } + + /* 6px: a thin scrollbar; Astryx has no scrollbar size token. */ + .uic-list-banner__list::-webkit-scrollbar { + width: 6px; + } + + .uic-list-banner__list::-webkit-scrollbar-thumb { + background: var(--color-text-disabled); + border-radius: var(--radius-full); + } +} diff --git a/src/components/ListBanner/ListBanner.test.tsx b/src/components/ListBanner/ListBanner.test.tsx new file mode 100644 index 0000000..194a870 --- /dev/null +++ b/src/components/ListBanner/ListBanner.test.tsx @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; + +import { ListBanner } from "./ListBanner"; + +describe("ListBanner", () => { + it("renders the title and one list item per item", () => { + render( + <ListBanner + status="warning" + title="Following users will be updated" + items={[ + { key: "1", content: "a@example.com" }, + { key: "2", content: "b@example.com" }, + ]} + />, + ); + expect(screen.getByText("Following users will be updated")).toBeInTheDocument(); + expect(screen.getAllByRole("listitem").map((li) => li.textContent)).toEqual([ + "a@example.com", + "b@example.com", + ]); + }); + + it("caps the list at 165px by default, and at maxHeight when given", () => { + const { unmount } = render( + <ListBanner status="info" title="t" items={[{ content: "x" }]} />, + ); + expect( + screen.getByRole("list").style.getPropertyValue("--uic-list-banner-max-height"), + ).toBe("165px"); + unmount(); + + render( + <ListBanner status="info" title="t" maxHeight={80} items={[{ content: "x" }]} />, + ); + const list = screen.getByRole("list"); + expect(list.style.getPropertyValue("--uic-list-banner-max-height")).toBe("80px"); + expect(list).toHaveClass("uic-list-banner__list"); + expect(list).toHaveAttribute("tabindex", "0"); + }); + + it("puts the list in the title slot when there is no title", () => { + render( + <ListBanner + status="info" + items={[{ content: "first" }, { content: "second" }]} + />, + ); + expect(screen.getByText("first")).toBeInTheDocument(); + expect(screen.getByText("second")).toBeInTheDocument(); + }); + + it("renders no list without items", () => { + render(<ListBanner status="info" title="Nothing" items={[]} />); + expect(screen.queryByRole("list")).toBeNull(); + }); +}); diff --git a/src/components/ListBanner/ListBanner.tsx b/src/components/ListBanner/ListBanner.tsx new file mode 100644 index 0000000..1f743ed --- /dev/null +++ b/src/components/ListBanner/ListBanner.tsx @@ -0,0 +1,77 @@ +/** + * ListBanner + * + * A `Banner` whose description is a list of items, for "these N things will + * be affected" notices in a dialog. The list scrolls once it passes + * `maxHeight`, so the dialog does not grow with the selection, and the + * scrolling list is reachable by keyboard. Say how many items there are in + * the `title`. + * + * Without a `title` the list takes the banner's title slot. Without items + * there is no list. + * + * @example + * <ListBanner + * status="warning" + * title={`${users.length} users will be updated`} + * items={users.map((user) => ({ key: user.id, content: user.email }))} + * /> + */ +import type { CSSProperties, Key, ReactElement, ReactNode } from "react"; +import { Banner, type BannerProps } from "@astryxdesign/core/Banner"; + +import "./ListBanner.css"; + +export interface ListBannerItem { + /** Default: the item's position. */ + key?: Key | null; + content: ReactNode; +} + +export interface ListBannerProps extends Omit<BannerProps, "title" | "description"> { + title?: ReactNode; + items: ReadonlyArray<ListBannerItem>; + /** Height at which the list scrolls. Default: 165 (about seven rows) */ + maxHeight?: number | string; +} + +const DEFAULT_MAX_HEIGHT = 165; + +export function ListBanner({ + title, + items, + maxHeight = DEFAULT_MAX_HEIGHT, + ...bannerProps +}: ListBannerProps): ReactElement { + const list = + items.length === 0 ? undefined : ( + <ul + // Keyboard users can scroll it. + tabIndex={0} + className="uic-list-banner__list" + style={ + { + "--uic-list-banner-max-height": + typeof maxHeight === "number" ? `${maxHeight}px` : maxHeight, + // Inline: product sheets reset `ul` unlayered, which beats any layer. + listStyle: "circle inside", + } as CSSProperties + } + > + {items.map((item, index) => ( + <li key={item.key ?? `__index-${index}`}>{item.content}</li> + ))} + </ul> + ); + const hasTitle = title !== undefined && title !== null; + + return ( + <Banner + {...bannerProps} + title={hasTitle ? title : list} + description={hasTitle ? list : undefined} + /> + ); +} + +ListBanner.displayName = "ListBanner"; diff --git a/src/components/ListBanner/index.ts b/src/components/ListBanner/index.ts new file mode 100644 index 0000000..ab1bbaf --- /dev/null +++ b/src/components/ListBanner/index.ts @@ -0,0 +1,2 @@ +export { ListBanner } from "./ListBanner"; +export type { ListBannerItem, ListBannerProps } from "./ListBanner"; diff --git a/src/components/ProgressWithLabel/ProgressWithLabel.css b/src/components/ProgressWithLabel/ProgressWithLabel.css new file mode 100644 index 0000000..ccc093c --- /dev/null +++ b/src/components/ProgressWithLabel/ProgressWithLabel.css @@ -0,0 +1,62 @@ +@layer ui-common { + .uic-progress-with-label { + position: relative; + display: flex; + flex-direction: column; + align-items: stretch; + flex-shrink: 0; + box-sizing: border-box; + min-width: 0; + min-height: 0; + /* A hairline between the frame and the fill. */ + padding: var(--border-width); + border: var(--border-width) solid var(--color-border-emphasized); + border-radius: var(--uic-progress-with-label-radius, var(--radius-inner)); + background-color: var(--color-background-muted); + } + + .uic-progress-with-label--grow { + flex: 1; + } + + .uic-progress-with-label--sm { + --uic-progress-with-label-font-size: var(--font-size-sm); + } + + .uic-progress-with-label--md { + --uic-progress-with-label-font-size: var(--font-size-base); + } + + .uic-progress-with-label--lg { + --uic-progress-with-label-font-size: var(--font-size-lg); + } + + .uic-progress-with-label__fill { + position: absolute; + top: 0; + left: 0; + z-index: 0; + height: 100%; + overflow: hidden; + opacity: 0.7; + background-color: var(--uic-progress-with-label-color, var(--color-success)); + } + + /* Positioned, so it paints above the fill. */ + .uic-progress-with-label__labels { + position: relative; + display: flex; + flex-shrink: 0; + align-items: center; + justify-content: space-between; + min-width: 0; + } + + .uic-progress-with-label__text { + font-size: var(--uic-progress-with-label-font-size); + } + + .uic-progress-with-label__value { + min-height: var(--spacing-1); + } +} diff --git a/src/components/ProgressWithLabel/ProgressWithLabel.test.tsx b/src/components/ProgressWithLabel/ProgressWithLabel.test.tsx new file mode 100644 index 0000000..b6f3ed5 --- /dev/null +++ b/src/components/ProgressWithLabel/ProgressWithLabel.test.tsx @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; + +import { ProgressWithLabel } from "./ProgressWithLabel"; + +const fillOf = (container: HTMLElement) => + container.querySelector<HTMLElement>(".uic-progress-with-label__fill")!; +const rootOf = (container: HTMLElement) => + container.querySelector<HTMLElement>(".uic-progress-with-label")!; + +describe("ProgressWithLabel", () => { + it("renders both labels over a fill of value percent", () => { + const { container } = render( + <ProgressWithLabel label="CPU" valueLabel="3 / 8" value={37.5} />, + ); + expect(screen.getByText("CPU")).toBeInTheDocument(); + expect(screen.getByText("3 / 8")).toBeInTheDocument(); + expect(fillOf(container).style.width).toBe("37.5%"); + }); + + it.each([ + [undefined, "0%"], + [Number.NaN, "0%"], + [0, "0%"], + [150, "100%"], + ])("fills %s as %s", (value, width) => { + const { container } = render(<ProgressWithLabel label="x" value={value} />); + expect(fillOf(container).style.width).toBe(width); + }); + + it("greys the value label while the value is missing", () => { + render(<ProgressWithLabel label="x" valueLabel="-" />); + expect(screen.getByText("-")).toHaveAttribute("data-color", "disabled"); + }); + + it("keeps the value label's space when hasValueLabel is false", () => { + const { container } = render( + <ProgressWithLabel + label="x" + valueLabel="hidden" + value={10} + hasValueLabel={false} + />, + ); + expect(screen.queryByText("hidden")).toBeNull(); + expect( + container.querySelector(".uic-progress-with-label__value")?.textContent, + ).toBe(" "); + }); + + it("takes a width, or grows without one", () => { + const fixed = render(<ProgressWithLabel label="x" width={120} />); + expect(rootOf(fixed.container).style.width).toBe("120px"); + expect(rootOf(fixed.container)).not.toHaveClass("uic-progress-with-label--grow"); + + const grown = render(<ProgressWithLabel label="y" />); + expect(rootOf(grown.container)).toHaveClass("uic-progress-with-label--grow"); + }); + + it("sets the fill colour, size, style and label style", () => { + const { container } = render( + <ProgressWithLabel + label="x" + value={10} + color="var(--color-error)" + size="lg" + style={{ border: "none" }} + labelStyle={{ height: "8px" }} + />, + ); + const root = rootOf(container); + expect(root.style.getPropertyValue("--uic-progress-with-label-color")).toBe( + "var(--color-error)", + ); + expect(root).toHaveClass("uic-progress-with-label--lg"); + expect(root.style.borderStyle).toBe("none"); + expect(screen.getByText("x").style.height).toBe("8px"); + }); +}); diff --git a/src/components/ProgressWithLabel/ProgressWithLabel.tsx b/src/components/ProgressWithLabel/ProgressWithLabel.tsx new file mode 100644 index 0000000..a6f6e91 --- /dev/null +++ b/src/components/ProgressWithLabel/ProgressWithLabel.tsx @@ -0,0 +1,94 @@ +/** + * ProgressWithLabel + * + * A bar that carries its own labels: `label` at the start and `valueLabel` at + * the end, over a fill of `value` percent. For compact resource readouts in + * table cells and cards ("CPU 3 / 8 cores"). A missing or NaN `value` draws + * no fill and greys the value label. + * + * The fill is `color`, or `--uic-progress-with-label-color` (default Astryx + * `--color-success`). The frame's corner is `--uic-progress-with-label-radius` + * (default `--radius-inner`). + * + * @example + * <ProgressWithLabel label="CPU" valueLabel="3 / 8" value={37.5} width={160} /> + */ +import type { CSSProperties, ReactElement, ReactNode } from "react"; +import { Text } from "@astryxdesign/core/Text"; + +import "./ProgressWithLabel.css"; + +export interface ProgressWithLabelProps { + /** Start label, usually what is measured. */ + label?: ReactNode; + /** End label, usually the amount. */ + valueLabel?: ReactNode; + /** Fill, in percent. Values above 100 fill the bar. */ + value?: number; + /** Whether the end label shows. Its space stays reserved. Default: true */ + hasValueLabel?: boolean; + /** Fill colour: any CSS colour or `var()`. */ + color?: string; + /** Width of the bar. Without it the bar grows to fill its flex container. */ + width?: CSSProperties["width"]; + /** Label size. Default: "sm" */ + size?: "sm" | "md" | "lg"; + className?: string; + /** Inline style of the frame. */ + style?: CSSProperties; + /** Inline style of both labels. */ + labelStyle?: CSSProperties; +} + +export function ProgressWithLabel({ + label, + valueLabel, + value, + hasValueLabel = true, + color, + width, + size = "sm", + className, + style, + labelStyle, +}: ProgressWithLabelProps): ReactElement { + const isValueMissing = value === undefined || Number.isNaN(value); + const fill = !value || Number.isNaN(value) ? 0 : Math.min(value, 100); + const hasWidth = typeof width === "number" || typeof width === "string"; + + return ( + <div + className={[ + "uic-progress-with-label", + `uic-progress-with-label--${size}`, + !hasWidth && "uic-progress-with-label--grow", + className, + ] + .filter(Boolean) + .join(" ")} + style={{ + ...(hasWidth ? { width } : null), + ...(color + ? ({ "--uic-progress-with-label-color": color } as CSSProperties) + : null), + ...style, + }} + > + <div className="uic-progress-with-label__fill" style={{ width: `${fill}%` }} /> + <div className="uic-progress-with-label__labels"> + <Text className="uic-progress-with-label__text" style={labelStyle}> + {label} + </Text> + <Text + className="uic-progress-with-label__text uic-progress-with-label__value" + color={isValueMissing ? "disabled" : undefined} + style={labelStyle} + > + {hasValueLabel ? valueLabel : " "} + </Text> + </div> + </div> + ); +} + +ProgressWithLabel.displayName = "ProgressWithLabel"; diff --git a/src/components/ProgressWithLabel/index.ts b/src/components/ProgressWithLabel/index.ts new file mode 100644 index 0000000..0d5536a --- /dev/null +++ b/src/components/ProgressWithLabel/index.ts @@ -0,0 +1,2 @@ +export { ProgressWithLabel } from "./ProgressWithLabel"; +export type { ProgressWithLabelProps } from "./ProgressWithLabel"; diff --git a/src/components/TextHighlighter/TextHighlighter.css b/src/components/TextHighlighter/TextHighlighter.css new file mode 100644 index 0000000..f5a2ffa --- /dev/null +++ b/src/components/TextHighlighter/TextHighlighter.css @@ -0,0 +1,8 @@ +@layer ui-common { + .uic-text-highlighter__match { + background-color: var( + --uic-text-highlighter-background, + var(--color-warning-muted) + ); + } +} diff --git a/src/components/TextHighlighter/TextHighlighter.test.tsx b/src/components/TextHighlighter/TextHighlighter.test.tsx new file mode 100644 index 0000000..1bf8aef --- /dev/null +++ b/src/components/TextHighlighter/TextHighlighter.test.tsx @@ -0,0 +1,62 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { render } from "@testing-library/react"; + +import { TextHighlighter } from "./TextHighlighter"; + +const matches = (container: HTMLElement) => + [...container.querySelectorAll(".uic-text-highlighter__match")].map( + (el) => el.textContent, + ); + +describe("TextHighlighter", () => { + it("marks every case-insensitive occurrence, keeping the original case", () => { + const { container } = render( + <TextHighlighter keyword="backend">Backend.AI runs backend work</TextHighlighter>, + ); + expect(matches(container)).toEqual(["Backend", "backend"]); + expect(container.textContent).toBe("Backend.AI runs backend work"); + }); + + it("matches regex characters literally", () => { + const { container } = render( + <TextHighlighter keyword="(a+b)">sum (a+b) and a+b</TextHighlighter>, + ); + expect(matches(container)).toEqual(["(a+b)"]); + }); + + it("renders plain text without a keyword and nothing without text", () => { + const plain = render(<TextHighlighter>plain</TextHighlighter>); + expect(plain.container.textContent).toBe("plain"); + expect(matches(plain.container)).toEqual([]); + + const empty = render(<TextHighlighter keyword="x">{null}</TextHighlighter>); + expect(empty.container).toBeEmptyDOMElement(); + }); + + it("applies highlightStyle to each mark and className to the root", () => { + const { container } = render( + <TextHighlighter + keyword="a" + className="host" + highlightStyle={{ fontWeight: 700 }} + > + a-a + </TextHighlighter>, + ); + expect(container.firstElementChild).toHaveClass("uic-text-highlighter", "host"); + const marks = container.querySelectorAll<HTMLElement>( + ".uic-text-highlighter__match", + ); + expect(marks).toHaveLength(2); + expect(marks[0]?.style.fontWeight).toBe("700"); + }); + + it("paints the mark from the custom property, defaulting to warning-muted", () => { + const css = readFileSync(join(__dirname, "TextHighlighter.css"), "utf8"); + expect(css).toMatch( + /background-color:\s*var\(\s*--uic-text-highlighter-background,\s*var\(--color-warning-muted\)\s*\)/, + ); + }); +}); diff --git a/src/components/TextHighlighter/TextHighlighter.tsx b/src/components/TextHighlighter/TextHighlighter.tsx new file mode 100644 index 0000000..ededcc3 --- /dev/null +++ b/src/components/TextHighlighter/TextHighlighter.tsx @@ -0,0 +1,63 @@ +/** + * TextHighlighter + * + * Marks every case-insensitive occurrence of `keyword` in a string, for search + * results. The keyword is matched literally (regex characters are escaped). + * Without a keyword the text renders plain; without text nothing renders. + * + * The mark's background is `--uic-text-highlighter-background`, by default + * Astryx `--color-warning-muted`; set it on any ancestor to change it. + * + * @example + * <TextHighlighter keyword={search}>{row.name}</TextHighlighter> + */ +import { memo, type CSSProperties, type ReactElement } from "react"; + +import "./TextHighlighter.css"; + +export interface TextHighlighterProps { + /** The text to search in. */ + children?: string | null; + /** The text to mark. */ + keyword?: string; + /** Class on the outer span. */ + className?: string; + /** Inline style of each marked part. */ + highlightStyle?: CSSProperties; +} + +const escapeRegExp = (text: string) => text.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&"); + +function TextHighlighterBase({ + children, + keyword, + className, + highlightStyle, +}: TextHighlighterProps): ReactElement | null { + if (!children) return null; + const rootClassName = ["uic-text-highlighter", className].filter(Boolean).join(" "); + if (!keyword) return <span className={rootClassName}>{children}</span>; + + const lowerKeyword = keyword.toLowerCase(); + const parts = children.split(new RegExp(`(${escapeRegExp(keyword)})`, "gi")); + return ( + <span className={rootClassName}> + {parts.map((part, index) => + part.toLowerCase() === lowerKeyword ? ( + <span + key={index} + className="uic-text-highlighter__match" + style={highlightStyle} + > + {part} + </span> + ) : ( + part + ), + )} + </span> + ); +} + +export const TextHighlighter = memo(TextHighlighterBase); +TextHighlighter.displayName = "TextHighlighter"; diff --git a/src/components/TextHighlighter/index.ts b/src/components/TextHighlighter/index.ts new file mode 100644 index 0000000..e717a8d --- /dev/null +++ b/src/components/TextHighlighter/index.ts @@ -0,0 +1,2 @@ +export { TextHighlighter } from "./TextHighlighter"; +export type { TextHighlighterProps } from "./TextHighlighter"; diff --git a/src/i18n/catalog.ts b/src/i18n/catalog.ts index 902dda6..2ff5e10 100644 --- a/src/i18n/catalog.ts +++ b/src/i18n/catalog.ts @@ -26,7 +26,9 @@ import type { Catalog, MessageEntry } from "@astryxdesign/core/i18n"; // it is a function declaration, so it is bound before either module runs. import { booleanTokenMessages } from "../components/BooleanToken/BooleanToken.messages"; import { bulkEditFormItemMessages } from "../components/BulkEditFormItem/BulkEditFormItem.messages"; +import { bulkErrorModalMessages } from "../components/BulkErrorModal/BulkErrorModal.messages"; import { colorPickerMessages } from "../components/ColorPicker/ColorPicker.messages"; +import { dataGridMessages } from "../components/DataGrid/DataGrid.messages"; import { deleteConfirmModalMessages } from "../components/DeleteConfirmModal/DeleteConfirmModal.messages"; import { formMessages } from "../components/Form/Form.messages"; import { notificationStackMessages } from "../components/NotificationStack/NotificationStack.messages"; @@ -60,7 +62,9 @@ export const uiCommonCatalog: Catalog = { ...commonMessages, ...booleanTokenMessages, ...bulkEditFormItemMessages, + ...bulkErrorModalMessages, ...colorPickerMessages, + ...dataGridMessages, ...deleteConfirmModalMessages, ...formMessages, ...notificationStackMessages, diff --git a/src/i18n/common.messages.ts b/src/i18n/common.messages.ts index e82ae68..c4217ca 100644 --- a/src/i18n/common.messages.ts +++ b/src/i18n/common.messages.ts @@ -19,6 +19,10 @@ export const commonMessages = defineMessages({ defaultMessage: "Confirm", description: "Generic label of a button that confirms an action", }, + "uic.common.apply": { + defaultMessage: "Apply", + description: "Generic label of a button that applies the changes made in a dialog", + }, "uic.common.delete": { defaultMessage: "Delete", description: "Generic label of a button that deletes something", diff --git a/src/i18n/locales/de-DE.json b/src/i18n/locales/de-DE.json index c06814d..7407caa 100644 --- a/src/i18n/locales/de-DE.json +++ b/src/i18n/locales/de-DE.json @@ -11,6 +11,10 @@ "defaultMessage": "Löschen", "description": "Generic label of a button that deletes something" }, + "uic.common.apply": { + "defaultMessage": "Anwenden", + "description": "Generic label of a button that applies the changes made in a dialog" + }, "uic.ColorPicker.label": { "defaultMessage": "Farbe auswählen", "description": "Accessible name of a color picker whose caller gave it no label" @@ -194,5 +198,57 @@ "uic.BulkEditFormItem.undoChanges": { "defaultMessage": "Änderungen rückgängig machen", "description": "Link that returns a bulk-edit field to keeping the current values" + }, + "uic.BulkErrorModal.title": { + "defaultMessage": "Ausführung fehlgeschlagen", + "description": "Default title of the dialog that lists the failed items of a bulk operation" + }, + "uic.BulkErrorModal.errorOccurred": { + "defaultMessage": "Ein Fehler ist aufgetreten", + "description": "Title of the error banner above the failed items, over the caller's guidance" + }, + "uic.DataGrid.noData": { + "defaultMessage": "Keine Daten zum Anzeigen", + "description": "Shown in place of a data grid's rows when there are none" + }, + "uic.DataGrid.invalidPage": { + "defaultMessage": "Ungültige Seitenzahl", + "description": "Shown in place of a data grid's rows when the current page lies past the last page" + }, + "uic.DataGrid.goToFirstPage": { + "defaultMessage": "Zur ersten Seite", + "description": "Button under the invalid page message that returns to page 1" + }, + "uic.DataGrid.expandRow": { + "defaultMessage": "Zeile ausklappen", + "description": "Accessible name of the button that shows or hides a row's details" + }, + "uic.DataGrid.pagination": { + "defaultMessage": "Seitennummerierung", + "description": "Accessible name of a data grid's page navigation" + }, + "uic.DataGrid.range": { + "defaultMessage": "{start} - {end} von {total} Elementen", + "description": "Beside a data grid's page navigation: the rows on screen out of the total. {start} and {end} are 1-based row numbers" + }, + "uic.DataGrid.settings": { + "defaultMessage": "Tabelleneinstellungen", + "description": "Button under a data grid that opens its column settings, and the title of that dialog" + }, + "uic.DataGrid.exportCsv": { + "defaultMessage": "Als CSV exportieren", + "description": "Button under a data grid that opens the CSV export dialog, and the title of that dialog" + }, + "uic.DataGrid.selectColumns": { + "defaultMessage": "Wählen Sie Spalten aus, um angezeigt zu werden", + "description": "Subtitle of a data grid's column settings and CSV export dialogs" + }, + "uic.DataGrid.searchColumns": { + "defaultMessage": "Suchtabellenspalten", + "description": "Placeholder of the column search field in a data grid's dialogs, also shown when no column matches" + }, + "uic.DataGrid.export": { + "defaultMessage": "Exportieren", + "description": "Primary button of a data grid's CSV export dialog" } } diff --git a/src/i18n/locales/el-GR.json b/src/i18n/locales/el-GR.json index a4750fd..8244a7c 100644 --- a/src/i18n/locales/el-GR.json +++ b/src/i18n/locales/el-GR.json @@ -11,6 +11,10 @@ "defaultMessage": "Διαγράφω", "description": "Generic label of a button that deletes something" }, + "uic.common.apply": { + "defaultMessage": "Εφαρμογή", + "description": "Generic label of a button that applies the changes made in a dialog" + }, "uic.ColorPicker.label": { "defaultMessage": "Επιλογή χρώματος", "description": "Accessible name of a color picker whose caller gave it no label" @@ -194,5 +198,57 @@ "uic.BulkEditFormItem.undoChanges": { "defaultMessage": "Αναίρεση αλλαγών", "description": "Link that returns a bulk-edit field to keeping the current values" + }, + "uic.BulkErrorModal.title": { + "defaultMessage": "Η εκτέλεση απέτυχε", + "description": "Default title of the dialog that lists the failed items of a bulk operation" + }, + "uic.BulkErrorModal.errorOccurred": { + "defaultMessage": "Παρουσιάστηκε σφάλμα", + "description": "Title of the error banner above the failed items, over the caller's guidance" + }, + "uic.DataGrid.noData": { + "defaultMessage": "Δεν υπάρχουν δεδομένα για εμφάνιση", + "description": "Shown in place of a data grid's rows when there are none" + }, + "uic.DataGrid.invalidPage": { + "defaultMessage": "Μη έγκυρος αριθμός σελίδας", + "description": "Shown in place of a data grid's rows when the current page lies past the last page" + }, + "uic.DataGrid.goToFirstPage": { + "defaultMessage": "Μετάβαση στην πρώτη σελίδα", + "description": "Button under the invalid page message that returns to page 1" + }, + "uic.DataGrid.expandRow": { + "defaultMessage": "Ανάπτυξη γραμμής", + "description": "Accessible name of the button that shows or hides a row's details" + }, + "uic.DataGrid.pagination": { + "defaultMessage": "Σελιδοποίηση", + "description": "Accessible name of a data grid's page navigation" + }, + "uic.DataGrid.range": { + "defaultMessage": "{start} - {end} των στοιχείων {total}", + "description": "Beside a data grid's page navigation: the rows on screen out of the total. {start} and {end} are 1-based row numbers" + }, + "uic.DataGrid.settings": { + "defaultMessage": "Ρυθμίσεις πίνακα", + "description": "Button under a data grid that opens its column settings, and the title of that dialog" + }, + "uic.DataGrid.exportCsv": { + "defaultMessage": "Εξαγωγή CSV", + "description": "Button under a data grid that opens the CSV export dialog, and the title of that dialog" + }, + "uic.DataGrid.selectColumns": { + "defaultMessage": "Επιλέξτε στήλες για εμφάνιση", + "description": "Subtitle of a data grid's column settings and CSV export dialogs" + }, + "uic.DataGrid.searchColumns": { + "defaultMessage": "Στήλες πίνακα αναζήτησης", + "description": "Placeholder of the column search field in a data grid's dialogs, also shown when no column matches" + }, + "uic.DataGrid.export": { + "defaultMessage": "Εξαγωγή", + "description": "Primary button of a data grid's CSV export dialog" } } diff --git a/src/i18n/locales/es-ES.json b/src/i18n/locales/es-ES.json index 2f5146a..6d29f52 100644 --- a/src/i18n/locales/es-ES.json +++ b/src/i18n/locales/es-ES.json @@ -11,6 +11,10 @@ "defaultMessage": "Borrar", "description": "Generic label of a button that deletes something" }, + "uic.common.apply": { + "defaultMessage": "Aplicar", + "description": "Generic label of a button that applies the changes made in a dialog" + }, "uic.ColorPicker.label": { "defaultMessage": "Seleccionar color", "description": "Accessible name of a color picker whose caller gave it no label" @@ -194,5 +198,57 @@ "uic.BulkEditFormItem.undoChanges": { "defaultMessage": "Deshacer cambios", "description": "Link that returns a bulk-edit field to keeping the current values" + }, + "uic.BulkErrorModal.title": { + "defaultMessage": "Error de ejecución", + "description": "Default title of the dialog that lists the failed items of a bulk operation" + }, + "uic.BulkErrorModal.errorOccurred": { + "defaultMessage": "Se produjo un error", + "description": "Title of the error banner above the failed items, over the caller's guidance" + }, + "uic.DataGrid.noData": { + "defaultMessage": "No hay datos para mostrar", + "description": "Shown in place of a data grid's rows when there are none" + }, + "uic.DataGrid.invalidPage": { + "defaultMessage": "Número de página no válido", + "description": "Shown in place of a data grid's rows when the current page lies past the last page" + }, + "uic.DataGrid.goToFirstPage": { + "defaultMessage": "Ir a la primera página", + "description": "Button under the invalid page message that returns to page 1" + }, + "uic.DataGrid.expandRow": { + "defaultMessage": "Expandir fila", + "description": "Accessible name of the button that shows or hides a row's details" + }, + "uic.DataGrid.pagination": { + "defaultMessage": "Paginación", + "description": "Accessible name of a data grid's page navigation" + }, + "uic.DataGrid.range": { + "defaultMessage": "{start} - {end} de {total} elementos", + "description": "Beside a data grid's page navigation: the rows on screen out of the total. {start} and {end} are 1-based row numbers" + }, + "uic.DataGrid.settings": { + "defaultMessage": "Configuración de tabla", + "description": "Button under a data grid that opens its column settings, and the title of that dialog" + }, + "uic.DataGrid.exportCsv": { + "defaultMessage": "Exportar CSV", + "description": "Button under a data grid that opens the CSV export dialog, and the title of that dialog" + }, + "uic.DataGrid.selectColumns": { + "defaultMessage": "Seleccionar columnas para mostrar", + "description": "Subtitle of a data grid's column settings and CSV export dialogs" + }, + "uic.DataGrid.searchColumns": { + "defaultMessage": "Columnas de la tabla de búsqueda", + "description": "Placeholder of the column search field in a data grid's dialogs, also shown when no column matches" + }, + "uic.DataGrid.export": { + "defaultMessage": "Exportar", + "description": "Primary button of a data grid's CSV export dialog" } } diff --git a/src/i18n/locales/fi-FI.json b/src/i18n/locales/fi-FI.json index ffbe422..eb5bb35 100644 --- a/src/i18n/locales/fi-FI.json +++ b/src/i18n/locales/fi-FI.json @@ -11,6 +11,10 @@ "defaultMessage": "Poistaa", "description": "Generic label of a button that deletes something" }, + "uic.common.apply": { + "defaultMessage": "Käytä", + "description": "Generic label of a button that applies the changes made in a dialog" + }, "uic.ColorPicker.label": { "defaultMessage": "Valitse väri", "description": "Accessible name of a color picker whose caller gave it no label" @@ -194,5 +198,57 @@ "uic.BulkEditFormItem.undoChanges": { "defaultMessage": "Kumoa muutokset", "description": "Link that returns a bulk-edit field to keeping the current values" + }, + "uic.BulkErrorModal.title": { + "defaultMessage": "Suoritus epäonnistui", + "description": "Default title of the dialog that lists the failed items of a bulk operation" + }, + "uic.BulkErrorModal.errorOccurred": { + "defaultMessage": "Tapahtui virhe", + "description": "Title of the error banner above the failed items, over the caller's guidance" + }, + "uic.DataGrid.noData": { + "defaultMessage": "Ei näytettäviä tietoja", + "description": "Shown in place of a data grid's rows when there are none" + }, + "uic.DataGrid.invalidPage": { + "defaultMessage": "Virheellinen sivunumero", + "description": "Shown in place of a data grid's rows when the current page lies past the last page" + }, + "uic.DataGrid.goToFirstPage": { + "defaultMessage": "Siirry ensimmäiselle sivulle", + "description": "Button under the invalid page message that returns to page 1" + }, + "uic.DataGrid.expandRow": { + "defaultMessage": "Laajenna rivi", + "description": "Accessible name of the button that shows or hides a row's details" + }, + "uic.DataGrid.pagination": { + "defaultMessage": "Sivutus", + "description": "Accessible name of a data grid's page navigation" + }, + "uic.DataGrid.range": { + "defaultMessage": "{start}–{end} yhteensä {total} kohteesta", + "description": "Beside a data grid's page navigation: the rows on screen out of the total. {start} and {end} are 1-based row numbers" + }, + "uic.DataGrid.settings": { + "defaultMessage": "Taulukon asetukset", + "description": "Button under a data grid that opens its column settings, and the title of that dialog" + }, + "uic.DataGrid.exportCsv": { + "defaultMessage": "Vie CSV", + "description": "Button under a data grid that opens the CSV export dialog, and the title of that dialog" + }, + "uic.DataGrid.selectColumns": { + "defaultMessage": "Valitse näytettävä sarakkeet", + "description": "Subtitle of a data grid's column settings and CSV export dialogs" + }, + "uic.DataGrid.searchColumns": { + "defaultMessage": "Hakutaulukon sarakkeet", + "description": "Placeholder of the column search field in a data grid's dialogs, also shown when no column matches" + }, + "uic.DataGrid.export": { + "defaultMessage": "Vie", + "description": "Primary button of a data grid's CSV export dialog" } } diff --git a/src/i18n/locales/fr-FR.json b/src/i18n/locales/fr-FR.json index c287ab1..1e91c4a 100644 --- a/src/i18n/locales/fr-FR.json +++ b/src/i18n/locales/fr-FR.json @@ -11,6 +11,10 @@ "defaultMessage": "Supprimer", "description": "Generic label of a button that deletes something" }, + "uic.common.apply": { + "defaultMessage": "Appliquer", + "description": "Generic label of a button that applies the changes made in a dialog" + }, "uic.ColorPicker.label": { "defaultMessage": "Sélectionner une couleur", "description": "Accessible name of a color picker whose caller gave it no label" @@ -194,5 +198,57 @@ "uic.BulkEditFormItem.undoChanges": { "defaultMessage": "Annuler les modifications", "description": "Link that returns a bulk-edit field to keeping the current values" + }, + "uic.BulkErrorModal.title": { + "defaultMessage": "Échec de l'exécution", + "description": "Default title of the dialog that lists the failed items of a bulk operation" + }, + "uic.BulkErrorModal.errorOccurred": { + "defaultMessage": "Une erreur s'est produite", + "description": "Title of the error banner above the failed items, over the caller's guidance" + }, + "uic.DataGrid.noData": { + "defaultMessage": "Aucune donnée à afficher", + "description": "Shown in place of a data grid's rows when there are none" + }, + "uic.DataGrid.invalidPage": { + "defaultMessage": "Numéro de page invalide", + "description": "Shown in place of a data grid's rows when the current page lies past the last page" + }, + "uic.DataGrid.goToFirstPage": { + "defaultMessage": "Aller à la première page", + "description": "Button under the invalid page message that returns to page 1" + }, + "uic.DataGrid.expandRow": { + "defaultMessage": "Développer la ligne", + "description": "Accessible name of the button that shows or hides a row's details" + }, + "uic.DataGrid.pagination": { + "defaultMessage": "Pagination", + "description": "Accessible name of a data grid's page navigation" + }, + "uic.DataGrid.range": { + "defaultMessage": "{start} - {end} des éléments {total}", + "description": "Beside a data grid's page navigation: the rows on screen out of the total. {start} and {end} are 1-based row numbers" + }, + "uic.DataGrid.settings": { + "defaultMessage": "Paramètres de la table", + "description": "Button under a data grid that opens its column settings, and the title of that dialog" + }, + "uic.DataGrid.exportCsv": { + "defaultMessage": "Exporter en CSV", + "description": "Button under a data grid that opens the CSV export dialog, and the title of that dialog" + }, + "uic.DataGrid.selectColumns": { + "defaultMessage": "Sélectionnez des colonnes à afficher", + "description": "Subtitle of a data grid's column settings and CSV export dialogs" + }, + "uic.DataGrid.searchColumns": { + "defaultMessage": "Colonnes de table de recherche", + "description": "Placeholder of the column search field in a data grid's dialogs, also shown when no column matches" + }, + "uic.DataGrid.export": { + "defaultMessage": "Exporter", + "description": "Primary button of a data grid's CSV export dialog" } } diff --git a/src/i18n/locales/id-ID.json b/src/i18n/locales/id-ID.json index a73cfc7..1571645 100644 --- a/src/i18n/locales/id-ID.json +++ b/src/i18n/locales/id-ID.json @@ -11,6 +11,10 @@ "defaultMessage": "Menghapus", "description": "Generic label of a button that deletes something" }, + "uic.common.apply": { + "defaultMessage": "Terapkan", + "description": "Generic label of a button that applies the changes made in a dialog" + }, "uic.ColorPicker.label": { "defaultMessage": "Pilih warna", "description": "Accessible name of a color picker whose caller gave it no label" @@ -194,5 +198,57 @@ "uic.BulkEditFormItem.undoChanges": { "defaultMessage": "Batalkan perubahan", "description": "Link that returns a bulk-edit field to keeping the current values" + }, + "uic.BulkErrorModal.title": { + "defaultMessage": "Eksekusi gagal", + "description": "Default title of the dialog that lists the failed items of a bulk operation" + }, + "uic.BulkErrorModal.errorOccurred": { + "defaultMessage": "Terjadi kesalahan", + "description": "Title of the error banner above the failed items, over the caller's guidance" + }, + "uic.DataGrid.noData": { + "defaultMessage": "Tidak ada data untuk ditampilkan", + "description": "Shown in place of a data grid's rows when there are none" + }, + "uic.DataGrid.invalidPage": { + "defaultMessage": "Nomor halaman tidak valid", + "description": "Shown in place of a data grid's rows when the current page lies past the last page" + }, + "uic.DataGrid.goToFirstPage": { + "defaultMessage": "Ke halaman pertama", + "description": "Button under the invalid page message that returns to page 1" + }, + "uic.DataGrid.expandRow": { + "defaultMessage": "Perluas baris", + "description": "Accessible name of the button that shows or hides a row's details" + }, + "uic.DataGrid.pagination": { + "defaultMessage": "Penomoran halaman", + "description": "Accessible name of a data grid's page navigation" + }, + "uic.DataGrid.range": { + "defaultMessage": "{start} - {end} dari {total} item", + "description": "Beside a data grid's page navigation: the rows on screen out of the total. {start} and {end} are 1-based row numbers" + }, + "uic.DataGrid.settings": { + "defaultMessage": "Pengaturan Tabel", + "description": "Button under a data grid that opens its column settings, and the title of that dialog" + }, + "uic.DataGrid.exportCsv": { + "defaultMessage": "Ekspor CSV", + "description": "Button under a data grid that opens the CSV export dialog, and the title of that dialog" + }, + "uic.DataGrid.selectColumns": { + "defaultMessage": "Pilih kolom untuk ditampilkan", + "description": "Subtitle of a data grid's column settings and CSV export dialogs" + }, + "uic.DataGrid.searchColumns": { + "defaultMessage": "Kolom Tabel Cari", + "description": "Placeholder of the column search field in a data grid's dialogs, also shown when no column matches" + }, + "uic.DataGrid.export": { + "defaultMessage": "Ekspor", + "description": "Primary button of a data grid's CSV export dialog" } } diff --git a/src/i18n/locales/it-IT.json b/src/i18n/locales/it-IT.json index dffdf35..c47f5dd 100644 --- a/src/i18n/locales/it-IT.json +++ b/src/i18n/locales/it-IT.json @@ -11,6 +11,10 @@ "defaultMessage": "Eliminare", "description": "Generic label of a button that deletes something" }, + "uic.common.apply": { + "defaultMessage": "Applica", + "description": "Generic label of a button that applies the changes made in a dialog" + }, "uic.ColorPicker.label": { "defaultMessage": "Seleziona colore", "description": "Accessible name of a color picker whose caller gave it no label" @@ -194,5 +198,57 @@ "uic.BulkEditFormItem.undoChanges": { "defaultMessage": "Annulla modifiche", "description": "Link that returns a bulk-edit field to keeping the current values" + }, + "uic.BulkErrorModal.title": { + "defaultMessage": "Esecuzione non riuscita", + "description": "Default title of the dialog that lists the failed items of a bulk operation" + }, + "uic.BulkErrorModal.errorOccurred": { + "defaultMessage": "Si è verificato un errore", + "description": "Title of the error banner above the failed items, over the caller's guidance" + }, + "uic.DataGrid.noData": { + "defaultMessage": "Nessun dato da visualizzare", + "description": "Shown in place of a data grid's rows when there are none" + }, + "uic.DataGrid.invalidPage": { + "defaultMessage": "Numero di pagina non valido", + "description": "Shown in place of a data grid's rows when the current page lies past the last page" + }, + "uic.DataGrid.goToFirstPage": { + "defaultMessage": "Vai alla prima pagina", + "description": "Button under the invalid page message that returns to page 1" + }, + "uic.DataGrid.expandRow": { + "defaultMessage": "Espandi riga", + "description": "Accessible name of the button that shows or hides a row's details" + }, + "uic.DataGrid.pagination": { + "defaultMessage": "Impaginazione", + "description": "Accessible name of a data grid's page navigation" + }, + "uic.DataGrid.range": { + "defaultMessage": "{start} - {end} di {total} elementi", + "description": "Beside a data grid's page navigation: the rows on screen out of the total. {start} and {end} are 1-based row numbers" + }, + "uic.DataGrid.settings": { + "defaultMessage": "Impostazioni della tabella", + "description": "Button under a data grid that opens its column settings, and the title of that dialog" + }, + "uic.DataGrid.exportCsv": { + "defaultMessage": "Esporta CSV", + "description": "Button under a data grid that opens the CSV export dialog, and the title of that dialog" + }, + "uic.DataGrid.selectColumns": { + "defaultMessage": "Seleziona le colonne da visualizzare", + "description": "Subtitle of a data grid's column settings and CSV export dialogs" + }, + "uic.DataGrid.searchColumns": { + "defaultMessage": "Colonne della tabella di ricerca", + "description": "Placeholder of the column search field in a data grid's dialogs, also shown when no column matches" + }, + "uic.DataGrid.export": { + "defaultMessage": "Esporta", + "description": "Primary button of a data grid's CSV export dialog" } } diff --git a/src/i18n/locales/ja-JP.json b/src/i18n/locales/ja-JP.json index a85357f..5d856e6 100644 --- a/src/i18n/locales/ja-JP.json +++ b/src/i18n/locales/ja-JP.json @@ -19,6 +19,10 @@ "defaultMessage": "再試行", "description": "Generic label of a button that retries a failed action or load" }, + "uic.common.apply": { + "defaultMessage": "適用", + "description": "Generic label of a button that applies the changes made in a dialog" + }, "uic.BooleanToken.true": { "defaultMessage": "はい", "description": "Default label of a token showing a boolean value that is true" @@ -234,5 +238,57 @@ "uic.BulkEditFormItem.undoChanges": { "defaultMessage": "変更を元に戻す", "description": "Link that returns a bulk-edit field to keeping the current values" + }, + "uic.BulkErrorModal.title": { + "defaultMessage": "実行エラー", + "description": "Default title of the dialog that lists the failed items of a bulk operation" + }, + "uic.BulkErrorModal.errorOccurred": { + "defaultMessage": "問題が発生しました。", + "description": "Title of the error banner above the failed items, over the caller's guidance" + }, + "uic.DataGrid.noData": { + "defaultMessage": "表示するデータがありません", + "description": "Shown in place of a data grid's rows when there are none" + }, + "uic.DataGrid.invalidPage": { + "defaultMessage": "無効なページ番号です", + "description": "Shown in place of a data grid's rows when the current page lies past the last page" + }, + "uic.DataGrid.goToFirstPage": { + "defaultMessage": "最初のページへ", + "description": "Button under the invalid page message that returns to page 1" + }, + "uic.DataGrid.expandRow": { + "defaultMessage": "行を展開", + "description": "Accessible name of the button that shows or hides a row's details" + }, + "uic.DataGrid.pagination": { + "defaultMessage": "ページネーション", + "description": "Accessible name of a data grid's page navigation" + }, + "uic.DataGrid.range": { + "defaultMessage": "{total}アイテム中 {start} - {end}", + "description": "Beside a data grid's page navigation: the rows on screen out of the total. {start} and {end} are 1-based row numbers" + }, + "uic.DataGrid.settings": { + "defaultMessage": "テーブル設定", + "description": "Button under a data grid that opens its column settings, and the title of that dialog" + }, + "uic.DataGrid.exportCsv": { + "defaultMessage": "CSV をエクスポート", + "description": "Button under a data grid that opens the CSV export dialog, and the title of that dialog" + }, + "uic.DataGrid.selectColumns": { + "defaultMessage": "表示する列を選択します", + "description": "Subtitle of a data grid's column settings and CSV export dialogs" + }, + "uic.DataGrid.searchColumns": { + "defaultMessage": "テーブル列を検索します", + "description": "Placeholder of the column search field in a data grid's dialogs, also shown when no column matches" + }, + "uic.DataGrid.export": { + "defaultMessage": "エクスポート", + "description": "Primary button of a data grid's CSV export dialog" } } diff --git a/src/i18n/locales/ko-KR.json b/src/i18n/locales/ko-KR.json index 3843ff3..e6a6630 100644 --- a/src/i18n/locales/ko-KR.json +++ b/src/i18n/locales/ko-KR.json @@ -19,6 +19,10 @@ "defaultMessage": "다시 시도", "description": "Generic label of a button that retries a failed action or load" }, + "uic.common.apply": { + "defaultMessage": "적용", + "description": "Generic label of a button that applies the changes made in a dialog" + }, "uic.BooleanToken.true": { "defaultMessage": "예", "description": "Default label of a token showing a boolean value that is true" @@ -234,5 +238,57 @@ "uic.BulkEditFormItem.undoChanges": { "defaultMessage": "변경 취소", "description": "Link that returns a bulk-edit field to keeping the current values" + }, + "uic.BulkErrorModal.title": { + "defaultMessage": "실행 오류", + "description": "Default title of the dialog that lists the failed items of a bulk operation" + }, + "uic.BulkErrorModal.errorOccurred": { + "defaultMessage": "문제가 발생했습니다.", + "description": "Title of the error banner above the failed items, over the caller's guidance" + }, + "uic.DataGrid.noData": { + "defaultMessage": "표시할 데이터가 없습니다", + "description": "Shown in place of a data grid's rows when there are none" + }, + "uic.DataGrid.invalidPage": { + "defaultMessage": "유효하지 않은 페이지 번호입니다", + "description": "Shown in place of a data grid's rows when the current page lies past the last page" + }, + "uic.DataGrid.goToFirstPage": { + "defaultMessage": "첫 페이지로 이동하기", + "description": "Button under the invalid page message that returns to page 1" + }, + "uic.DataGrid.expandRow": { + "defaultMessage": "행 펼치기", + "description": "Accessible name of the button that shows or hides a row's details" + }, + "uic.DataGrid.pagination": { + "defaultMessage": "페이지 나누기", + "description": "Accessible name of a data grid's page navigation" + }, + "uic.DataGrid.range": { + "defaultMessage": "전체 {total}개 중 {start} - {end}", + "description": "Beside a data grid's page navigation: the rows on screen out of the total. {start} and {end} are 1-based row numbers" + }, + "uic.DataGrid.settings": { + "defaultMessage": "표 설정", + "description": "Button under a data grid that opens its column settings, and the title of that dialog" + }, + "uic.DataGrid.exportCsv": { + "defaultMessage": "CSV로 내보내기", + "description": "Button under a data grid that opens the CSV export dialog, and the title of that dialog" + }, + "uic.DataGrid.selectColumns": { + "defaultMessage": "표시할 열 선택", + "description": "Subtitle of a data grid's column settings and CSV export dialogs" + }, + "uic.DataGrid.searchColumns": { + "defaultMessage": "표시할 열 검색", + "description": "Placeholder of the column search field in a data grid's dialogs, also shown when no column matches" + }, + "uic.DataGrid.export": { + "defaultMessage": "내보내기", + "description": "Primary button of a data grid's CSV export dialog" } } diff --git a/src/i18n/locales/mn-MN.json b/src/i18n/locales/mn-MN.json index 9cedffe..4147cd0 100644 --- a/src/i18n/locales/mn-MN.json +++ b/src/i18n/locales/mn-MN.json @@ -11,6 +11,10 @@ "defaultMessage": "Эдгээх", "description": "Generic label of a button that deletes something" }, + "uic.common.apply": { + "defaultMessage": "Хэрэглэх", + "description": "Generic label of a button that applies the changes made in a dialog" + }, "uic.ColorPicker.label": { "defaultMessage": "Өнгө сонгох", "description": "Accessible name of a color picker whose caller gave it no label" @@ -194,5 +198,57 @@ "uic.BulkEditFormItem.undoChanges": { "defaultMessage": "Өөрчлөлтийг буцаах", "description": "Link that returns a bulk-edit field to keeping the current values" + }, + "uic.BulkErrorModal.title": { + "defaultMessage": "Гүйцэтгэл амжилтгүй боллоо", + "description": "Default title of the dialog that lists the failed items of a bulk operation" + }, + "uic.BulkErrorModal.errorOccurred": { + "defaultMessage": "Алдаа гарлаа", + "description": "Title of the error banner above the failed items, over the caller's guidance" + }, + "uic.DataGrid.noData": { + "defaultMessage": "Харуулах өгөгдөл алга", + "description": "Shown in place of a data grid's rows when there are none" + }, + "uic.DataGrid.invalidPage": { + "defaultMessage": "Хуудасны дугаар буруу байна", + "description": "Shown in place of a data grid's rows when the current page lies past the last page" + }, + "uic.DataGrid.goToFirstPage": { + "defaultMessage": "Эх хуудас руу шилжих", + "description": "Button under the invalid page message that returns to page 1" + }, + "uic.DataGrid.expandRow": { + "defaultMessage": "Мөрийг дэлгэх", + "description": "Accessible name of the button that shows or hides a row's details" + }, + "uic.DataGrid.pagination": { + "defaultMessage": "Хуудаслалт", + "description": "Accessible name of a data grid's page navigation" + }, + "uic.DataGrid.range": { + "defaultMessage": "{start} - {end}, нийт {total} зүйл", + "description": "Beside a data grid's page navigation: the rows on screen out of the total. {start} and {end} are 1-based row numbers" + }, + "uic.DataGrid.settings": { + "defaultMessage": "Хүснэгтийн тохиргоо", + "description": "Button under a data grid that opens its column settings, and the title of that dialog" + }, + "uic.DataGrid.exportCsv": { + "defaultMessage": "CSV экспортлах", + "description": "Button under a data grid that opens the CSV export dialog, and the title of that dialog" + }, + "uic.DataGrid.selectColumns": { + "defaultMessage": "Дэлгэцийн баганыг сонгоно уу", + "description": "Subtitle of a data grid's column settings and CSV export dialogs" + }, + "uic.DataGrid.searchColumns": { + "defaultMessage": "Хүснэгтийн баганыг хайх", + "description": "Placeholder of the column search field in a data grid's dialogs, also shown when no column matches" + }, + "uic.DataGrid.export": { + "defaultMessage": "Экспорт", + "description": "Primary button of a data grid's CSV export dialog" } } diff --git a/src/i18n/locales/ms-MY.json b/src/i18n/locales/ms-MY.json index dc75c95..a4e7560 100644 --- a/src/i18n/locales/ms-MY.json +++ b/src/i18n/locales/ms-MY.json @@ -11,6 +11,10 @@ "defaultMessage": "Padam", "description": "Generic label of a button that deletes something" }, + "uic.common.apply": { + "defaultMessage": "Guna", + "description": "Generic label of a button that applies the changes made in a dialog" + }, "uic.ColorPicker.label": { "defaultMessage": "Pilih warna", "description": "Accessible name of a color picker whose caller gave it no label" @@ -194,5 +198,57 @@ "uic.BulkEditFormItem.undoChanges": { "defaultMessage": "Batal perubahan", "description": "Link that returns a bulk-edit field to keeping the current values" + }, + "uic.BulkErrorModal.title": { + "defaultMessage": "Pelaksanaan gagal", + "description": "Default title of the dialog that lists the failed items of a bulk operation" + }, + "uic.BulkErrorModal.errorOccurred": { + "defaultMessage": "Ralat berlaku", + "description": "Title of the error banner above the failed items, over the caller's guidance" + }, + "uic.DataGrid.noData": { + "defaultMessage": "Tiada data untuk dipaparkan", + "description": "Shown in place of a data grid's rows when there are none" + }, + "uic.DataGrid.invalidPage": { + "defaultMessage": "Nombor halaman tidak sah", + "description": "Shown in place of a data grid's rows when the current page lies past the last page" + }, + "uic.DataGrid.goToFirstPage": { + "defaultMessage": "Pergi ke halaman pertama", + "description": "Button under the invalid page message that returns to page 1" + }, + "uic.DataGrid.expandRow": { + "defaultMessage": "Kembangkan baris", + "description": "Accessible name of the button that shows or hides a row's details" + }, + "uic.DataGrid.pagination": { + "defaultMessage": "Penomboran halaman", + "description": "Accessible name of a data grid's page navigation" + }, + "uic.DataGrid.range": { + "defaultMessage": "{start} - {end} dari {total} item", + "description": "Beside a data grid's page navigation: the rows on screen out of the total. {start} and {end} are 1-based row numbers" + }, + "uic.DataGrid.settings": { + "defaultMessage": "Tetapan Jadual", + "description": "Button under a data grid that opens its column settings, and the title of that dialog" + }, + "uic.DataGrid.exportCsv": { + "defaultMessage": "Eksport CSV", + "description": "Button under a data grid that opens the CSV export dialog, and the title of that dialog" + }, + "uic.DataGrid.selectColumns": { + "defaultMessage": "Pilih lajur untuk dipaparkan", + "description": "Subtitle of a data grid's column settings and CSV export dialogs" + }, + "uic.DataGrid.searchColumns": { + "defaultMessage": "Lajur jadual carian", + "description": "Placeholder of the column search field in a data grid's dialogs, also shown when no column matches" + }, + "uic.DataGrid.export": { + "defaultMessage": "Eksport", + "description": "Primary button of a data grid's CSV export dialog" } } diff --git a/src/i18n/locales/pl-PL.json b/src/i18n/locales/pl-PL.json index 3b0f891..000a938 100644 --- a/src/i18n/locales/pl-PL.json +++ b/src/i18n/locales/pl-PL.json @@ -11,6 +11,10 @@ "defaultMessage": "Usuwać", "description": "Generic label of a button that deletes something" }, + "uic.common.apply": { + "defaultMessage": "Zastosuj", + "description": "Generic label of a button that applies the changes made in a dialog" + }, "uic.ColorPicker.label": { "defaultMessage": "Wybierz kolor", "description": "Accessible name of a color picker whose caller gave it no label" @@ -194,5 +198,57 @@ "uic.BulkEditFormItem.undoChanges": { "defaultMessage": "Cofnij zmiany", "description": "Link that returns a bulk-edit field to keeping the current values" + }, + "uic.BulkErrorModal.title": { + "defaultMessage": "Wykonanie nie powiodło się", + "description": "Default title of the dialog that lists the failed items of a bulk operation" + }, + "uic.BulkErrorModal.errorOccurred": { + "defaultMessage": "Wystąpił błąd", + "description": "Title of the error banner above the failed items, over the caller's guidance" + }, + "uic.DataGrid.noData": { + "defaultMessage": "Brak danych do wyświetlenia", + "description": "Shown in place of a data grid's rows when there are none" + }, + "uic.DataGrid.invalidPage": { + "defaultMessage": "Nieprawidłowy numer strony", + "description": "Shown in place of a data grid's rows when the current page lies past the last page" + }, + "uic.DataGrid.goToFirstPage": { + "defaultMessage": "Przejdź do pierwszej strony", + "description": "Button under the invalid page message that returns to page 1" + }, + "uic.DataGrid.expandRow": { + "defaultMessage": "Rozwiń wiersz", + "description": "Accessible name of the button that shows or hides a row's details" + }, + "uic.DataGrid.pagination": { + "defaultMessage": "Paginacja", + "description": "Accessible name of a data grid's page navigation" + }, + "uic.DataGrid.range": { + "defaultMessage": "{start} - {end} z {total}", + "description": "Beside a data grid's page navigation: the rows on screen out of the total. {start} and {end} are 1-based row numbers" + }, + "uic.DataGrid.settings": { + "defaultMessage": "Ustawienia tabeli", + "description": "Button under a data grid that opens its column settings, and the title of that dialog" + }, + "uic.DataGrid.exportCsv": { + "defaultMessage": "Eksportuj do CSV", + "description": "Button under a data grid that opens the CSV export dialog, and the title of that dialog" + }, + "uic.DataGrid.selectColumns": { + "defaultMessage": "Wybierz kolumny do wyświetlenia", + "description": "Subtitle of a data grid's column settings and CSV export dialogs" + }, + "uic.DataGrid.searchColumns": { + "defaultMessage": "Wyszukaj kolumny tabeli", + "description": "Placeholder of the column search field in a data grid's dialogs, also shown when no column matches" + }, + "uic.DataGrid.export": { + "defaultMessage": "Eksportuj", + "description": "Primary button of a data grid's CSV export dialog" } } diff --git a/src/i18n/locales/pt-BR.json b/src/i18n/locales/pt-BR.json index 5e96e79..be2617a 100644 --- a/src/i18n/locales/pt-BR.json +++ b/src/i18n/locales/pt-BR.json @@ -11,6 +11,10 @@ "defaultMessage": "Excluir", "description": "Generic label of a button that deletes something" }, + "uic.common.apply": { + "defaultMessage": "Aplicar", + "description": "Generic label of a button that applies the changes made in a dialog" + }, "uic.ColorPicker.label": { "defaultMessage": "Selecionar cor", "description": "Accessible name of a color picker whose caller gave it no label" @@ -194,5 +198,57 @@ "uic.BulkEditFormItem.undoChanges": { "defaultMessage": "Desfazer alterações", "description": "Link that returns a bulk-edit field to keeping the current values" + }, + "uic.BulkErrorModal.title": { + "defaultMessage": "Falha na execução", + "description": "Default title of the dialog that lists the failed items of a bulk operation" + }, + "uic.BulkErrorModal.errorOccurred": { + "defaultMessage": "Ocorreu um erro", + "description": "Title of the error banner above the failed items, over the caller's guidance" + }, + "uic.DataGrid.noData": { + "defaultMessage": "Nenhum dado para exibir", + "description": "Shown in place of a data grid's rows when there are none" + }, + "uic.DataGrid.invalidPage": { + "defaultMessage": "Número de página inválido", + "description": "Shown in place of a data grid's rows when the current page lies past the last page" + }, + "uic.DataGrid.goToFirstPage": { + "defaultMessage": "Ir para a primeira página", + "description": "Button under the invalid page message that returns to page 1" + }, + "uic.DataGrid.expandRow": { + "defaultMessage": "Expandir linha", + "description": "Accessible name of the button that shows or hides a row's details" + }, + "uic.DataGrid.pagination": { + "defaultMessage": "Paginação", + "description": "Accessible name of a data grid's page navigation" + }, + "uic.DataGrid.range": { + "defaultMessage": "{start} - {end} de {total} itens", + "description": "Beside a data grid's page navigation: the rows on screen out of the total. {start} and {end} are 1-based row numbers" + }, + "uic.DataGrid.settings": { + "defaultMessage": "Configurações da tabela", + "description": "Button under a data grid that opens its column settings, and the title of that dialog" + }, + "uic.DataGrid.exportCsv": { + "defaultMessage": "Exportar CSV", + "description": "Button under a data grid that opens the CSV export dialog, and the title of that dialog" + }, + "uic.DataGrid.selectColumns": { + "defaultMessage": "Selecione colunas para exibir", + "description": "Subtitle of a data grid's column settings and CSV export dialogs" + }, + "uic.DataGrid.searchColumns": { + "defaultMessage": "Pesquisar colunas da tabela", + "description": "Placeholder of the column search field in a data grid's dialogs, also shown when no column matches" + }, + "uic.DataGrid.export": { + "defaultMessage": "Exportar", + "description": "Primary button of a data grid's CSV export dialog" } } diff --git a/src/i18n/locales/pt-PT.json b/src/i18n/locales/pt-PT.json index 36dc7bc..363f8d6 100644 --- a/src/i18n/locales/pt-PT.json +++ b/src/i18n/locales/pt-PT.json @@ -11,6 +11,10 @@ "defaultMessage": "Excluir", "description": "Generic label of a button that deletes something" }, + "uic.common.apply": { + "defaultMessage": "Aplicar", + "description": "Generic label of a button that applies the changes made in a dialog" + }, "uic.ColorPicker.label": { "defaultMessage": "Selecionar cor", "description": "Accessible name of a color picker whose caller gave it no label" @@ -194,5 +198,57 @@ "uic.BulkEditFormItem.undoChanges": { "defaultMessage": "Desfazer alterações", "description": "Link that returns a bulk-edit field to keeping the current values" + }, + "uic.BulkErrorModal.title": { + "defaultMessage": "Falha na execução", + "description": "Default title of the dialog that lists the failed items of a bulk operation" + }, + "uic.BulkErrorModal.errorOccurred": { + "defaultMessage": "Ocorreu um erro", + "description": "Title of the error banner above the failed items, over the caller's guidance" + }, + "uic.DataGrid.noData": { + "defaultMessage": "Nenhum dado para apresentar", + "description": "Shown in place of a data grid's rows when there are none" + }, + "uic.DataGrid.invalidPage": { + "defaultMessage": "Número de página inválido", + "description": "Shown in place of a data grid's rows when the current page lies past the last page" + }, + "uic.DataGrid.goToFirstPage": { + "defaultMessage": "Ir para a primeira página", + "description": "Button under the invalid page message that returns to page 1" + }, + "uic.DataGrid.expandRow": { + "defaultMessage": "Expandir linha", + "description": "Accessible name of the button that shows or hides a row's details" + }, + "uic.DataGrid.pagination": { + "defaultMessage": "Paginação", + "description": "Accessible name of a data grid's page navigation" + }, + "uic.DataGrid.range": { + "defaultMessage": "{start} - {end} de {total} itens", + "description": "Beside a data grid's page navigation: the rows on screen out of the total. {start} and {end} are 1-based row numbers" + }, + "uic.DataGrid.settings": { + "defaultMessage": "Configurações da tabela", + "description": "Button under a data grid that opens its column settings, and the title of that dialog" + }, + "uic.DataGrid.exportCsv": { + "defaultMessage": "Exportar CSV", + "description": "Button under a data grid that opens the CSV export dialog, and the title of that dialog" + }, + "uic.DataGrid.selectColumns": { + "defaultMessage": "Selecione colunas para exibir", + "description": "Subtitle of a data grid's column settings and CSV export dialogs" + }, + "uic.DataGrid.searchColumns": { + "defaultMessage": "Pesquisar colunas da tabela", + "description": "Placeholder of the column search field in a data grid's dialogs, also shown when no column matches" + }, + "uic.DataGrid.export": { + "defaultMessage": "Exportar", + "description": "Primary button of a data grid's CSV export dialog" } } diff --git a/src/i18n/locales/ru-RU.json b/src/i18n/locales/ru-RU.json index 87e6e13..7884516 100644 --- a/src/i18n/locales/ru-RU.json +++ b/src/i18n/locales/ru-RU.json @@ -11,6 +11,10 @@ "defaultMessage": "Удалить", "description": "Generic label of a button that deletes something" }, + "uic.common.apply": { + "defaultMessage": "Применить", + "description": "Generic label of a button that applies the changes made in a dialog" + }, "uic.ColorPicker.label": { "defaultMessage": "Выбрать цвет", "description": "Accessible name of a color picker whose caller gave it no label" @@ -194,5 +198,57 @@ "uic.BulkEditFormItem.undoChanges": { "defaultMessage": "Отменить изменения", "description": "Link that returns a bulk-edit field to keeping the current values" + }, + "uic.BulkErrorModal.title": { + "defaultMessage": "Ошибка выполнения", + "description": "Default title of the dialog that lists the failed items of a bulk operation" + }, + "uic.BulkErrorModal.errorOccurred": { + "defaultMessage": "Произошла ошибка", + "description": "Title of the error banner above the failed items, over the caller's guidance" + }, + "uic.DataGrid.noData": { + "defaultMessage": "Нет данных для отображения", + "description": "Shown in place of a data grid's rows when there are none" + }, + "uic.DataGrid.invalidPage": { + "defaultMessage": "Неверный номер страницы", + "description": "Shown in place of a data grid's rows when the current page lies past the last page" + }, + "uic.DataGrid.goToFirstPage": { + "defaultMessage": "Перейти на первую страницу", + "description": "Button under the invalid page message that returns to page 1" + }, + "uic.DataGrid.expandRow": { + "defaultMessage": "Развернуть строку", + "description": "Accessible name of the button that shows or hides a row's details" + }, + "uic.DataGrid.pagination": { + "defaultMessage": "Разбиение на страницы", + "description": "Accessible name of a data grid's page navigation" + }, + "uic.DataGrid.range": { + "defaultMessage": "{start} - {end} of {total} элементы", + "description": "Beside a data grid's page navigation: the rows on screen out of the total. {start} and {end} are 1-based row numbers" + }, + "uic.DataGrid.settings": { + "defaultMessage": "Настройки таблицы", + "description": "Button under a data grid that opens its column settings, and the title of that dialog" + }, + "uic.DataGrid.exportCsv": { + "defaultMessage": "Экспорт в CSV", + "description": "Button under a data grid that opens the CSV export dialog, and the title of that dialog" + }, + "uic.DataGrid.selectColumns": { + "defaultMessage": "Выберите столбцы, чтобы отобразить", + "description": "Subtitle of a data grid's column settings and CSV export dialogs" + }, + "uic.DataGrid.searchColumns": { + "defaultMessage": "Поиск таблицы столбцов", + "description": "Placeholder of the column search field in a data grid's dialogs, also shown when no column matches" + }, + "uic.DataGrid.export": { + "defaultMessage": "Экспорт", + "description": "Primary button of a data grid's CSV export dialog" } } diff --git a/src/i18n/locales/th-TH.json b/src/i18n/locales/th-TH.json index e209674..441dd9e 100644 --- a/src/i18n/locales/th-TH.json +++ b/src/i18n/locales/th-TH.json @@ -11,6 +11,10 @@ "defaultMessage": "ลบ", "description": "Generic label of a button that deletes something" }, + "uic.common.apply": { + "defaultMessage": "ใช้", + "description": "Generic label of a button that applies the changes made in a dialog" + }, "uic.ColorPicker.label": { "defaultMessage": "เลือกสี", "description": "Accessible name of a color picker whose caller gave it no label" @@ -194,5 +198,57 @@ "uic.BulkEditFormItem.undoChanges": { "defaultMessage": "ยกเลิกการเปลี่ยนแปลง", "description": "Link that returns a bulk-edit field to keeping the current values" + }, + "uic.BulkErrorModal.title": { + "defaultMessage": "การดำเนินการล้มเหลว", + "description": "Default title of the dialog that lists the failed items of a bulk operation" + }, + "uic.BulkErrorModal.errorOccurred": { + "defaultMessage": "เกิดข้อผิดพลาด", + "description": "Title of the error banner above the failed items, over the caller's guidance" + }, + "uic.DataGrid.noData": { + "defaultMessage": "ไม่มีข้อมูลที่จะแสดง", + "description": "Shown in place of a data grid's rows when there are none" + }, + "uic.DataGrid.invalidPage": { + "defaultMessage": "หมายเลขหน้าไม่ถูกต้อ", + "description": "Shown in place of a data grid's rows when the current page lies past the last page" + }, + "uic.DataGrid.goToFirstPage": { + "defaultMessage": "ไปยังหน้าแรก", + "description": "Button under the invalid page message that returns to page 1" + }, + "uic.DataGrid.expandRow": { + "defaultMessage": "ขยายแถว", + "description": "Accessible name of the button that shows or hides a row's details" + }, + "uic.DataGrid.pagination": { + "defaultMessage": "การแบ่งหน้า", + "description": "Accessible name of a data grid's page navigation" + }, + "uic.DataGrid.range": { + "defaultMessage": "{start} - {end} ของ {total} รายการ", + "description": "Beside a data grid's page navigation: the rows on screen out of the total. {start} and {end} are 1-based row numbers" + }, + "uic.DataGrid.settings": { + "defaultMessage": "การตั้งค่าตาราง", + "description": "Button under a data grid that opens its column settings, and the title of that dialog" + }, + "uic.DataGrid.exportCsv": { + "defaultMessage": "ส่งออก CSV", + "description": "Button under a data grid that opens the CSV export dialog, and the title of that dialog" + }, + "uic.DataGrid.selectColumns": { + "defaultMessage": "เลือกคอลัมน์ที่จะแสดง", + "description": "Subtitle of a data grid's column settings and CSV export dialogs" + }, + "uic.DataGrid.searchColumns": { + "defaultMessage": "คอลัมน์ตารางค้นหา", + "description": "Placeholder of the column search field in a data grid's dialogs, also shown when no column matches" + }, + "uic.DataGrid.export": { + "defaultMessage": "ส่งออก", + "description": "Primary button of a data grid's CSV export dialog" } } diff --git a/src/i18n/locales/tr-TR.json b/src/i18n/locales/tr-TR.json index b3c362d..10a6718 100644 --- a/src/i18n/locales/tr-TR.json +++ b/src/i18n/locales/tr-TR.json @@ -11,6 +11,10 @@ "defaultMessage": "Silmek", "description": "Generic label of a button that deletes something" }, + "uic.common.apply": { + "defaultMessage": "Uygula", + "description": "Generic label of a button that applies the changes made in a dialog" + }, "uic.ColorPicker.label": { "defaultMessage": "Renk seç", "description": "Accessible name of a color picker whose caller gave it no label" @@ -194,5 +198,57 @@ "uic.BulkEditFormItem.undoChanges": { "defaultMessage": "Değişiklikleri geri al", "description": "Link that returns a bulk-edit field to keeping the current values" + }, + "uic.BulkErrorModal.title": { + "defaultMessage": "Yürütme başarısız oldu", + "description": "Default title of the dialog that lists the failed items of a bulk operation" + }, + "uic.BulkErrorModal.errorOccurred": { + "defaultMessage": "Bir hata oluştu", + "description": "Title of the error banner above the failed items, over the caller's guidance" + }, + "uic.DataGrid.noData": { + "defaultMessage": "Görüntülenecek veri yok", + "description": "Shown in place of a data grid's rows when there are none" + }, + "uic.DataGrid.invalidPage": { + "defaultMessage": "Geçersiz sayfa numarası", + "description": "Shown in place of a data grid's rows when the current page lies past the last page" + }, + "uic.DataGrid.goToFirstPage": { + "defaultMessage": "İlk sayfaya git", + "description": "Button under the invalid page message that returns to page 1" + }, + "uic.DataGrid.expandRow": { + "defaultMessage": "Satırı genişlet", + "description": "Accessible name of the button that shows or hides a row's details" + }, + "uic.DataGrid.pagination": { + "defaultMessage": "Sayfalama", + "description": "Accessible name of a data grid's page navigation" + }, + "uic.DataGrid.range": { + "defaultMessage": "{start} - {end} öğelerinin {total}", + "description": "Beside a data grid's page navigation: the rows on screen out of the total. {start} and {end} are 1-based row numbers" + }, + "uic.DataGrid.settings": { + "defaultMessage": "Masa Ayarları", + "description": "Button under a data grid that opens its column settings, and the title of that dialog" + }, + "uic.DataGrid.exportCsv": { + "defaultMessage": "CSV Dışa Aktar", + "description": "Button under a data grid that opens the CSV export dialog, and the title of that dialog" + }, + "uic.DataGrid.selectColumns": { + "defaultMessage": "Görüntülemek için sütunları seçin", + "description": "Subtitle of a data grid's column settings and CSV export dialogs" + }, + "uic.DataGrid.searchColumns": { + "defaultMessage": "Arama Tablosu sütunları", + "description": "Placeholder of the column search field in a data grid's dialogs, also shown when no column matches" + }, + "uic.DataGrid.export": { + "defaultMessage": "Dışa aktar", + "description": "Primary button of a data grid's CSV export dialog" } } diff --git a/src/i18n/locales/vi-VN.json b/src/i18n/locales/vi-VN.json index 81b6207..3310cd4 100644 --- a/src/i18n/locales/vi-VN.json +++ b/src/i18n/locales/vi-VN.json @@ -11,6 +11,10 @@ "defaultMessage": "Xóa bỏ", "description": "Generic label of a button that deletes something" }, + "uic.common.apply": { + "defaultMessage": "Áp dụng", + "description": "Generic label of a button that applies the changes made in a dialog" + }, "uic.ColorPicker.label": { "defaultMessage": "Chọn màu", "description": "Accessible name of a color picker whose caller gave it no label" @@ -194,5 +198,57 @@ "uic.BulkEditFormItem.undoChanges": { "defaultMessage": "Hoàn tác thay đổi", "description": "Link that returns a bulk-edit field to keeping the current values" + }, + "uic.BulkErrorModal.title": { + "defaultMessage": "Thực thi thất bại", + "description": "Default title of the dialog that lists the failed items of a bulk operation" + }, + "uic.BulkErrorModal.errorOccurred": { + "defaultMessage": "Đã xảy ra lỗi", + "description": "Title of the error banner above the failed items, over the caller's guidance" + }, + "uic.DataGrid.noData": { + "defaultMessage": "Không có dữ liệu để hiển thị", + "description": "Shown in place of a data grid's rows when there are none" + }, + "uic.DataGrid.invalidPage": { + "defaultMessage": "Số trang không hợp lệ", + "description": "Shown in place of a data grid's rows when the current page lies past the last page" + }, + "uic.DataGrid.goToFirstPage": { + "defaultMessage": "Đến trang đầu", + "description": "Button under the invalid page message that returns to page 1" + }, + "uic.DataGrid.expandRow": { + "defaultMessage": "Mở rộng hàng", + "description": "Accessible name of the button that shows or hides a row's details" + }, + "uic.DataGrid.pagination": { + "defaultMessage": "Phân trang", + "description": "Accessible name of a data grid's page navigation" + }, + "uic.DataGrid.range": { + "defaultMessage": "{start} - {end} của {total}", + "description": "Beside a data grid's page navigation: the rows on screen out of the total. {start} and {end} are 1-based row numbers" + }, + "uic.DataGrid.settings": { + "defaultMessage": "Cài đặt bảng", + "description": "Button under a data grid that opens its column settings, and the title of that dialog" + }, + "uic.DataGrid.exportCsv": { + "defaultMessage": "Xuất CSV", + "description": "Button under a data grid that opens the CSV export dialog, and the title of that dialog" + }, + "uic.DataGrid.selectColumns": { + "defaultMessage": "Chọn các cột để hiển thị", + "description": "Subtitle of a data grid's column settings and CSV export dialogs" + }, + "uic.DataGrid.searchColumns": { + "defaultMessage": "Các cột bảng tìm kiếm", + "description": "Placeholder of the column search field in a data grid's dialogs, also shown when no column matches" + }, + "uic.DataGrid.export": { + "defaultMessage": "Xuất", + "description": "Primary button of a data grid's CSV export dialog" } } diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 7dad759..29cf521 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -11,6 +11,10 @@ "defaultMessage": "删除", "description": "Generic label of a button that deletes something" }, + "uic.common.apply": { + "defaultMessage": "应用", + "description": "Generic label of a button that applies the changes made in a dialog" + }, "uic.ColorPicker.label": { "defaultMessage": "选择颜色", "description": "Accessible name of a color picker whose caller gave it no label" @@ -194,5 +198,57 @@ "uic.BulkEditFormItem.undoChanges": { "defaultMessage": "撤销更改", "description": "Link that returns a bulk-edit field to keeping the current values" + }, + "uic.BulkErrorModal.title": { + "defaultMessage": "执行错误", + "description": "Default title of the dialog that lists the failed items of a bulk operation" + }, + "uic.BulkErrorModal.errorOccurred": { + "defaultMessage": "发生错误", + "description": "Title of the error banner above the failed items, over the caller's guidance" + }, + "uic.DataGrid.noData": { + "defaultMessage": "暂无数据", + "description": "Shown in place of a data grid's rows when there are none" + }, + "uic.DataGrid.invalidPage": { + "defaultMessage": "无效的页码", + "description": "Shown in place of a data grid's rows when the current page lies past the last page" + }, + "uic.DataGrid.goToFirstPage": { + "defaultMessage": "转到第一页", + "description": "Button under the invalid page message that returns to page 1" + }, + "uic.DataGrid.expandRow": { + "defaultMessage": "展开行", + "description": "Accessible name of the button that shows or hides a row's details" + }, + "uic.DataGrid.pagination": { + "defaultMessage": "分页", + "description": "Accessible name of a data grid's page navigation" + }, + "uic.DataGrid.range": { + "defaultMessage": "{start} - {end} {total}项目", + "description": "Beside a data grid's page navigation: the rows on screen out of the total. {start} and {end} are 1-based row numbers" + }, + "uic.DataGrid.settings": { + "defaultMessage": "表设置", + "description": "Button under a data grid that opens its column settings, and the title of that dialog" + }, + "uic.DataGrid.exportCsv": { + "defaultMessage": "导出 CSV", + "description": "Button under a data grid that opens the CSV export dialog, and the title of that dialog" + }, + "uic.DataGrid.selectColumns": { + "defaultMessage": "选择要显示的列", + "description": "Subtitle of a data grid's column settings and CSV export dialogs" + }, + "uic.DataGrid.searchColumns": { + "defaultMessage": "搜索表列", + "description": "Placeholder of the column search field in a data grid's dialogs, also shown when no column matches" + }, + "uic.DataGrid.export": { + "defaultMessage": "导出", + "description": "Primary button of a data grid's CSV export dialog" } } diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index bfb442f..25f5e6c 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -11,6 +11,10 @@ "defaultMessage": "刪除", "description": "Generic label of a button that deletes something" }, + "uic.common.apply": { + "defaultMessage": "套用", + "description": "Generic label of a button that applies the changes made in a dialog" + }, "uic.ColorPicker.label": { "defaultMessage": "選擇顏色", "description": "Accessible name of a color picker whose caller gave it no label" @@ -194,5 +198,57 @@ "uic.BulkEditFormItem.undoChanges": { "defaultMessage": "撤銷更改", "description": "Link that returns a bulk-edit field to keeping the current values" + }, + "uic.BulkErrorModal.title": { + "defaultMessage": "執行錯誤", + "description": "Default title of the dialog that lists the failed items of a bulk operation" + }, + "uic.BulkErrorModal.errorOccurred": { + "defaultMessage": "發生錯誤", + "description": "Title of the error banner above the failed items, over the caller's guidance" + }, + "uic.DataGrid.noData": { + "defaultMessage": "暫無資料", + "description": "Shown in place of a data grid's rows when there are none" + }, + "uic.DataGrid.invalidPage": { + "defaultMessage": "無效的頁碼", + "description": "Shown in place of a data grid's rows when the current page lies past the last page" + }, + "uic.DataGrid.goToFirstPage": { + "defaultMessage": "跳到第一頁", + "description": "Button under the invalid page message that returns to page 1" + }, + "uic.DataGrid.expandRow": { + "defaultMessage": "展開列", + "description": "Accessible name of the button that shows or hides a row's details" + }, + "uic.DataGrid.pagination": { + "defaultMessage": "分頁", + "description": "Accessible name of a data grid's page navigation" + }, + "uic.DataGrid.range": { + "defaultMessage": "{start} - {end} {total}項目", + "description": "Beside a data grid's page navigation: the rows on screen out of the total. {start} and {end} are 1-based row numbers" + }, + "uic.DataGrid.settings": { + "defaultMessage": "表設置", + "description": "Button under a data grid that opens its column settings, and the title of that dialog" + }, + "uic.DataGrid.exportCsv": { + "defaultMessage": "匯出 CSV", + "description": "Button under a data grid that opens the CSV export dialog, and the title of that dialog" + }, + "uic.DataGrid.selectColumns": { + "defaultMessage": "選擇要顯示的列", + "description": "Subtitle of a data grid's column settings and CSV export dialogs" + }, + "uic.DataGrid.searchColumns": { + "defaultMessage": "搜索表列", + "description": "Placeholder of the column search field in a data grid's dialogs, also shown when no column matches" + }, + "uic.DataGrid.export": { + "defaultMessage": "匯出", + "description": "Primary button of a data grid's CSV export dialog" } } diff --git a/src/index.ts b/src/index.ts index e0cf810..369e033 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1325,6 +1325,10 @@ export type { BooleanTokenProps } from "./components/BooleanToken"; export { BulkEditFormItem } from "./components/BulkEditFormItem"; export type { BulkEditFormItemProps } from "./components/BulkEditFormItem"; +// BulkErrorModal +export { BulkErrorModal } from "./components/BulkErrorModal"; +export type { BulkErrorModalProps } from "./components/BulkErrorModal"; + // ColorPicker export { ColorPicker, toHexColor } from "./components/ColorPicker"; export type { ColorPickerProps } from "./components/ColorPicker"; @@ -1337,6 +1341,38 @@ export type { ConfirmPopoverProps } from "./components/ConfirmPopover"; export { CountBadge } from "./components/CountBadge"; export type { CountBadgeProps } from "./components/CountBadge"; +// CountdownBorder +export { CountdownBorder } from "./components/CountdownBorder"; +export type { CountdownBorderProps } from "./components/CountdownBorder"; + +// DataGrid +export { + DataGrid, + DataGridExportModal, + DataGridSettingsModal, + dataGridColumnLabel, + isDataGridColumnVisible, +} from "./components/DataGrid"; +export type { + DataGridColumn, + DataGridColumnOverride, + DataGridColumnOverrides, + DataGridColumnSettings, + DataGridCsvExport, + DataGridExpansion, + DataGridExportColumn, + DataGridExportModalProps, + DataGridKey, + DataGridPagination, + DataGridProps, + DataGridSelection, + DataGridSettingsColumn, + DataGridSettingsModalProps, + DataGridSettingsResult, + DataGridSort, + DataGridSortDirection, +} from "./components/DataGrid"; + // DeleteConfirmModal export { DeleteConfirmModal } from "./components/DeleteConfirmModal"; export type { @@ -1356,6 +1392,14 @@ export type { DividedRowProps } from "./components/DividedRow"; export { DoubleBadge } from "./components/DoubleBadge"; export type { DoubleBadgeProps, DoubleBadgeValue } from "./components/DoubleBadge"; +// DoubleToken +export { DoubleToken } from "./components/DoubleToken"; +export type { + DoubleTokenColor, + DoubleTokenProps, + DoubleTokenValue, +} from "./components/DoubleToken"; + // ErrorState export { ErrorState } from "./components/ErrorState"; export type { ErrorAction, ErrorStateProps, ErrorTone } from "./components/ErrorState"; @@ -1417,6 +1461,10 @@ export type { IconWithTooltipProps } from "./components/IconWithTooltip"; export { ImageWithFallback } from "./components/ImageWithFallback"; export type { ImageWithFallbackProps } from "./components/ImageWithFallback"; +// ListBanner +export { ListBanner } from "./components/ListBanner"; +export type { ListBannerItem, ListBannerProps } from "./components/ListBanner"; + // Modal export { DialogHeader, @@ -1469,6 +1517,10 @@ export type { PageHeaderProps } from "./components/PageHeader"; export { PageLayout } from "./components/PageLayout"; export type { PageLayoutProps, PageLayoutVariant } from "./components/PageLayout"; +// ProgressWithLabel +export { ProgressWithLabel } from "./components/ProgressWithLabel"; +export type { ProgressWithLabelProps } from "./components/ProgressWithLabel"; + // SelectionLabel export { SelectionLabel } from "./components/SelectionLabel"; export type { SelectionLabelProps } from "./components/SelectionLabel"; @@ -1518,6 +1570,10 @@ export type { StepNumberInputProps, } from "./components/StepNumberInput"; +// TextHighlighter +export { TextHighlighter } from "./components/TextHighlighter"; +export type { TextHighlighterProps } from "./components/TextHighlighter"; + // TokenList export { TokenList } from "./components/TokenList"; export type { TokenListItem, TokenListProps } from "./components/TokenList"; From 479c39f7b5623a792036a5adb3dc02ed9dcd5e90 Mon Sep 17 00:00:00 2001 From: Jongeun <jongeun@lablup.com> Date: Tue, 29 Sep 2026 01:43:06 +0000 Subject: [PATCH 54/87] chore: prepare 0.2.0-alpha.10 --- CHANGELOG.md | 5 +++++ package.json | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 29c6f09..1e7ba87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ Versioning follows the policy in [CONTRIBUTING.md](CONTRIBUTING.md#versioning). ## [Unreleased] +## [0.2.0-alpha.10] + +backend.ai-ui's table cluster (DataGrid, its dialogs, BulkErrorModal) and +five single components, the moves its theme shim had held back. + ### Added - **Components moved from backend.ai-ui**, with Astryx-shaped props and diff --git a/package.json b/package.json index 9185fc5..fd302f0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@lablup/ui-common", - "version": "0.2.0-alpha.9", + "version": "0.2.0-alpha.10", "description": "Lablup's UI layer on Astryx: the Astryx surface re-exported 1:1, the Lablup theme, and shared components", "license": "Apache-2.0", "author": "Lablup Inc.", From 85abfd7d6a05ebec17d1c1511295004891ddb104 Mon Sep 17 00:00:00 2001 From: Jongeun <jongeun@lablup.com> Date: Tue, 29 Sep 2026 02:45:27 +0000 Subject: [PATCH 55/87] fix(i18n): use button labels for the common actions and fill known translations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit uic.common.cancel carried infinitives or the wrong word in ten locales ("Stornieren", "Cancellare", "Anulować", "İptal etmek", ...); it now uses the button labels backend.ai-ui's table had (Abbrechen, Annulla, Anuluj, İptal, ...). uic.common.delete had the same problem in el, fi, id, it, pl, tr and vi, and a wrong word in mn ("Эдгээх") and ja ("消去"); apply reads "Gunakan" in ms and "นำไปใช้" in th. uic.common.ok, uic.common.retry and the Card/Row/Text skeletons' "Loading" are filled from the translations Backend.AI WebUI already ships for the same strings. Mongolian keeps OK untranslated: the only existing string asks "is it OK?". The allowlist is now per key and locale, so a key translated in some locales cannot hide behind it in the others. --- src/i18n/locales/de-DE.json | 22 ++++++++++++++- src/i18n/locales/el-GR.json | 24 +++++++++++++++-- src/i18n/locales/es-ES.json | 20 ++++++++++++++ src/i18n/locales/fi-FI.json | 24 +++++++++++++++-- src/i18n/locales/fr-FR.json | 20 ++++++++++++++ src/i18n/locales/id-ID.json | 24 +++++++++++++++-- src/i18n/locales/it-IT.json | 24 +++++++++++++++-- src/i18n/locales/ja-JP.json | 2 +- src/i18n/locales/mn-MN.json | 20 ++++++++++++-- src/i18n/locales/ms-MY.json | 24 +++++++++++++++-- src/i18n/locales/pl-PL.json | 24 +++++++++++++++-- src/i18n/locales/pt-BR.json | 20 ++++++++++++++ src/i18n/locales/pt-PT.json | 22 ++++++++++++++- src/i18n/locales/ru-RU.json | 20 ++++++++++++++ src/i18n/locales/th-TH.json | 22 ++++++++++++++- src/i18n/locales/tr-TR.json | 24 +++++++++++++++-- src/i18n/locales/vi-VN.json | 24 +++++++++++++++-- src/i18n/locales/zh-CN.json | 20 ++++++++++++++ src/i18n/locales/zh-TW.json | 20 ++++++++++++++ src/i18n/useUicTranslator.test.tsx | 43 +++++++++++++++--------------- 20 files changed, 399 insertions(+), 44 deletions(-) diff --git a/src/i18n/locales/de-DE.json b/src/i18n/locales/de-DE.json index 7407caa..4ad9b69 100644 --- a/src/i18n/locales/de-DE.json +++ b/src/i18n/locales/de-DE.json @@ -1,6 +1,10 @@ { + "uic.common.ok": { + "defaultMessage": "OK", + "description": "Generic label of a primary action button that accepts" + }, "uic.common.cancel": { - "defaultMessage": "Stornieren", + "defaultMessage": "Abbrechen", "description": "Generic label of a button that backs out of an action or stops a task" }, "uic.common.confirm": { @@ -11,6 +15,10 @@ "defaultMessage": "Löschen", "description": "Generic label of a button that deletes something" }, + "uic.common.retry": { + "defaultMessage": "Wiederholen", + "description": "Generic label of a button that retries a failed action or load" + }, "uic.common.apply": { "defaultMessage": "Anwenden", "description": "Generic label of a button that applies the changes made in a dialog" @@ -75,6 +83,18 @@ "defaultMessage": "Alle abwählen", "description": "Button that clears the whole selection" }, + "uic.SkeletonCard.loading": { + "defaultMessage": "Wird geladen", + "description": "Accessible name of a card-shaped loading placeholder" + }, + "uic.SkeletonRow.loading": { + "defaultMessage": "Wird geladen", + "description": "Accessible name of one list-row loading placeholder" + }, + "uic.SkeletonText.loading": { + "defaultMessage": "Wird geladen", + "description": "Accessible name of a text-block loading placeholder" + }, "uic.Statistic.unlimited": { "defaultMessage": "Unbegrenzt", "description": "Shown in place of a metric's value when the value has no limit" diff --git a/src/i18n/locales/el-GR.json b/src/i18n/locales/el-GR.json index 8244a7c..1f7fb83 100644 --- a/src/i18n/locales/el-GR.json +++ b/src/i18n/locales/el-GR.json @@ -1,6 +1,10 @@ { + "uic.common.ok": { + "defaultMessage": "OK", + "description": "Generic label of a primary action button that accepts" + }, "uic.common.cancel": { - "defaultMessage": "Ματαίωση", + "defaultMessage": "Άκυρο", "description": "Generic label of a button that backs out of an action or stops a task" }, "uic.common.confirm": { @@ -8,9 +12,13 @@ "description": "Generic label of a button that confirms an action" }, "uic.common.delete": { - "defaultMessage": "Διαγράφω", + "defaultMessage": "Διαγραφή", "description": "Generic label of a button that deletes something" }, + "uic.common.retry": { + "defaultMessage": "Επανάληψη", + "description": "Generic label of a button that retries a failed action or load" + }, "uic.common.apply": { "defaultMessage": "Εφαρμογή", "description": "Generic label of a button that applies the changes made in a dialog" @@ -75,6 +83,18 @@ "defaultMessage": "Αποεπιλογή όλων", "description": "Button that clears the whole selection" }, + "uic.SkeletonCard.loading": { + "defaultMessage": "Φόρτωση", + "description": "Accessible name of a card-shaped loading placeholder" + }, + "uic.SkeletonRow.loading": { + "defaultMessage": "Φόρτωση", + "description": "Accessible name of one list-row loading placeholder" + }, + "uic.SkeletonText.loading": { + "defaultMessage": "Φόρτωση", + "description": "Accessible name of a text-block loading placeholder" + }, "uic.Statistic.unlimited": { "defaultMessage": "Απεριόριστος", "description": "Shown in place of a metric's value when the value has no limit" diff --git a/src/i18n/locales/es-ES.json b/src/i18n/locales/es-ES.json index 6d29f52..bc05bfa 100644 --- a/src/i18n/locales/es-ES.json +++ b/src/i18n/locales/es-ES.json @@ -1,4 +1,8 @@ { + "uic.common.ok": { + "defaultMessage": "OK", + "description": "Generic label of a primary action button that accepts" + }, "uic.common.cancel": { "defaultMessage": "Cancelar", "description": "Generic label of a button that backs out of an action or stops a task" @@ -11,6 +15,10 @@ "defaultMessage": "Borrar", "description": "Generic label of a button that deletes something" }, + "uic.common.retry": { + "defaultMessage": "Reintentar", + "description": "Generic label of a button that retries a failed action or load" + }, "uic.common.apply": { "defaultMessage": "Aplicar", "description": "Generic label of a button that applies the changes made in a dialog" @@ -75,6 +83,18 @@ "defaultMessage": "Deseleccionar todo", "description": "Button that clears the whole selection" }, + "uic.SkeletonCard.loading": { + "defaultMessage": "Cargando", + "description": "Accessible name of a card-shaped loading placeholder" + }, + "uic.SkeletonRow.loading": { + "defaultMessage": "Cargando", + "description": "Accessible name of one list-row loading placeholder" + }, + "uic.SkeletonText.loading": { + "defaultMessage": "Cargando", + "description": "Accessible name of a text-block loading placeholder" + }, "uic.Statistic.unlimited": { "defaultMessage": "Ilimitado", "description": "Shown in place of a metric's value when the value has no limit" diff --git a/src/i18n/locales/fi-FI.json b/src/i18n/locales/fi-FI.json index eb5bb35..a23fafe 100644 --- a/src/i18n/locales/fi-FI.json +++ b/src/i18n/locales/fi-FI.json @@ -1,6 +1,10 @@ { + "uic.common.ok": { + "defaultMessage": "OK", + "description": "Generic label of a primary action button that accepts" + }, "uic.common.cancel": { - "defaultMessage": "Peruuttaa", + "defaultMessage": "Peruuta", "description": "Generic label of a button that backs out of an action or stops a task" }, "uic.common.confirm": { @@ -8,9 +12,13 @@ "description": "Generic label of a button that confirms an action" }, "uic.common.delete": { - "defaultMessage": "Poistaa", + "defaultMessage": "Poista", "description": "Generic label of a button that deletes something" }, + "uic.common.retry": { + "defaultMessage": "Yritä uudelleen", + "description": "Generic label of a button that retries a failed action or load" + }, "uic.common.apply": { "defaultMessage": "Käytä", "description": "Generic label of a button that applies the changes made in a dialog" @@ -75,6 +83,18 @@ "defaultMessage": "Poista kaikki valinnat", "description": "Button that clears the whole selection" }, + "uic.SkeletonCard.loading": { + "defaultMessage": "Ladataan", + "description": "Accessible name of a card-shaped loading placeholder" + }, + "uic.SkeletonRow.loading": { + "defaultMessage": "Ladataan", + "description": "Accessible name of one list-row loading placeholder" + }, + "uic.SkeletonText.loading": { + "defaultMessage": "Ladataan", + "description": "Accessible name of a text-block loading placeholder" + }, "uic.Statistic.unlimited": { "defaultMessage": "Rajoittamaton", "description": "Shown in place of a metric's value when the value has no limit" diff --git a/src/i18n/locales/fr-FR.json b/src/i18n/locales/fr-FR.json index 1e91c4a..319cfe3 100644 --- a/src/i18n/locales/fr-FR.json +++ b/src/i18n/locales/fr-FR.json @@ -1,4 +1,8 @@ { + "uic.common.ok": { + "defaultMessage": "OK", + "description": "Generic label of a primary action button that accepts" + }, "uic.common.cancel": { "defaultMessage": "Annuler", "description": "Generic label of a button that backs out of an action or stops a task" @@ -11,6 +15,10 @@ "defaultMessage": "Supprimer", "description": "Generic label of a button that deletes something" }, + "uic.common.retry": { + "defaultMessage": "Réessayer", + "description": "Generic label of a button that retries a failed action or load" + }, "uic.common.apply": { "defaultMessage": "Appliquer", "description": "Generic label of a button that applies the changes made in a dialog" @@ -75,6 +83,18 @@ "defaultMessage": "Tout désélectionner", "description": "Button that clears the whole selection" }, + "uic.SkeletonCard.loading": { + "defaultMessage": "Chargement", + "description": "Accessible name of a card-shaped loading placeholder" + }, + "uic.SkeletonRow.loading": { + "defaultMessage": "Chargement", + "description": "Accessible name of one list-row loading placeholder" + }, + "uic.SkeletonText.loading": { + "defaultMessage": "Chargement", + "description": "Accessible name of a text-block loading placeholder" + }, "uic.Statistic.unlimited": { "defaultMessage": "Illimité", "description": "Shown in place of a metric's value when the value has no limit" diff --git a/src/i18n/locales/id-ID.json b/src/i18n/locales/id-ID.json index 1571645..efa7954 100644 --- a/src/i18n/locales/id-ID.json +++ b/src/i18n/locales/id-ID.json @@ -1,6 +1,10 @@ { + "uic.common.ok": { + "defaultMessage": "OK", + "description": "Generic label of a primary action button that accepts" + }, "uic.common.cancel": { - "defaultMessage": "Membatalkan", + "defaultMessage": "Batal", "description": "Generic label of a button that backs out of an action or stops a task" }, "uic.common.confirm": { @@ -8,9 +12,13 @@ "description": "Generic label of a button that confirms an action" }, "uic.common.delete": { - "defaultMessage": "Menghapus", + "defaultMessage": "Hapus", "description": "Generic label of a button that deletes something" }, + "uic.common.retry": { + "defaultMessage": "Coba lagi", + "description": "Generic label of a button that retries a failed action or load" + }, "uic.common.apply": { "defaultMessage": "Terapkan", "description": "Generic label of a button that applies the changes made in a dialog" @@ -75,6 +83,18 @@ "defaultMessage": "Batalkan semua pilihan", "description": "Button that clears the whole selection" }, + "uic.SkeletonCard.loading": { + "defaultMessage": "Memuat", + "description": "Accessible name of a card-shaped loading placeholder" + }, + "uic.SkeletonRow.loading": { + "defaultMessage": "Memuat", + "description": "Accessible name of one list-row loading placeholder" + }, + "uic.SkeletonText.loading": { + "defaultMessage": "Memuat", + "description": "Accessible name of a text-block loading placeholder" + }, "uic.Statistic.unlimited": { "defaultMessage": "Tak terbatas", "description": "Shown in place of a metric's value when the value has no limit" diff --git a/src/i18n/locales/it-IT.json b/src/i18n/locales/it-IT.json index c47f5dd..ee54794 100644 --- a/src/i18n/locales/it-IT.json +++ b/src/i18n/locales/it-IT.json @@ -1,6 +1,10 @@ { + "uic.common.ok": { + "defaultMessage": "OK", + "description": "Generic label of a primary action button that accepts" + }, "uic.common.cancel": { - "defaultMessage": "Cancellare", + "defaultMessage": "Annulla", "description": "Generic label of a button that backs out of an action or stops a task" }, "uic.common.confirm": { @@ -8,9 +12,13 @@ "description": "Generic label of a button that confirms an action" }, "uic.common.delete": { - "defaultMessage": "Eliminare", + "defaultMessage": "Elimina", "description": "Generic label of a button that deletes something" }, + "uic.common.retry": { + "defaultMessage": "Riprova", + "description": "Generic label of a button that retries a failed action or load" + }, "uic.common.apply": { "defaultMessage": "Applica", "description": "Generic label of a button that applies the changes made in a dialog" @@ -75,6 +83,18 @@ "defaultMessage": "Deseleziona tutto", "description": "Button that clears the whole selection" }, + "uic.SkeletonCard.loading": { + "defaultMessage": "Caricamento", + "description": "Accessible name of a card-shaped loading placeholder" + }, + "uic.SkeletonRow.loading": { + "defaultMessage": "Caricamento", + "description": "Accessible name of one list-row loading placeholder" + }, + "uic.SkeletonText.loading": { + "defaultMessage": "Caricamento", + "description": "Accessible name of a text-block loading placeholder" + }, "uic.Statistic.unlimited": { "defaultMessage": "Illimitato", "description": "Shown in place of a metric's value when the value has no limit" diff --git a/src/i18n/locales/ja-JP.json b/src/i18n/locales/ja-JP.json index 5d856e6..042c571 100644 --- a/src/i18n/locales/ja-JP.json +++ b/src/i18n/locales/ja-JP.json @@ -12,7 +12,7 @@ "description": "Generic label of a button that confirms an action" }, "uic.common.delete": { - "defaultMessage": "消去", + "defaultMessage": "削除", "description": "Generic label of a button that deletes something" }, "uic.common.retry": { diff --git a/src/i18n/locales/mn-MN.json b/src/i18n/locales/mn-MN.json index 4147cd0..4d54887 100644 --- a/src/i18n/locales/mn-MN.json +++ b/src/i18n/locales/mn-MN.json @@ -1,6 +1,6 @@ { "uic.common.cancel": { - "defaultMessage": "Цуаах", + "defaultMessage": "Цуцлах", "description": "Generic label of a button that backs out of an action or stops a task" }, "uic.common.confirm": { @@ -8,9 +8,13 @@ "description": "Generic label of a button that confirms an action" }, "uic.common.delete": { - "defaultMessage": "Эдгээх", + "defaultMessage": "Устгах", "description": "Generic label of a button that deletes something" }, + "uic.common.retry": { + "defaultMessage": "Дахин оролдох", + "description": "Generic label of a button that retries a failed action or load" + }, "uic.common.apply": { "defaultMessage": "Хэрэглэх", "description": "Generic label of a button that applies the changes made in a dialog" @@ -75,6 +79,18 @@ "defaultMessage": "Бүгдийг сонголтоос хасах", "description": "Button that clears the whole selection" }, + "uic.SkeletonCard.loading": { + "defaultMessage": "Ачаалж байна", + "description": "Accessible name of a card-shaped loading placeholder" + }, + "uic.SkeletonRow.loading": { + "defaultMessage": "Ачаалж байна", + "description": "Accessible name of one list-row loading placeholder" + }, + "uic.SkeletonText.loading": { + "defaultMessage": "Ачаалж байна", + "description": "Accessible name of a text-block loading placeholder" + }, "uic.Statistic.unlimited": { "defaultMessage": "Хязгааргүй", "description": "Shown in place of a metric's value when the value has no limit" diff --git a/src/i18n/locales/ms-MY.json b/src/i18n/locales/ms-MY.json index a4e7560..401fca8 100644 --- a/src/i18n/locales/ms-MY.json +++ b/src/i18n/locales/ms-MY.json @@ -1,6 +1,10 @@ { + "uic.common.ok": { + "defaultMessage": "OK", + "description": "Generic label of a primary action button that accepts" + }, "uic.common.cancel": { - "defaultMessage": "Batalkan", + "defaultMessage": "Batal", "description": "Generic label of a button that backs out of an action or stops a task" }, "uic.common.confirm": { @@ -11,8 +15,12 @@ "defaultMessage": "Padam", "description": "Generic label of a button that deletes something" }, + "uic.common.retry": { + "defaultMessage": "Cuba semula", + "description": "Generic label of a button that retries a failed action or load" + }, "uic.common.apply": { - "defaultMessage": "Guna", + "defaultMessage": "Gunakan", "description": "Generic label of a button that applies the changes made in a dialog" }, "uic.ColorPicker.label": { @@ -75,6 +83,18 @@ "defaultMessage": "Nyahpilih semua", "description": "Button that clears the whole selection" }, + "uic.SkeletonCard.loading": { + "defaultMessage": "Memuatkan", + "description": "Accessible name of a card-shaped loading placeholder" + }, + "uic.SkeletonRow.loading": { + "defaultMessage": "Memuatkan", + "description": "Accessible name of one list-row loading placeholder" + }, + "uic.SkeletonText.loading": { + "defaultMessage": "Memuatkan", + "description": "Accessible name of a text-block loading placeholder" + }, "uic.Statistic.unlimited": { "defaultMessage": "Tidak terhad", "description": "Shown in place of a metric's value when the value has no limit" diff --git a/src/i18n/locales/pl-PL.json b/src/i18n/locales/pl-PL.json index 000a938..f5f6586 100644 --- a/src/i18n/locales/pl-PL.json +++ b/src/i18n/locales/pl-PL.json @@ -1,6 +1,10 @@ { + "uic.common.ok": { + "defaultMessage": "OK", + "description": "Generic label of a primary action button that accepts" + }, "uic.common.cancel": { - "defaultMessage": "Anulować", + "defaultMessage": "Anuluj", "description": "Generic label of a button that backs out of an action or stops a task" }, "uic.common.confirm": { @@ -8,9 +12,13 @@ "description": "Generic label of a button that confirms an action" }, "uic.common.delete": { - "defaultMessage": "Usuwać", + "defaultMessage": "Usuń", "description": "Generic label of a button that deletes something" }, + "uic.common.retry": { + "defaultMessage": "Ponów", + "description": "Generic label of a button that retries a failed action or load" + }, "uic.common.apply": { "defaultMessage": "Zastosuj", "description": "Generic label of a button that applies the changes made in a dialog" @@ -75,6 +83,18 @@ "defaultMessage": "Odznacz wszystko", "description": "Button that clears the whole selection" }, + "uic.SkeletonCard.loading": { + "defaultMessage": "Ładowanie", + "description": "Accessible name of a card-shaped loading placeholder" + }, + "uic.SkeletonRow.loading": { + "defaultMessage": "Ładowanie", + "description": "Accessible name of one list-row loading placeholder" + }, + "uic.SkeletonText.loading": { + "defaultMessage": "Ładowanie", + "description": "Accessible name of a text-block loading placeholder" + }, "uic.Statistic.unlimited": { "defaultMessage": "Nieograniczony", "description": "Shown in place of a metric's value when the value has no limit" diff --git a/src/i18n/locales/pt-BR.json b/src/i18n/locales/pt-BR.json index be2617a..6acaa5c 100644 --- a/src/i18n/locales/pt-BR.json +++ b/src/i18n/locales/pt-BR.json @@ -1,4 +1,8 @@ { + "uic.common.ok": { + "defaultMessage": "OK", + "description": "Generic label of a primary action button that accepts" + }, "uic.common.cancel": { "defaultMessage": "Cancelar", "description": "Generic label of a button that backs out of an action or stops a task" @@ -11,6 +15,10 @@ "defaultMessage": "Excluir", "description": "Generic label of a button that deletes something" }, + "uic.common.retry": { + "defaultMessage": "Tentar novamente", + "description": "Generic label of a button that retries a failed action or load" + }, "uic.common.apply": { "defaultMessage": "Aplicar", "description": "Generic label of a button that applies the changes made in a dialog" @@ -75,6 +83,18 @@ "defaultMessage": "Desmarcar tudo", "description": "Button that clears the whole selection" }, + "uic.SkeletonCard.loading": { + "defaultMessage": "Carregando", + "description": "Accessible name of a card-shaped loading placeholder" + }, + "uic.SkeletonRow.loading": { + "defaultMessage": "Carregando", + "description": "Accessible name of one list-row loading placeholder" + }, + "uic.SkeletonText.loading": { + "defaultMessage": "Carregando", + "description": "Accessible name of a text-block loading placeholder" + }, "uic.Statistic.unlimited": { "defaultMessage": "Ilimitado", "description": "Shown in place of a metric's value when the value has no limit" diff --git a/src/i18n/locales/pt-PT.json b/src/i18n/locales/pt-PT.json index 363f8d6..c4b36c2 100644 --- a/src/i18n/locales/pt-PT.json +++ b/src/i18n/locales/pt-PT.json @@ -1,4 +1,8 @@ { + "uic.common.ok": { + "defaultMessage": "OK", + "description": "Generic label of a primary action button that accepts" + }, "uic.common.cancel": { "defaultMessage": "Cancelar", "description": "Generic label of a button that backs out of an action or stops a task" @@ -8,9 +12,13 @@ "description": "Generic label of a button that confirms an action" }, "uic.common.delete": { - "defaultMessage": "Excluir", + "defaultMessage": "Eliminar", "description": "Generic label of a button that deletes something" }, + "uic.common.retry": { + "defaultMessage": "Tentar novamente", + "description": "Generic label of a button that retries a failed action or load" + }, "uic.common.apply": { "defaultMessage": "Aplicar", "description": "Generic label of a button that applies the changes made in a dialog" @@ -75,6 +83,18 @@ "defaultMessage": "Desmarcar tudo", "description": "Button that clears the whole selection" }, + "uic.SkeletonCard.loading": { + "defaultMessage": "A carregar", + "description": "Accessible name of a card-shaped loading placeholder" + }, + "uic.SkeletonRow.loading": { + "defaultMessage": "A carregar", + "description": "Accessible name of one list-row loading placeholder" + }, + "uic.SkeletonText.loading": { + "defaultMessage": "A carregar", + "description": "Accessible name of a text-block loading placeholder" + }, "uic.Statistic.unlimited": { "defaultMessage": "Ilimitado", "description": "Shown in place of a metric's value when the value has no limit" diff --git a/src/i18n/locales/ru-RU.json b/src/i18n/locales/ru-RU.json index 7884516..882082b 100644 --- a/src/i18n/locales/ru-RU.json +++ b/src/i18n/locales/ru-RU.json @@ -1,4 +1,8 @@ { + "uic.common.ok": { + "defaultMessage": "OK", + "description": "Generic label of a primary action button that accepts" + }, "uic.common.cancel": { "defaultMessage": "Отмена", "description": "Generic label of a button that backs out of an action or stops a task" @@ -11,6 +15,10 @@ "defaultMessage": "Удалить", "description": "Generic label of a button that deletes something" }, + "uic.common.retry": { + "defaultMessage": "Повторить", + "description": "Generic label of a button that retries a failed action or load" + }, "uic.common.apply": { "defaultMessage": "Применить", "description": "Generic label of a button that applies the changes made in a dialog" @@ -75,6 +83,18 @@ "defaultMessage": "Снять выделение", "description": "Button that clears the whole selection" }, + "uic.SkeletonCard.loading": { + "defaultMessage": "Загрузка", + "description": "Accessible name of a card-shaped loading placeholder" + }, + "uic.SkeletonRow.loading": { + "defaultMessage": "Загрузка", + "description": "Accessible name of one list-row loading placeholder" + }, + "uic.SkeletonText.loading": { + "defaultMessage": "Загрузка", + "description": "Accessible name of a text-block loading placeholder" + }, "uic.Statistic.unlimited": { "defaultMessage": "Неограниченный", "description": "Shown in place of a metric's value when the value has no limit" diff --git a/src/i18n/locales/th-TH.json b/src/i18n/locales/th-TH.json index 441dd9e..2ffb2a9 100644 --- a/src/i18n/locales/th-TH.json +++ b/src/i18n/locales/th-TH.json @@ -1,4 +1,8 @@ { + "uic.common.ok": { + "defaultMessage": "ตกลง", + "description": "Generic label of a primary action button that accepts" + }, "uic.common.cancel": { "defaultMessage": "ยกเลิก", "description": "Generic label of a button that backs out of an action or stops a task" @@ -11,8 +15,12 @@ "defaultMessage": "ลบ", "description": "Generic label of a button that deletes something" }, + "uic.common.retry": { + "defaultMessage": "ลองใหม่", + "description": "Generic label of a button that retries a failed action or load" + }, "uic.common.apply": { - "defaultMessage": "ใช้", + "defaultMessage": "นำไปใช้", "description": "Generic label of a button that applies the changes made in a dialog" }, "uic.ColorPicker.label": { @@ -75,6 +83,18 @@ "defaultMessage": "ยกเลิกการเลือกทั้งหมด", "description": "Button that clears the whole selection" }, + "uic.SkeletonCard.loading": { + "defaultMessage": "กำลังโหลด", + "description": "Accessible name of a card-shaped loading placeholder" + }, + "uic.SkeletonRow.loading": { + "defaultMessage": "กำลังโหลด", + "description": "Accessible name of one list-row loading placeholder" + }, + "uic.SkeletonText.loading": { + "defaultMessage": "กำลังโหลด", + "description": "Accessible name of a text-block loading placeholder" + }, "uic.Statistic.unlimited": { "defaultMessage": "ไม่ จำกัด", "description": "Shown in place of a metric's value when the value has no limit" diff --git a/src/i18n/locales/tr-TR.json b/src/i18n/locales/tr-TR.json index 10a6718..d9a530d 100644 --- a/src/i18n/locales/tr-TR.json +++ b/src/i18n/locales/tr-TR.json @@ -1,6 +1,10 @@ { + "uic.common.ok": { + "defaultMessage": "Tamam", + "description": "Generic label of a primary action button that accepts" + }, "uic.common.cancel": { - "defaultMessage": "İptal etmek", + "defaultMessage": "İptal", "description": "Generic label of a button that backs out of an action or stops a task" }, "uic.common.confirm": { @@ -8,9 +12,13 @@ "description": "Generic label of a button that confirms an action" }, "uic.common.delete": { - "defaultMessage": "Silmek", + "defaultMessage": "Sil", "description": "Generic label of a button that deletes something" }, + "uic.common.retry": { + "defaultMessage": "Yeniden Dene", + "description": "Generic label of a button that retries a failed action or load" + }, "uic.common.apply": { "defaultMessage": "Uygula", "description": "Generic label of a button that applies the changes made in a dialog" @@ -75,6 +83,18 @@ "defaultMessage": "Tüm seçimi kaldır", "description": "Button that clears the whole selection" }, + "uic.SkeletonCard.loading": { + "defaultMessage": "Yükleniyor", + "description": "Accessible name of a card-shaped loading placeholder" + }, + "uic.SkeletonRow.loading": { + "defaultMessage": "Yükleniyor", + "description": "Accessible name of one list-row loading placeholder" + }, + "uic.SkeletonText.loading": { + "defaultMessage": "Yükleniyor", + "description": "Accessible name of a text-block loading placeholder" + }, "uic.Statistic.unlimited": { "defaultMessage": "Sınırsız", "description": "Shown in place of a metric's value when the value has no limit" diff --git a/src/i18n/locales/vi-VN.json b/src/i18n/locales/vi-VN.json index 3310cd4..de8a535 100644 --- a/src/i18n/locales/vi-VN.json +++ b/src/i18n/locales/vi-VN.json @@ -1,6 +1,10 @@ { + "uic.common.ok": { + "defaultMessage": "OK", + "description": "Generic label of a primary action button that accepts" + }, "uic.common.cancel": { - "defaultMessage": "Hủy bỏ", + "defaultMessage": "Hủy", "description": "Generic label of a button that backs out of an action or stops a task" }, "uic.common.confirm": { @@ -8,9 +12,13 @@ "description": "Generic label of a button that confirms an action" }, "uic.common.delete": { - "defaultMessage": "Xóa bỏ", + "defaultMessage": "Xóa", "description": "Generic label of a button that deletes something" }, + "uic.common.retry": { + "defaultMessage": "Thử lại", + "description": "Generic label of a button that retries a failed action or load" + }, "uic.common.apply": { "defaultMessage": "Áp dụng", "description": "Generic label of a button that applies the changes made in a dialog" @@ -75,6 +83,18 @@ "defaultMessage": "Bỏ chọn tất cả", "description": "Button that clears the whole selection" }, + "uic.SkeletonCard.loading": { + "defaultMessage": "Đang tải", + "description": "Accessible name of a card-shaped loading placeholder" + }, + "uic.SkeletonRow.loading": { + "defaultMessage": "Đang tải", + "description": "Accessible name of one list-row loading placeholder" + }, + "uic.SkeletonText.loading": { + "defaultMessage": "Đang tải", + "description": "Accessible name of a text-block loading placeholder" + }, "uic.Statistic.unlimited": { "defaultMessage": "Không giới hạn", "description": "Shown in place of a metric's value when the value has no limit" diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 29cf521..49c64b7 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -1,4 +1,8 @@ { + "uic.common.ok": { + "defaultMessage": "确定", + "description": "Generic label of a primary action button that accepts" + }, "uic.common.cancel": { "defaultMessage": "取消", "description": "Generic label of a button that backs out of an action or stops a task" @@ -11,6 +15,10 @@ "defaultMessage": "删除", "description": "Generic label of a button that deletes something" }, + "uic.common.retry": { + "defaultMessage": "重试", + "description": "Generic label of a button that retries a failed action or load" + }, "uic.common.apply": { "defaultMessage": "应用", "description": "Generic label of a button that applies the changes made in a dialog" @@ -75,6 +83,18 @@ "defaultMessage": "取消全选", "description": "Button that clears the whole selection" }, + "uic.SkeletonCard.loading": { + "defaultMessage": "加载中", + "description": "Accessible name of a card-shaped loading placeholder" + }, + "uic.SkeletonRow.loading": { + "defaultMessage": "加载中", + "description": "Accessible name of one list-row loading placeholder" + }, + "uic.SkeletonText.loading": { + "defaultMessage": "加载中", + "description": "Accessible name of a text-block loading placeholder" + }, "uic.Statistic.unlimited": { "defaultMessage": "无限", "description": "Shown in place of a metric's value when the value has no limit" diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 25f5e6c..b4e04fb 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -1,4 +1,8 @@ { + "uic.common.ok": { + "defaultMessage": "確定", + "description": "Generic label of a primary action button that accepts" + }, "uic.common.cancel": { "defaultMessage": "取消", "description": "Generic label of a button that backs out of an action or stops a task" @@ -11,6 +15,10 @@ "defaultMessage": "刪除", "description": "Generic label of a button that deletes something" }, + "uic.common.retry": { + "defaultMessage": "重試", + "description": "Generic label of a button that retries a failed action or load" + }, "uic.common.apply": { "defaultMessage": "套用", "description": "Generic label of a button that applies the changes made in a dialog" @@ -75,6 +83,18 @@ "defaultMessage": "取消全選", "description": "Button that clears the whole selection" }, + "uic.SkeletonCard.loading": { + "defaultMessage": "載入中", + "description": "Accessible name of a card-shaped loading placeholder" + }, + "uic.SkeletonRow.loading": { + "defaultMessage": "載入中", + "description": "Accessible name of one list-row loading placeholder" + }, + "uic.SkeletonText.loading": { + "defaultMessage": "載入中", + "description": "Accessible name of a text-block loading placeholder" + }, "uic.Statistic.unlimited": { "defaultMessage": "無限", "description": "Shown in place of a metric's value when the value has no limit" diff --git a/src/i18n/useUicTranslator.test.tsx b/src/i18n/useUicTranslator.test.tsx index c408f00..654fa60 100644 --- a/src/i18n/useUicTranslator.test.tsx +++ b/src/i18n/useUicTranslator.test.tsx @@ -99,25 +99,8 @@ describe("useUicTranslator", () => { */ const EXTRA_LOCALES = ["id-ID", "mn-MN", "ms-MY", "th-TH"]; -/** - * Keys with no translation yet in the `UNTRANSLATED_IN` locales: their - * components' origin had no translated string for them. Every other key is - * translated in every shipped locale file. An allowlisted pair that is - * translated fails, so the list only shrinks. - */ -const UNTRANSLATED_KEYS = [ - "uic.common.ok", - "uic.common.retry", - "uic.BooleanToken.true", - "uic.BooleanToken.false", - "uic.NotificationStack.progress", - "uic.PageHeader.dismissError", - "uic.SkeletonCard.loading", - "uic.SkeletonChart.loading", - "uic.SkeletonRow.loading", - "uic.SkeletonText.loading", -]; -const UNTRANSLATED_IN = [ +/** Every locale ui-common ships a file for but Korean and Japanese. */ +const OTHER_LOCALES = [ "de-DE", "el-GR", "es-ES", @@ -138,6 +121,22 @@ const UNTRANSLATED_IN = [ "zh-TW", ]; +/** + * Keys with no translation yet, and the locales they lack it in: no product + * that uses them had a translated string to carry over. Every other key is + * translated in every shipped locale file. An allowlisted pair that is + * translated fails, so the list only shrinks. + */ +const UNTRANSLATED: Record<string, readonly string[]> = { + // Mongolian products used "БОЛЖ БАЙНА УУ" ("is it OK?"), not a button label. + "uic.common.ok": ["mn-MN"], + "uic.BooleanToken.true": OTHER_LOCALES, + "uic.BooleanToken.false": OTHER_LOCALES, + "uic.NotificationStack.progress": OTHER_LOCALES, + "uic.PageHeader.dismissError": OTHER_LOCALES, + "uic.SkeletonChart.loading": OTHER_LOCALES, +}; + describe("ui-common catalog", () => { const LOCALES_DIR = join(__dirname, "locales"); const ASTRYX_LOCALES = new Set( @@ -176,13 +175,13 @@ describe("ui-common catalog", () => { it("ships a file for every locale it translates", () => { expect(translationFiles.map(localeOf).sort()).toEqual( - ["ja-JP", "ko-KR", ...UNTRANSLATED_IN].sort(), + ["ja-JP", "ko-KR", ...OTHER_LOCALES].sort(), ); }); it("translates every key in every file, but for the allowlist", () => { const isAllowed = (locale: string, key: string) => - UNTRANSLATED_IN.includes(locale) && UNTRANSLATED_KEYS.includes(key); + UNTRANSLATED[key]?.includes(locale) ?? false; for (const file of translationFiles) { const locale = localeOf(file); const catalog = readLocale(file); @@ -190,7 +189,7 @@ describe("ui-common catalog", () => { const missing = Object.keys(uiCommonCatalog).filter( (key) => !isTranslated(key) && !isAllowed(locale, key), ); - const stale = UNTRANSLATED_KEYS.filter( + const stale = Object.keys(UNTRANSLATED).filter( (key) => isAllowed(locale, key) && isTranslated(key), ); expect({ locale, missing, stale }).toEqual({ locale, missing: [], stale: [] }); From 272f1c7fe78cd3a73df7bc710b8863cbf7211547 Mon Sep 17 00:00:00 2001 From: Jongeun <jongeun@lablup.com> Date: Tue, 29 Sep 2026 02:45:53 +0000 Subject: [PATCH 56/87] fix(DataGridSettingsModal): name the drag handles and reorder them by keyboard The handles were focusable (dnd-kit gives them role="button" and tabIndex 0) but aria-hidden and unnamed, so keyboard users landed on an element screen readers could not see. The handle now carries uic.DataGrid.reorderColumn ("Reorder {column}") and only the grip icon is hidden. The keyboard sensor moves through the list with sortableKeyboardCoordinates (Space, arrow keys, Space; Escape cancels), next to the pointer sensor, and dnd-kit's screen reader instructions and live announcements come from the catalog (uic.DataGrid.reorderInstructions, reorderPickedUp, reorderMoved, reorderDropped, reorderCancelled) with the column's label and position, not its key. All six keys are translated in every shipped locale. --- src/components/DataGrid/DataGrid.messages.ts | 32 ++++++++ .../DataGrid/DataGridModals.test.tsx | 33 +++++++++ .../DataGrid/DataGridSettingsModal.tsx | 74 +++++++++++++++++-- src/i18n/locales/de-DE.json | 24 ++++++ src/i18n/locales/el-GR.json | 24 ++++++ src/i18n/locales/es-ES.json | 24 ++++++ src/i18n/locales/fi-FI.json | 24 ++++++ src/i18n/locales/fr-FR.json | 24 ++++++ src/i18n/locales/id-ID.json | 24 ++++++ src/i18n/locales/it-IT.json | 24 ++++++ src/i18n/locales/ja-JP.json | 24 ++++++ src/i18n/locales/ko-KR.json | 24 ++++++ src/i18n/locales/mn-MN.json | 24 ++++++ src/i18n/locales/ms-MY.json | 24 ++++++ src/i18n/locales/pl-PL.json | 24 ++++++ src/i18n/locales/pt-BR.json | 24 ++++++ src/i18n/locales/pt-PT.json | 24 ++++++ src/i18n/locales/ru-RU.json | 24 ++++++ src/i18n/locales/th-TH.json | 24 ++++++ src/i18n/locales/tr-TR.json | 24 ++++++ src/i18n/locales/vi-VN.json | 24 ++++++ src/i18n/locales/zh-CN.json | 24 ++++++ src/i18n/locales/zh-TW.json | 24 ++++++ 23 files changed, 613 insertions(+), 6 deletions(-) diff --git a/src/components/DataGrid/DataGrid.messages.ts b/src/components/DataGrid/DataGrid.messages.ts index ff600b2..2bf094d 100644 --- a/src/components/DataGrid/DataGrid.messages.ts +++ b/src/components/DataGrid/DataGrid.messages.ts @@ -46,6 +46,38 @@ export const dataGridMessages = defineMessages({ description: "Placeholder of the column search field in a data grid's dialogs, also shown when no column matches", }, + "uic.DataGrid.reorderColumn": { + defaultMessage: "Reorder {column}", + description: + "Accessible name of the drag handle beside a column in a data grid's column settings dialog. {column} is the column's label", + }, + "uic.DataGrid.reorderInstructions": { + defaultMessage: + "Press Space to pick up the column, use the arrow keys to move it, then press Space again to drop it or Escape to cancel.", + description: + "Screen reader instructions for a drag handle in a data grid's column settings dialog", + }, + "uic.DataGrid.reorderPickedUp": { + defaultMessage: "Picked up {column}. It is at position {position} of {total}.", + description: + "Screen reader announcement when a column is picked up for reordering in the column settings dialog. {position} is 1-based", + }, + "uic.DataGrid.reorderMoved": { + defaultMessage: "{column} moved to position {position} of {total}.", + description: + "Screen reader announcement while a picked-up column moves in the column settings dialog. {position} is 1-based", + }, + "uic.DataGrid.reorderDropped": { + defaultMessage: "{column} dropped at position {position} of {total}.", + description: + "Screen reader announcement when a column is dropped in the column settings dialog. {position} is 1-based", + }, + "uic.DataGrid.reorderCancelled": { + defaultMessage: + "Reordering cancelled. {column} is back at position {position} of {total}.", + description: + "Screen reader announcement when reordering a column is cancelled in the column settings dialog. {position} is 1-based", + }, "uic.DataGrid.export": { defaultMessage: "Export", description: "Primary button of a data grid's CSV export dialog", diff --git a/src/components/DataGrid/DataGridModals.test.tsx b/src/components/DataGrid/DataGridModals.test.tsx index 6b9d24d..0f03b4f 100644 --- a/src/components/DataGrid/DataGridModals.test.tsx +++ b/src/components/DataGrid/DataGridModals.test.tsx @@ -98,6 +98,38 @@ describe("DataGridSettingsModal", () => { ); }); + it("names each drag handle and keeps it in the accessibility tree", () => { + renderSettings(); + const handle = screen.getByRole("button", { name: "Reorder Size" }); + expect(handle).toHaveClass("uic-data-grid-dialog__handle"); + expect(handle).not.toHaveAttribute("aria-hidden"); + expect(handle).toHaveAttribute("tabindex", "0"); + expect(screen.getByRole("button", { name: "Reorder Name" })).toBeInTheDocument(); + }); + + it("describes keyboard reordering from the catalog", () => { + renderSettings(); + const handle = screen.getByRole("button", { name: "Reorder Size" }); + expect(handle).toHaveAccessibleDescription( + "Press Space to pick up the column, use the arrow keys to move it, then press Space again to drop it or Escape to cancel.", + ); + }); + + it("announces a keyboard pick-up with the column's position", async () => { + renderSettings(); + screen.getByRole("button", { name: "Reorder Owner" }).focus(); + await userEvent.keyboard(" "); + expect( + await screen.findByText("Picked up Owner. It is at position 2 of 3."), + ).toBeInTheDocument(); + await userEvent.keyboard("{Escape}"); + expect( + await screen.findByText( + "Reordering cancelled. Owner is back at position 2 of 3.", + ), + ).toBeInTheDocument(); + }); + it("renders no drag handles when not reorderable", () => { renderSettings({ isReorderable: false }); expect(handles(screen.getByRole("dialog"))).toHaveLength(0); @@ -133,6 +165,7 @@ describe("DataGridSettingsModal", () => { ); expect(screen.getByRole("dialog")).toHaveAccessibleName("표 설정"); expect(screen.getByRole("button", { name: "적용" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Name 순서 변경" })).toBeInTheDocument(); }); }); diff --git a/src/components/DataGrid/DataGridSettingsModal.tsx b/src/components/DataGrid/DataGridSettingsModal.tsx index 58b5f17..19c93dd 100644 --- a/src/components/DataGrid/DataGridSettingsModal.tsx +++ b/src/components/DataGrid/DataGridSettingsModal.tsx @@ -2,7 +2,8 @@ * DataGridSettingsModal * * Chooses which columns a grid shows, and in what order: a searchable list of - * checkboxes with drag handles. Columns marked `isAlwaysVisible` stay checked. + * checkboxes with drag handles (pointer, or keyboard: Space, arrow keys, + * Space). Columns marked `isAlwaysVisible` stay checked. * Dragging is off while a search narrows the list. The working set is fresh * on every open; Apply reports it and does not close the dialog. * @@ -19,11 +20,21 @@ * /> */ import { useState, type CSSProperties, type ReactElement, type ReactNode } from "react"; -import { DndContext, type DragEndEvent } from "@dnd-kit/core"; +import { + DndContext, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + type Announcements, + type DragEndEvent, + type UniqueIdentifier, +} from "@dnd-kit/core"; import { restrictToVerticalAxis } from "@dnd-kit/modifiers"; import { SortableContext, arrayMove, + sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy, } from "@dnd-kit/sortable"; @@ -86,10 +97,12 @@ const LIST_HEIGHT = "360px"; function SortableRow({ id, isDragDisabled, + handleLabel, children, }: { id: string; isDragDisabled: boolean; + handleLabel: string; children: ReactNode; }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = @@ -111,9 +124,9 @@ function SortableRow({ {...attributes} {...listeners} className="uic-data-grid-dialog__handle" - aria-hidden + aria-label={handleLabel} > - <GripVertical size={16} /> + <GripVertical size={16} aria-hidden /> </span> )} {children} @@ -158,6 +171,38 @@ function SettingsBody({ const searchText = searchLabel ?? t("uic.DataGrid.searchColumns"); const close = () => onOpenChange?.(false); + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), + ); + const labelOf = (key: UniqueIdentifier) => { + const column = columnByKey.get(String(key)); + return column?.label || String(key); + }; + const positionOf = (key: UniqueIdentifier) => ({ + column: labelOf(key), + position: order.indexOf(String(key)) + 1, + total: order.length, + }); + const announcements: Announcements = { + onDragStart: ({ active }) => + t("uic.DataGrid.reorderPickedUp", positionOf(active.id)), + onDragOver: ({ active, over }) => + over + ? t("uic.DataGrid.reorderMoved", { + ...positionOf(over.id), + column: labelOf(active.id), + }) + : undefined, + onDragEnd: ({ active, over }) => + t("uic.DataGrid.reorderDropped", { + ...positionOf(over?.id ?? active.id), + column: labelOf(active.id), + }), + onDragCancel: ({ active }) => + t("uic.DataGrid.reorderCancelled", positionOf(active.id)), + }; + const onDragEnd = ({ active, over }: DragEndEvent) => { if (!over || active.id === over.id) return; const from = order.indexOf(String(active.id)); @@ -172,7 +217,14 @@ function SettingsBody({ const column = columnByKey.get(key); if (!column) return null; return ( - <SortableRow key={key} id={key} isDragDisabled={isDragDisabled}> + <SortableRow + key={key} + id={key} + isDragDisabled={isDragDisabled} + handleLabel={t("uic.DataGrid.reorderColumn", { + column: column.label || key, + })} + > <CheckboxInput label={column.label || key} size="sm" @@ -229,7 +281,17 @@ function SettingsBody({ {isDragDisabled ? ( list ) : ( - <DndContext modifiers={[restrictToVerticalAxis]} onDragEnd={onDragEnd}> + <DndContext + sensors={sensors} + modifiers={[restrictToVerticalAxis]} + accessibility={{ + announcements, + screenReaderInstructions: { + draggable: t("uic.DataGrid.reorderInstructions"), + }, + }} + onDragEnd={onDragEnd} + > <SortableContext items={shownKeys} strategy={verticalListSortingStrategy} diff --git a/src/i18n/locales/de-DE.json b/src/i18n/locales/de-DE.json index 4ad9b69..c9a93a0 100644 --- a/src/i18n/locales/de-DE.json +++ b/src/i18n/locales/de-DE.json @@ -267,6 +267,30 @@ "defaultMessage": "Suchtabellenspalten", "description": "Placeholder of the column search field in a data grid's dialogs, also shown when no column matches" }, + "uic.DataGrid.reorderColumn": { + "defaultMessage": "{column} verschieben", + "description": "Accessible name of the drag handle beside a column in a data grid's column settings dialog. {column} is the column's label" + }, + "uic.DataGrid.reorderInstructions": { + "defaultMessage": "Drücken Sie die Leertaste, um die Spalte aufzunehmen, verschieben Sie sie mit den Pfeiltasten und drücken Sie erneut die Leertaste, um sie abzulegen, oder Escape, um abzubrechen.", + "description": "Screen reader instructions for a drag handle in a data grid's column settings dialog" + }, + "uic.DataGrid.reorderPickedUp": { + "defaultMessage": "{column} aufgenommen. Position {position} von {total}.", + "description": "Screen reader announcement when a column is picked up for reordering in the column settings dialog. {position} is 1-based" + }, + "uic.DataGrid.reorderMoved": { + "defaultMessage": "{column} auf Position {position} von {total} verschoben.", + "description": "Screen reader announcement while a picked-up column moves in the column settings dialog. {position} is 1-based" + }, + "uic.DataGrid.reorderDropped": { + "defaultMessage": "{column} auf Position {position} von {total} abgelegt.", + "description": "Screen reader announcement when a column is dropped in the column settings dialog. {position} is 1-based" + }, + "uic.DataGrid.reorderCancelled": { + "defaultMessage": "Verschieben abgebrochen. {column} ist wieder auf Position {position} von {total}.", + "description": "Screen reader announcement when reordering a column is cancelled in the column settings dialog. {position} is 1-based" + }, "uic.DataGrid.export": { "defaultMessage": "Exportieren", "description": "Primary button of a data grid's CSV export dialog" diff --git a/src/i18n/locales/el-GR.json b/src/i18n/locales/el-GR.json index 1f7fb83..92dfcb4 100644 --- a/src/i18n/locales/el-GR.json +++ b/src/i18n/locales/el-GR.json @@ -267,6 +267,30 @@ "defaultMessage": "Στήλες πίνακα αναζήτησης", "description": "Placeholder of the column search field in a data grid's dialogs, also shown when no column matches" }, + "uic.DataGrid.reorderColumn": { + "defaultMessage": "Αναδιάταξη {column}", + "description": "Accessible name of the drag handle beside a column in a data grid's column settings dialog. {column} is the column's label" + }, + "uic.DataGrid.reorderInstructions": { + "defaultMessage": "Πατήστε το πλήκτρο διαστήματος για να επιλέξετε τη στήλη, μετακινήστε τη με τα βελάκια και πατήστε ξανά το πλήκτρο διαστήματος για να την αφήσετε ή το Escape για ακύρωση.", + "description": "Screen reader instructions for a drag handle in a data grid's column settings dialog" + }, + "uic.DataGrid.reorderPickedUp": { + "defaultMessage": "Επιλέχθηκε η στήλη {column}. Βρίσκεται στη θέση {position} από {total}.", + "description": "Screen reader announcement when a column is picked up for reordering in the column settings dialog. {position} is 1-based" + }, + "uic.DataGrid.reorderMoved": { + "defaultMessage": "Η στήλη {column} μετακινήθηκε στη θέση {position} από {total}.", + "description": "Screen reader announcement while a picked-up column moves in the column settings dialog. {position} is 1-based" + }, + "uic.DataGrid.reorderDropped": { + "defaultMessage": "Η στήλη {column} τοποθετήθηκε στη θέση {position} από {total}.", + "description": "Screen reader announcement when a column is dropped in the column settings dialog. {position} is 1-based" + }, + "uic.DataGrid.reorderCancelled": { + "defaultMessage": "Η αναδιάταξη ακυρώθηκε. Η στήλη {column} επέστρεψε στη θέση {position} από {total}.", + "description": "Screen reader announcement when reordering a column is cancelled in the column settings dialog. {position} is 1-based" + }, "uic.DataGrid.export": { "defaultMessage": "Εξαγωγή", "description": "Primary button of a data grid's CSV export dialog" diff --git a/src/i18n/locales/es-ES.json b/src/i18n/locales/es-ES.json index bc05bfa..7fa645f 100644 --- a/src/i18n/locales/es-ES.json +++ b/src/i18n/locales/es-ES.json @@ -267,6 +267,30 @@ "defaultMessage": "Columnas de la tabla de búsqueda", "description": "Placeholder of the column search field in a data grid's dialogs, also shown when no column matches" }, + "uic.DataGrid.reorderColumn": { + "defaultMessage": "Reordenar {column}", + "description": "Accessible name of the drag handle beside a column in a data grid's column settings dialog. {column} is the column's label" + }, + "uic.DataGrid.reorderInstructions": { + "defaultMessage": "Pulsa Espacio para tomar la columna, usa las teclas de flecha para moverla y vuelve a pulsar Espacio para soltarla o Escape para cancelar.", + "description": "Screen reader instructions for a drag handle in a data grid's column settings dialog" + }, + "uic.DataGrid.reorderPickedUp": { + "defaultMessage": "Se tomó {column}. Está en la posición {position} de {total}.", + "description": "Screen reader announcement when a column is picked up for reordering in the column settings dialog. {position} is 1-based" + }, + "uic.DataGrid.reorderMoved": { + "defaultMessage": "{column} se movió a la posición {position} de {total}.", + "description": "Screen reader announcement while a picked-up column moves in the column settings dialog. {position} is 1-based" + }, + "uic.DataGrid.reorderDropped": { + "defaultMessage": "{column} se soltó en la posición {position} de {total}.", + "description": "Screen reader announcement when a column is dropped in the column settings dialog. {position} is 1-based" + }, + "uic.DataGrid.reorderCancelled": { + "defaultMessage": "Reordenación cancelada. {column} vuelve a estar en la posición {position} de {total}.", + "description": "Screen reader announcement when reordering a column is cancelled in the column settings dialog. {position} is 1-based" + }, "uic.DataGrid.export": { "defaultMessage": "Exportar", "description": "Primary button of a data grid's CSV export dialog" diff --git a/src/i18n/locales/fi-FI.json b/src/i18n/locales/fi-FI.json index a23fafe..982f4f8 100644 --- a/src/i18n/locales/fi-FI.json +++ b/src/i18n/locales/fi-FI.json @@ -267,6 +267,30 @@ "defaultMessage": "Hakutaulukon sarakkeet", "description": "Placeholder of the column search field in a data grid's dialogs, also shown when no column matches" }, + "uic.DataGrid.reorderColumn": { + "defaultMessage": "Siirrä saraketta {column}", + "description": "Accessible name of the drag handle beside a column in a data grid's column settings dialog. {column} is the column's label" + }, + "uic.DataGrid.reorderInstructions": { + "defaultMessage": "Tartu sarakkeeseen painamalla välilyöntiä, siirrä sitä nuolinäppäimillä ja pudota se painamalla välilyöntiä uudelleen tai peruuta painamalla Escape-näppäintä.", + "description": "Screen reader instructions for a drag handle in a data grid's column settings dialog" + }, + "uic.DataGrid.reorderPickedUp": { + "defaultMessage": "Sarake {column} valittu. Se on sijainnissa {position}/{total}.", + "description": "Screen reader announcement when a column is picked up for reordering in the column settings dialog. {position} is 1-based" + }, + "uic.DataGrid.reorderMoved": { + "defaultMessage": "Sarake {column} siirretty sijaintiin {position}/{total}.", + "description": "Screen reader announcement while a picked-up column moves in the column settings dialog. {position} is 1-based" + }, + "uic.DataGrid.reorderDropped": { + "defaultMessage": "Sarake {column} pudotettu sijaintiin {position}/{total}.", + "description": "Screen reader announcement when a column is dropped in the column settings dialog. {position} is 1-based" + }, + "uic.DataGrid.reorderCancelled": { + "defaultMessage": "Järjestäminen peruttu. Sarake {column} on taas sijainnissa {position}/{total}.", + "description": "Screen reader announcement when reordering a column is cancelled in the column settings dialog. {position} is 1-based" + }, "uic.DataGrid.export": { "defaultMessage": "Vie", "description": "Primary button of a data grid's CSV export dialog" diff --git a/src/i18n/locales/fr-FR.json b/src/i18n/locales/fr-FR.json index 319cfe3..acf5812 100644 --- a/src/i18n/locales/fr-FR.json +++ b/src/i18n/locales/fr-FR.json @@ -267,6 +267,30 @@ "defaultMessage": "Colonnes de table de recherche", "description": "Placeholder of the column search field in a data grid's dialogs, also shown when no column matches" }, + "uic.DataGrid.reorderColumn": { + "defaultMessage": "Réorganiser {column}", + "description": "Accessible name of the drag handle beside a column in a data grid's column settings dialog. {column} is the column's label" + }, + "uic.DataGrid.reorderInstructions": { + "defaultMessage": "Appuyez sur Espace pour saisir la colonne, utilisez les flèches pour la déplacer, puis appuyez de nouveau sur Espace pour la déposer ou sur Échap pour annuler.", + "description": "Screen reader instructions for a drag handle in a data grid's column settings dialog" + }, + "uic.DataGrid.reorderPickedUp": { + "defaultMessage": "Colonne {column} saisie. Position {position} sur {total}.", + "description": "Screen reader announcement when a column is picked up for reordering in the column settings dialog. {position} is 1-based" + }, + "uic.DataGrid.reorderMoved": { + "defaultMessage": "Colonne {column} déplacée en position {position} sur {total}.", + "description": "Screen reader announcement while a picked-up column moves in the column settings dialog. {position} is 1-based" + }, + "uic.DataGrid.reorderDropped": { + "defaultMessage": "Colonne {column} déposée en position {position} sur {total}.", + "description": "Screen reader announcement when a column is dropped in the column settings dialog. {position} is 1-based" + }, + "uic.DataGrid.reorderCancelled": { + "defaultMessage": "Réorganisation annulée. La colonne {column} est revenue en position {position} sur {total}.", + "description": "Screen reader announcement when reordering a column is cancelled in the column settings dialog. {position} is 1-based" + }, "uic.DataGrid.export": { "defaultMessage": "Exporter", "description": "Primary button of a data grid's CSV export dialog" diff --git a/src/i18n/locales/id-ID.json b/src/i18n/locales/id-ID.json index efa7954..3dd8a27 100644 --- a/src/i18n/locales/id-ID.json +++ b/src/i18n/locales/id-ID.json @@ -267,6 +267,30 @@ "defaultMessage": "Kolom Tabel Cari", "description": "Placeholder of the column search field in a data grid's dialogs, also shown when no column matches" }, + "uic.DataGrid.reorderColumn": { + "defaultMessage": "Ubah urutan {column}", + "description": "Accessible name of the drag handle beside a column in a data grid's column settings dialog. {column} is the column's label" + }, + "uic.DataGrid.reorderInstructions": { + "defaultMessage": "Tekan Spasi untuk mengambil kolom, gunakan tombol panah untuk memindahkannya, lalu tekan Spasi lagi untuk meletakkannya atau Escape untuk membatalkan.", + "description": "Screen reader instructions for a drag handle in a data grid's column settings dialog" + }, + "uic.DataGrid.reorderPickedUp": { + "defaultMessage": "Kolom {column} diambil. Posisinya {position} dari {total}.", + "description": "Screen reader announcement when a column is picked up for reordering in the column settings dialog. {position} is 1-based" + }, + "uic.DataGrid.reorderMoved": { + "defaultMessage": "Kolom {column} dipindahkan ke posisi {position} dari {total}.", + "description": "Screen reader announcement while a picked-up column moves in the column settings dialog. {position} is 1-based" + }, + "uic.DataGrid.reorderDropped": { + "defaultMessage": "Kolom {column} diletakkan di posisi {position} dari {total}.", + "description": "Screen reader announcement when a column is dropped in the column settings dialog. {position} is 1-based" + }, + "uic.DataGrid.reorderCancelled": { + "defaultMessage": "Pengubahan urutan dibatalkan. Kolom {column} kembali ke posisi {position} dari {total}.", + "description": "Screen reader announcement when reordering a column is cancelled in the column settings dialog. {position} is 1-based" + }, "uic.DataGrid.export": { "defaultMessage": "Ekspor", "description": "Primary button of a data grid's CSV export dialog" diff --git a/src/i18n/locales/it-IT.json b/src/i18n/locales/it-IT.json index ee54794..89f8565 100644 --- a/src/i18n/locales/it-IT.json +++ b/src/i18n/locales/it-IT.json @@ -267,6 +267,30 @@ "defaultMessage": "Colonne della tabella di ricerca", "description": "Placeholder of the column search field in a data grid's dialogs, also shown when no column matches" }, + "uic.DataGrid.reorderColumn": { + "defaultMessage": "Riordina {column}", + "description": "Accessible name of the drag handle beside a column in a data grid's column settings dialog. {column} is the column's label" + }, + "uic.DataGrid.reorderInstructions": { + "defaultMessage": "Premi Spazio per prendere la colonna, usa i tasti freccia per spostarla, quindi premi di nuovo Spazio per rilasciarla o Esc per annullare.", + "description": "Screen reader instructions for a drag handle in a data grid's column settings dialog" + }, + "uic.DataGrid.reorderPickedUp": { + "defaultMessage": "Colonna {column} presa. Si trova in posizione {position} di {total}.", + "description": "Screen reader announcement when a column is picked up for reordering in the column settings dialog. {position} is 1-based" + }, + "uic.DataGrid.reorderMoved": { + "defaultMessage": "Colonna {column} spostata in posizione {position} di {total}.", + "description": "Screen reader announcement while a picked-up column moves in the column settings dialog. {position} is 1-based" + }, + "uic.DataGrid.reorderDropped": { + "defaultMessage": "Colonna {column} rilasciata in posizione {position} di {total}.", + "description": "Screen reader announcement when a column is dropped in the column settings dialog. {position} is 1-based" + }, + "uic.DataGrid.reorderCancelled": { + "defaultMessage": "Riordinamento annullato. La colonna {column} è tornata in posizione {position} di {total}.", + "description": "Screen reader announcement when reordering a column is cancelled in the column settings dialog. {position} is 1-based" + }, "uic.DataGrid.export": { "defaultMessage": "Esporta", "description": "Primary button of a data grid's CSV export dialog" diff --git a/src/i18n/locales/ja-JP.json b/src/i18n/locales/ja-JP.json index 042c571..a6584c9 100644 --- a/src/i18n/locales/ja-JP.json +++ b/src/i18n/locales/ja-JP.json @@ -287,6 +287,30 @@ "defaultMessage": "テーブル列を検索します", "description": "Placeholder of the column search field in a data grid's dialogs, also shown when no column matches" }, + "uic.DataGrid.reorderColumn": { + "defaultMessage": "{column}の並べ替え", + "description": "Accessible name of the drag handle beside a column in a data grid's column settings dialog. {column} is the column's label" + }, + "uic.DataGrid.reorderInstructions": { + "defaultMessage": "Space キーで列を持ち上げ、矢印キーで移動し、もう一度 Space キーを押して配置するか、Escape キーでキャンセルします。", + "description": "Screen reader instructions for a drag handle in a data grid's column settings dialog" + }, + "uic.DataGrid.reorderPickedUp": { + "defaultMessage": "{column}を持ち上げました。現在の位置は {total} 件中 {position} 番目です。", + "description": "Screen reader announcement when a column is picked up for reordering in the column settings dialog. {position} is 1-based" + }, + "uic.DataGrid.reorderMoved": { + "defaultMessage": "{column}を {total} 件中 {position} 番目に移動しました。", + "description": "Screen reader announcement while a picked-up column moves in the column settings dialog. {position} is 1-based" + }, + "uic.DataGrid.reorderDropped": { + "defaultMessage": "{column}を {total} 件中 {position} 番目に配置しました。", + "description": "Screen reader announcement when a column is dropped in the column settings dialog. {position} is 1-based" + }, + "uic.DataGrid.reorderCancelled": { + "defaultMessage": "並べ替えをキャンセルしました。{column}は {total} 件中 {position} 番目に戻りました。", + "description": "Screen reader announcement when reordering a column is cancelled in the column settings dialog. {position} is 1-based" + }, "uic.DataGrid.export": { "defaultMessage": "エクスポート", "description": "Primary button of a data grid's CSV export dialog" diff --git a/src/i18n/locales/ko-KR.json b/src/i18n/locales/ko-KR.json index e6a6630..02cbcbf 100644 --- a/src/i18n/locales/ko-KR.json +++ b/src/i18n/locales/ko-KR.json @@ -287,6 +287,30 @@ "defaultMessage": "표시할 열 검색", "description": "Placeholder of the column search field in a data grid's dialogs, also shown when no column matches" }, + "uic.DataGrid.reorderColumn": { + "defaultMessage": "{column} 순서 변경", + "description": "Accessible name of the drag handle beside a column in a data grid's column settings dialog. {column} is the column's label" + }, + "uic.DataGrid.reorderInstructions": { + "defaultMessage": "Space 키를 눌러 열을 들어 올리고 화살표 키로 옮긴 다음, Space 키를 다시 눌러 놓거나 Escape 키를 눌러 취소하세요.", + "description": "Screen reader instructions for a drag handle in a data grid's column settings dialog" + }, + "uic.DataGrid.reorderPickedUp": { + "defaultMessage": "{column}을(를) 들어 올렸습니다. 현재 위치는 {total}개 중 {position}번째입니다.", + "description": "Screen reader announcement when a column is picked up for reordering in the column settings dialog. {position} is 1-based" + }, + "uic.DataGrid.reorderMoved": { + "defaultMessage": "{column}을(를) {total}개 중 {position}번째 위치로 옮겼습니다.", + "description": "Screen reader announcement while a picked-up column moves in the column settings dialog. {position} is 1-based" + }, + "uic.DataGrid.reorderDropped": { + "defaultMessage": "{column}을(를) {total}개 중 {position}번째 위치에 놓았습니다.", + "description": "Screen reader announcement when a column is dropped in the column settings dialog. {position} is 1-based" + }, + "uic.DataGrid.reorderCancelled": { + "defaultMessage": "순서 변경을 취소했습니다. {column}은(는) {total}개 중 {position}번째 위치로 돌아갔습니다.", + "description": "Screen reader announcement when reordering a column is cancelled in the column settings dialog. {position} is 1-based" + }, "uic.DataGrid.export": { "defaultMessage": "내보내기", "description": "Primary button of a data grid's CSV export dialog" diff --git a/src/i18n/locales/mn-MN.json b/src/i18n/locales/mn-MN.json index 4d54887..6101c74 100644 --- a/src/i18n/locales/mn-MN.json +++ b/src/i18n/locales/mn-MN.json @@ -263,6 +263,30 @@ "defaultMessage": "Хүснэгтийн баганыг хайх", "description": "Placeholder of the column search field in a data grid's dialogs, also shown when no column matches" }, + "uic.DataGrid.reorderColumn": { + "defaultMessage": "{column} баганын дарааллыг өөрчлөх", + "description": "Accessible name of the drag handle beside a column in a data grid's column settings dialog. {column} is the column's label" + }, + "uic.DataGrid.reorderInstructions": { + "defaultMessage": "Баганыг авахын тулд Space товчийг дарж, сум товчоор зөөгөөд, дахин Space товчийг дарж байрлуулна уу, эсвэл Escape товчоор цуцална уу.", + "description": "Screen reader instructions for a drag handle in a data grid's column settings dialog" + }, + "uic.DataGrid.reorderPickedUp": { + "defaultMessage": "{column} баганыг авлаа. Байрлал {position}/{total}.", + "description": "Screen reader announcement when a column is picked up for reordering in the column settings dialog. {position} is 1-based" + }, + "uic.DataGrid.reorderMoved": { + "defaultMessage": "{column} баганыг {position}/{total} байрлал руу зөөлөө.", + "description": "Screen reader announcement while a picked-up column moves in the column settings dialog. {position} is 1-based" + }, + "uic.DataGrid.reorderDropped": { + "defaultMessage": "{column} баганыг {position}/{total} байрлалд байрлууллаа.", + "description": "Screen reader announcement when a column is dropped in the column settings dialog. {position} is 1-based" + }, + "uic.DataGrid.reorderCancelled": { + "defaultMessage": "Дараалал өөрчлөхийг цуцаллаа. {column} багана {position}/{total} байрлалдаа буцлаа.", + "description": "Screen reader announcement when reordering a column is cancelled in the column settings dialog. {position} is 1-based" + }, "uic.DataGrid.export": { "defaultMessage": "Экспорт", "description": "Primary button of a data grid's CSV export dialog" diff --git a/src/i18n/locales/ms-MY.json b/src/i18n/locales/ms-MY.json index 401fca8..a627237 100644 --- a/src/i18n/locales/ms-MY.json +++ b/src/i18n/locales/ms-MY.json @@ -267,6 +267,30 @@ "defaultMessage": "Lajur jadual carian", "description": "Placeholder of the column search field in a data grid's dialogs, also shown when no column matches" }, + "uic.DataGrid.reorderColumn": { + "defaultMessage": "Susun semula {column}", + "description": "Accessible name of the drag handle beside a column in a data grid's column settings dialog. {column} is the column's label" + }, + "uic.DataGrid.reorderInstructions": { + "defaultMessage": "Tekan Space untuk mengambil lajur, gunakan kekunci anak panah untuk mengalihkannya, kemudian tekan Space sekali lagi untuk melepaskannya atau Escape untuk membatalkan.", + "description": "Screen reader instructions for a drag handle in a data grid's column settings dialog" + }, + "uic.DataGrid.reorderPickedUp": { + "defaultMessage": "Lajur {column} diambil. Kedudukannya {position} daripada {total}.", + "description": "Screen reader announcement when a column is picked up for reordering in the column settings dialog. {position} is 1-based" + }, + "uic.DataGrid.reorderMoved": { + "defaultMessage": "Lajur {column} dialihkan ke kedudukan {position} daripada {total}.", + "description": "Screen reader announcement while a picked-up column moves in the column settings dialog. {position} is 1-based" + }, + "uic.DataGrid.reorderDropped": { + "defaultMessage": "Lajur {column} dilepaskan di kedudukan {position} daripada {total}.", + "description": "Screen reader announcement when a column is dropped in the column settings dialog. {position} is 1-based" + }, + "uic.DataGrid.reorderCancelled": { + "defaultMessage": "Penyusunan semula dibatalkan. Lajur {column} kembali ke kedudukan {position} daripada {total}.", + "description": "Screen reader announcement when reordering a column is cancelled in the column settings dialog. {position} is 1-based" + }, "uic.DataGrid.export": { "defaultMessage": "Eksport", "description": "Primary button of a data grid's CSV export dialog" diff --git a/src/i18n/locales/pl-PL.json b/src/i18n/locales/pl-PL.json index f5f6586..9bf5676 100644 --- a/src/i18n/locales/pl-PL.json +++ b/src/i18n/locales/pl-PL.json @@ -267,6 +267,30 @@ "defaultMessage": "Wyszukaj kolumny tabeli", "description": "Placeholder of the column search field in a data grid's dialogs, also shown when no column matches" }, + "uic.DataGrid.reorderColumn": { + "defaultMessage": "Zmień kolejność: {column}", + "description": "Accessible name of the drag handle beside a column in a data grid's column settings dialog. {column} is the column's label" + }, + "uic.DataGrid.reorderInstructions": { + "defaultMessage": "Naciśnij spację, aby chwycić kolumnę, przesuń ją klawiszami strzałek, a następnie naciśnij ponownie spację, aby ją upuścić, lub Escape, aby anulować.", + "description": "Screen reader instructions for a drag handle in a data grid's column settings dialog" + }, + "uic.DataGrid.reorderPickedUp": { + "defaultMessage": "Chwycono kolumnę {column}. Jest na pozycji {position} z {total}.", + "description": "Screen reader announcement when a column is picked up for reordering in the column settings dialog. {position} is 1-based" + }, + "uic.DataGrid.reorderMoved": { + "defaultMessage": "Kolumna {column} przeniesiona na pozycję {position} z {total}.", + "description": "Screen reader announcement while a picked-up column moves in the column settings dialog. {position} is 1-based" + }, + "uic.DataGrid.reorderDropped": { + "defaultMessage": "Kolumna {column} upuszczona na pozycji {position} z {total}.", + "description": "Screen reader announcement when a column is dropped in the column settings dialog. {position} is 1-based" + }, + "uic.DataGrid.reorderCancelled": { + "defaultMessage": "Anulowano zmianę kolejności. Kolumna {column} wróciła na pozycję {position} z {total}.", + "description": "Screen reader announcement when reordering a column is cancelled in the column settings dialog. {position} is 1-based" + }, "uic.DataGrid.export": { "defaultMessage": "Eksportuj", "description": "Primary button of a data grid's CSV export dialog" diff --git a/src/i18n/locales/pt-BR.json b/src/i18n/locales/pt-BR.json index 6acaa5c..463f794 100644 --- a/src/i18n/locales/pt-BR.json +++ b/src/i18n/locales/pt-BR.json @@ -267,6 +267,30 @@ "defaultMessage": "Pesquisar colunas da tabela", "description": "Placeholder of the column search field in a data grid's dialogs, also shown when no column matches" }, + "uic.DataGrid.reorderColumn": { + "defaultMessage": "Reordenar {column}", + "description": "Accessible name of the drag handle beside a column in a data grid's column settings dialog. {column} is the column's label" + }, + "uic.DataGrid.reorderInstructions": { + "defaultMessage": "Pressione Espaço para pegar a coluna, use as teclas de seta para movê-la e pressione Espaço novamente para soltá-la ou Esc para cancelar.", + "description": "Screen reader instructions for a drag handle in a data grid's column settings dialog" + }, + "uic.DataGrid.reorderPickedUp": { + "defaultMessage": "Coluna {column} selecionada. Está na posição {position} de {total}.", + "description": "Screen reader announcement when a column is picked up for reordering in the column settings dialog. {position} is 1-based" + }, + "uic.DataGrid.reorderMoved": { + "defaultMessage": "Coluna {column} movida para a posição {position} de {total}.", + "description": "Screen reader announcement while a picked-up column moves in the column settings dialog. {position} is 1-based" + }, + "uic.DataGrid.reorderDropped": { + "defaultMessage": "Coluna {column} solta na posição {position} de {total}.", + "description": "Screen reader announcement when a column is dropped in the column settings dialog. {position} is 1-based" + }, + "uic.DataGrid.reorderCancelled": { + "defaultMessage": "Reordenação cancelada. A coluna {column} voltou à posição {position} de {total}.", + "description": "Screen reader announcement when reordering a column is cancelled in the column settings dialog. {position} is 1-based" + }, "uic.DataGrid.export": { "defaultMessage": "Exportar", "description": "Primary button of a data grid's CSV export dialog" diff --git a/src/i18n/locales/pt-PT.json b/src/i18n/locales/pt-PT.json index c4b36c2..c0564f8 100644 --- a/src/i18n/locales/pt-PT.json +++ b/src/i18n/locales/pt-PT.json @@ -267,6 +267,30 @@ "defaultMessage": "Pesquisar colunas da tabela", "description": "Placeholder of the column search field in a data grid's dialogs, also shown when no column matches" }, + "uic.DataGrid.reorderColumn": { + "defaultMessage": "Reordenar {column}", + "description": "Accessible name of the drag handle beside a column in a data grid's column settings dialog. {column} is the column's label" + }, + "uic.DataGrid.reorderInstructions": { + "defaultMessage": "Prima Espaço para pegar na coluna, utilize as teclas de seta para a mover e prima Espaço novamente para a largar ou Esc para cancelar.", + "description": "Screen reader instructions for a drag handle in a data grid's column settings dialog" + }, + "uic.DataGrid.reorderPickedUp": { + "defaultMessage": "Coluna {column} selecionada. Está na posição {position} de {total}.", + "description": "Screen reader announcement when a column is picked up for reordering in the column settings dialog. {position} is 1-based" + }, + "uic.DataGrid.reorderMoved": { + "defaultMessage": "Coluna {column} movida para a posição {position} de {total}.", + "description": "Screen reader announcement while a picked-up column moves in the column settings dialog. {position} is 1-based" + }, + "uic.DataGrid.reorderDropped": { + "defaultMessage": "Coluna {column} largada na posição {position} de {total}.", + "description": "Screen reader announcement when a column is dropped in the column settings dialog. {position} is 1-based" + }, + "uic.DataGrid.reorderCancelled": { + "defaultMessage": "Reordenação cancelada. A coluna {column} voltou à posição {position} de {total}.", + "description": "Screen reader announcement when reordering a column is cancelled in the column settings dialog. {position} is 1-based" + }, "uic.DataGrid.export": { "defaultMessage": "Exportar", "description": "Primary button of a data grid's CSV export dialog" diff --git a/src/i18n/locales/ru-RU.json b/src/i18n/locales/ru-RU.json index 882082b..9ac34dd 100644 --- a/src/i18n/locales/ru-RU.json +++ b/src/i18n/locales/ru-RU.json @@ -267,6 +267,30 @@ "defaultMessage": "Поиск таблицы столбцов", "description": "Placeholder of the column search field in a data grid's dialogs, also shown when no column matches" }, + "uic.DataGrid.reorderColumn": { + "defaultMessage": "Изменить порядок: {column}", + "description": "Accessible name of the drag handle beside a column in a data grid's column settings dialog. {column} is the column's label" + }, + "uic.DataGrid.reorderInstructions": { + "defaultMessage": "Нажмите пробел, чтобы взять столбец, перемещайте его клавишами со стрелками, затем снова нажмите пробел, чтобы отпустить его, или Escape для отмены.", + "description": "Screen reader instructions for a drag handle in a data grid's column settings dialog" + }, + "uic.DataGrid.reorderPickedUp": { + "defaultMessage": "Столбец {column} взят. Позиция {position} из {total}.", + "description": "Screen reader announcement when a column is picked up for reordering in the column settings dialog. {position} is 1-based" + }, + "uic.DataGrid.reorderMoved": { + "defaultMessage": "Столбец {column} перемещён на позицию {position} из {total}.", + "description": "Screen reader announcement while a picked-up column moves in the column settings dialog. {position} is 1-based" + }, + "uic.DataGrid.reorderDropped": { + "defaultMessage": "Столбец {column} помещён на позицию {position} из {total}.", + "description": "Screen reader announcement when a column is dropped in the column settings dialog. {position} is 1-based" + }, + "uic.DataGrid.reorderCancelled": { + "defaultMessage": "Изменение порядка отменено. Столбец {column} возвращён на позицию {position} из {total}.", + "description": "Screen reader announcement when reordering a column is cancelled in the column settings dialog. {position} is 1-based" + }, "uic.DataGrid.export": { "defaultMessage": "Экспорт", "description": "Primary button of a data grid's CSV export dialog" diff --git a/src/i18n/locales/th-TH.json b/src/i18n/locales/th-TH.json index 2ffb2a9..85f7b6e 100644 --- a/src/i18n/locales/th-TH.json +++ b/src/i18n/locales/th-TH.json @@ -267,6 +267,30 @@ "defaultMessage": "คอลัมน์ตารางค้นหา", "description": "Placeholder of the column search field in a data grid's dialogs, also shown when no column matches" }, + "uic.DataGrid.reorderColumn": { + "defaultMessage": "จัดลำดับ {column}", + "description": "Accessible name of the drag handle beside a column in a data grid's column settings dialog. {column} is the column's label" + }, + "uic.DataGrid.reorderInstructions": { + "defaultMessage": "กด Space เพื่อหยิบคอลัมน์ ใช้ปุ่มลูกศรเพื่อย้าย แล้วกด Space อีกครั้งเพื่อวาง หรือกด Escape เพื่อยกเลิก", + "description": "Screen reader instructions for a drag handle in a data grid's column settings dialog" + }, + "uic.DataGrid.reorderPickedUp": { + "defaultMessage": "หยิบคอลัมน์ {column} แล้ว อยู่ที่ตำแหน่ง {position} จาก {total}", + "description": "Screen reader announcement when a column is picked up for reordering in the column settings dialog. {position} is 1-based" + }, + "uic.DataGrid.reorderMoved": { + "defaultMessage": "ย้ายคอลัมน์ {column} ไปที่ตำแหน่ง {position} จาก {total}", + "description": "Screen reader announcement while a picked-up column moves in the column settings dialog. {position} is 1-based" + }, + "uic.DataGrid.reorderDropped": { + "defaultMessage": "วางคอลัมน์ {column} ที่ตำแหน่ง {position} จาก {total}", + "description": "Screen reader announcement when a column is dropped in the column settings dialog. {position} is 1-based" + }, + "uic.DataGrid.reorderCancelled": { + "defaultMessage": "ยกเลิกการจัดลำดับแล้ว คอลัมน์ {column} กลับไปที่ตำแหน่ง {position} จาก {total}", + "description": "Screen reader announcement when reordering a column is cancelled in the column settings dialog. {position} is 1-based" + }, "uic.DataGrid.export": { "defaultMessage": "ส่งออก", "description": "Primary button of a data grid's CSV export dialog" diff --git a/src/i18n/locales/tr-TR.json b/src/i18n/locales/tr-TR.json index d9a530d..9eff7d4 100644 --- a/src/i18n/locales/tr-TR.json +++ b/src/i18n/locales/tr-TR.json @@ -267,6 +267,30 @@ "defaultMessage": "Arama Tablosu sütunları", "description": "Placeholder of the column search field in a data grid's dialogs, also shown when no column matches" }, + "uic.DataGrid.reorderColumn": { + "defaultMessage": "{column} sütununu yeniden sırala", + "description": "Accessible name of the drag handle beside a column in a data grid's column settings dialog. {column} is the column's label" + }, + "uic.DataGrid.reorderInstructions": { + "defaultMessage": "Sütunu almak için Boşluk tuşuna basın, ok tuşlarıyla taşıyın, ardından bırakmak için Boşluk tuşuna yeniden basın veya iptal etmek için Escape tuşuna basın.", + "description": "Screen reader instructions for a drag handle in a data grid's column settings dialog" + }, + "uic.DataGrid.reorderPickedUp": { + "defaultMessage": "{column} sütunu alındı. Konum: {position}/{total}.", + "description": "Screen reader announcement when a column is picked up for reordering in the column settings dialog. {position} is 1-based" + }, + "uic.DataGrid.reorderMoved": { + "defaultMessage": "{column} sütunu {position}/{total} konumuna taşındı.", + "description": "Screen reader announcement while a picked-up column moves in the column settings dialog. {position} is 1-based" + }, + "uic.DataGrid.reorderDropped": { + "defaultMessage": "{column} sütunu {position}/{total} konumuna bırakıldı.", + "description": "Screen reader announcement when a column is dropped in the column settings dialog. {position} is 1-based" + }, + "uic.DataGrid.reorderCancelled": { + "defaultMessage": "Yeniden sıralama iptal edildi. {column} sütunu {position}/{total} konumuna geri döndü.", + "description": "Screen reader announcement when reordering a column is cancelled in the column settings dialog. {position} is 1-based" + }, "uic.DataGrid.export": { "defaultMessage": "Dışa aktar", "description": "Primary button of a data grid's CSV export dialog" diff --git a/src/i18n/locales/vi-VN.json b/src/i18n/locales/vi-VN.json index de8a535..52f2d1a 100644 --- a/src/i18n/locales/vi-VN.json +++ b/src/i18n/locales/vi-VN.json @@ -267,6 +267,30 @@ "defaultMessage": "Các cột bảng tìm kiếm", "description": "Placeholder of the column search field in a data grid's dialogs, also shown when no column matches" }, + "uic.DataGrid.reorderColumn": { + "defaultMessage": "Sắp xếp lại {column}", + "description": "Accessible name of the drag handle beside a column in a data grid's column settings dialog. {column} is the column's label" + }, + "uic.DataGrid.reorderInstructions": { + "defaultMessage": "Nhấn phím Cách để chọn cột, dùng các phím mũi tên để di chuyển, rồi nhấn phím Cách lần nữa để thả hoặc Escape để hủy.", + "description": "Screen reader instructions for a drag handle in a data grid's column settings dialog" + }, + "uic.DataGrid.reorderPickedUp": { + "defaultMessage": "Đã chọn cột {column}. Cột đang ở vị trí {position} trên {total}.", + "description": "Screen reader announcement when a column is picked up for reordering in the column settings dialog. {position} is 1-based" + }, + "uic.DataGrid.reorderMoved": { + "defaultMessage": "Đã chuyển cột {column} đến vị trí {position} trên {total}.", + "description": "Screen reader announcement while a picked-up column moves in the column settings dialog. {position} is 1-based" + }, + "uic.DataGrid.reorderDropped": { + "defaultMessage": "Đã thả cột {column} tại vị trí {position} trên {total}.", + "description": "Screen reader announcement when a column is dropped in the column settings dialog. {position} is 1-based" + }, + "uic.DataGrid.reorderCancelled": { + "defaultMessage": "Đã hủy sắp xếp lại. Cột {column} trở về vị trí {position} trên {total}.", + "description": "Screen reader announcement when reordering a column is cancelled in the column settings dialog. {position} is 1-based" + }, "uic.DataGrid.export": { "defaultMessage": "Xuất", "description": "Primary button of a data grid's CSV export dialog" diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 49c64b7..c3aa9ad 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -267,6 +267,30 @@ "defaultMessage": "搜索表列", "description": "Placeholder of the column search field in a data grid's dialogs, also shown when no column matches" }, + "uic.DataGrid.reorderColumn": { + "defaultMessage": "调整 {column} 的顺序", + "description": "Accessible name of the drag handle beside a column in a data grid's column settings dialog. {column} is the column's label" + }, + "uic.DataGrid.reorderInstructions": { + "defaultMessage": "按空格键拿起该列,使用方向键移动,然后再次按空格键放下,或按 Esc 键取消。", + "description": "Screen reader instructions for a drag handle in a data grid's column settings dialog" + }, + "uic.DataGrid.reorderPickedUp": { + "defaultMessage": "已拿起 {column}。当前位于第 {position} 位,共 {total} 位。", + "description": "Screen reader announcement when a column is picked up for reordering in the column settings dialog. {position} is 1-based" + }, + "uic.DataGrid.reorderMoved": { + "defaultMessage": "{column} 已移动到第 {position} 位,共 {total} 位。", + "description": "Screen reader announcement while a picked-up column moves in the column settings dialog. {position} is 1-based" + }, + "uic.DataGrid.reorderDropped": { + "defaultMessage": "{column} 已放置在第 {position} 位,共 {total} 位。", + "description": "Screen reader announcement when a column is dropped in the column settings dialog. {position} is 1-based" + }, + "uic.DataGrid.reorderCancelled": { + "defaultMessage": "已取消调整顺序。{column} 已回到第 {position} 位,共 {total} 位。", + "description": "Screen reader announcement when reordering a column is cancelled in the column settings dialog. {position} is 1-based" + }, "uic.DataGrid.export": { "defaultMessage": "导出", "description": "Primary button of a data grid's CSV export dialog" diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index b4e04fb..b8e6191 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -267,6 +267,30 @@ "defaultMessage": "搜索表列", "description": "Placeholder of the column search field in a data grid's dialogs, also shown when no column matches" }, + "uic.DataGrid.reorderColumn": { + "defaultMessage": "調整 {column} 的順序", + "description": "Accessible name of the drag handle beside a column in a data grid's column settings dialog. {column} is the column's label" + }, + "uic.DataGrid.reorderInstructions": { + "defaultMessage": "按空白鍵拿起該欄,使用方向鍵移動,然後再按一次空白鍵放下,或按 Esc 鍵取消。", + "description": "Screen reader instructions for a drag handle in a data grid's column settings dialog" + }, + "uic.DataGrid.reorderPickedUp": { + "defaultMessage": "已拿起 {column}。目前位於第 {position} 位,共 {total} 位。", + "description": "Screen reader announcement when a column is picked up for reordering in the column settings dialog. {position} is 1-based" + }, + "uic.DataGrid.reorderMoved": { + "defaultMessage": "{column} 已移動到第 {position} 位,共 {total} 位。", + "description": "Screen reader announcement while a picked-up column moves in the column settings dialog. {position} is 1-based" + }, + "uic.DataGrid.reorderDropped": { + "defaultMessage": "{column} 已放置在第 {position} 位,共 {total} 位。", + "description": "Screen reader announcement when a column is dropped in the column settings dialog. {position} is 1-based" + }, + "uic.DataGrid.reorderCancelled": { + "defaultMessage": "已取消調整順序。{column} 已回到第 {position} 位,共 {total} 位。", + "description": "Screen reader announcement when reordering a column is cancelled in the column settings dialog. {position} is 1-based" + }, "uic.DataGrid.export": { "defaultMessage": "匯出", "description": "Primary button of a data grid's CSV export dialog" From 2d9fa90e8d7b97d781d38b4ad803e756497a0e78 Mon Sep 17 00:00:00 2001 From: Jongeun <jongeun@lablup.com> Date: Tue, 29 Sep 2026 02:46:03 +0000 Subject: [PATCH 57/87] fix(DataGrid): leave disabled rows out of select-all; pass getCellProps the row index Select-all ignored selection.getIsItemEnabled: it selected rows whose checkbox is disabled, deselect-all cleared disabled rows that were selected, and the header never reached the checked state while a disabled row stayed unselected. Select-all now adds and removes enabled rows only, a disabled row keeps its state, and the header's checked and indeterminate states count enabled rows only (neither, when no row is enabled). A column's getCellProps(item, index) always received 0; it now receives the row's index on the page, as renderCell and getRowProps do. --- astryx/components/DataGrid.doc.mjs | 2 +- src/components/DataGrid/DataGrid.test.tsx | 84 +++++++++++++++++++++++ src/components/DataGrid/DataGrid.tsx | 21 ++++-- 3 files changed, 99 insertions(+), 8 deletions(-) diff --git a/astryx/components/DataGrid.doc.mjs b/astryx/components/DataGrid.doc.mjs index 05825b3..f9c1a6a 100644 --- a/astryx/components/DataGrid.doc.mjs +++ b/astryx/components/DataGrid.doc.mjs @@ -66,7 +66,7 @@ export default { name: "selection", type: "DataGridSelection<T>", description: - "selectedKeys (controlled), onChange(keys, items), getIsItemEnabled, getRowLabel, isPreservingOtherPages.", + "selectedKeys (controlled), onChange(keys, items), getIsItemEnabled (select-all leaves a disabled row as it is), getRowLabel, isPreservingOtherPages.", }, { name: "columnSettings", diff --git a/src/components/DataGrid/DataGrid.test.tsx b/src/components/DataGrid/DataGrid.test.tsx index c7640e8..4eec632 100644 --- a/src/components/DataGrid/DataGrid.test.tsx +++ b/src/components/DataGrid/DataGrid.test.tsx @@ -314,6 +314,73 @@ describe("DataGrid selection", () => { expect(screen.getByRole("checkbox", { name: "Select row-2" })).toBeEnabled(); }); + describe("with rows getIsItemEnabled rejects", () => { + const firstDisabled = (selectedKeys: string[], onChange = vi.fn()) => { + renderGrid({ + data: makeRows(3), + selection: { + selectedKeys, + onChange, + getRowLabel: (row) => row.name, + getIsItemEnabled: (row) => row.id !== "1", + }, + }); + return { + onChange, + header: screen.getByRole("checkbox", { + name: "Select all rows", + }) as HTMLInputElement, + }; + }; + + it("select-all adds only the enabled rows", async () => { + const { onChange, header } = firstDisabled([]); + await userEvent.click(header); + expect(onChange.mock.lastCall?.[0]).toEqual(["2", "3"]); + }); + + it("select-all keeps a disabled row that was already selected", async () => { + const { onChange, header } = firstDisabled(["1"]); + await userEvent.click(header); + expect(onChange.mock.lastCall?.[0]).toEqual(["1", "2", "3"]); + }); + + it("checks the header once every enabled row is selected", () => { + const { header } = firstDisabled(["2", "3"]); + expect(header.checked).toBe(true); + expect(header.indeterminate).toBe(false); + }); + + it("deselect-all keeps the disabled rows that were selected", async () => { + const { onChange, header } = firstDisabled(["1", "2", "3"]); + expect(header.checked).toBe(true); + await userEvent.click(header); + expect(onChange.mock.lastCall?.[0]).toEqual(["1"]); + }); + + it("counts only enabled rows for the indeterminate state", () => { + const { header } = firstDisabled(["1"]); + expect(header.checked).toBe(false); + expect(header.indeterminate).toBe(false); + }); + + it("is neither checked nor indeterminate when no row is enabled", () => { + renderGrid({ + data: makeRows(2), + selection: { + selectedKeys: ["1"], + getRowLabel: (row) => row.name, + getIsItemEnabled: () => false, + }, + }); + const header = screen.getByRole("checkbox", { + name: "Select all rows", + }) as HTMLInputElement; + expect(header.checked).toBe(false); + expect(header.indeterminate).toBe(false); + }); + }); + it("keeps keys from other pages on select-all only with isPreservingOtherPages", async () => { const onChange = vi.fn(); renderGrid({ @@ -457,6 +524,23 @@ describe("DataGrid chrome", () => { expect(getRowProps).toHaveBeenCalledWith(expect.objectContaining({ id: "12" }), 1); }); + it("passes the row's page index to getCellProps", () => { + const getCellProps = vi.fn(() => ({})); + renderGrid({ + data: makeRows(12), + pagination: { page: 2, pageSize: 10 }, + columns: [ + { key: "name", header: "Name", renderCell: (row) => row.name, getCellProps }, + ], + }); + expect(getCellProps).toHaveBeenCalledWith(expect.objectContaining({ id: "11" }), 0); + expect(getCellProps).toHaveBeenCalledWith(expect.objectContaining({ id: "12" }), 1); + expect(getCellProps).not.toHaveBeenCalledWith( + expect.objectContaining({ id: "12" }), + 0, + ); + }); + it("applies a column's getCellProps to its body cells", () => { const { container } = renderGrid({ data: makeRows(1), diff --git a/src/components/DataGrid/DataGrid.tsx b/src/components/DataGrid/DataGrid.tsx index 905d46c..4627dbb 100644 --- a/src/components/DataGrid/DataGrid.tsx +++ b/src/components/DataGrid/DataGrid.tsx @@ -173,7 +173,7 @@ export interface DataGridSelection<T> { /** The selected row keys. Selection is controlled. */ selectedKeys?: ReadonlyArray<DataGridKey>; onChange?: (keys: string[], items: T[]) => void; - /** Whether a row's checkbox is enabled. */ + /** Whether a row's checkbox is enabled. Select-all leaves a disabled row as it is. */ getIsItemEnabled?: (item: T) => boolean; /** Accessible name of a row's checkbox. Default: the row key. */ getRowLabel?: (item: T) => string; @@ -714,7 +714,10 @@ export function DataGrid<T extends object = AnyRow>({ const cellRowPlugin: TablePlugin<AnyRow> = { transformBodyCell: (props, column, row) => { if (isDetailRow(row)) return props; - const extra = columnByKey.get(column.key)?.getCellProps?.(row as T, 0); + const item = row as T; + const extra = columnByKey + .get(column.key) + ?.getCellProps?.(item, rowIndexByKey.get(getRowKey(item)) ?? 0); if (!extra) return props; return { ...props, @@ -787,18 +790,21 @@ export function DataGrid<T extends object = AnyRow>({ selection?.onChange?.(keys, items); }; const isRowSelected = (item: T) => selectedKeySet.has(getRowKey(item)); + const isRowEnabled = (item: T) => selection?.getIsItemEnabled?.(item) ?? true; + // Select-all acts on the enabled rows only; a disabled row keeps its state. + const enabledRows = rows.filter(isRowEnabled); const selectionPlugin = useTableSelection<AnyRow>({ getIsItemSelectable: (row) => !isDetailRow(row), getIsItemSelected: (row) => !isDetailRow(row) && isRowSelected(row as T), - getIsItemEnabled: (row) => - isDetailRow(row) ? false : (selection?.getIsItemEnabled?.(row as T) ?? true), + getIsItemEnabled: (row) => !isDetailRow(row) && isRowEnabled(row as T), getRowLabel: (row) => isDetailRow(row) ? "" : (selection?.getRowLabel?.(row as T) ?? getRowKey(row as T)), - getIsAllSelected: () => rows.length > 0 && rows.every(isRowSelected), - getIsIndeterminate: () => rows.some(isRowSelected) && !rows.every(isRowSelected), + getIsAllSelected: () => enabledRows.length > 0 && enabledRows.every(isRowSelected), + getIsIndeterminate: () => + enabledRows.some(isRowSelected) && !enabledRows.every(isRowSelected), onSelectItem: ({ item, isSelected }) => { if (isDetailRow(item)) return; const key = getRowKey(item as T); @@ -811,7 +817,8 @@ export function DataGrid<T extends object = AnyRow>({ const next = new Set(selection?.isPreservingOtherPages ? selectedKeySet : []); for (const item of rows) { const key = getRowKey(item); - if (isAllSelected) next.add(key); + const isSelected = isRowEnabled(item) ? isAllSelected : isRowSelected(item); + if (isSelected) next.add(key); else next.delete(key); } emitSelection([...next]); From 3755470051639c27e8e089f40c14b316689da8a2 Mon Sep 17 00:00:00 2001 From: Jongeun <jongeun@lablup.com> Date: Tue, 29 Sep 2026 02:46:03 +0000 Subject: [PATCH 58/87] fix: ship the MIT licence text for the Ant Design Icons path data NOTICE credited the feedback glyphs' path data to Ant Design Icons but left out the MIT permission notice the licence requires in every copy, and gave the wrong copyright years. It now carries the upstream licence verbatim (Copyright (c) 2018-present Ant UED), and check:pack fails when the packed NOTICE lacks the copyright line or the permission notice. --- NOTICE | 31 ++++++++++++++++++++++++++++--- scripts/check-pack.mjs | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 3 deletions(-) diff --git a/NOTICE b/NOTICE index d3a6508..a8076c5 100644 --- a/NOTICE +++ b/NOTICE @@ -10,6 +10,31 @@ published here, and the migration record is kept in that product's own repository. The form item's validation feedback glyphs -(src/components/Form/feedbackIcons.tsx) use path data from Ant Design Icons -(https://github.com/ant-design/ant-design-icons), Copyright (c) 2015-present -Ant UED, licensed under the MIT License. +(src/components/Form/feedbackIcons.tsx, shipped in dist/components/Form) use +path data from Ant Design Icons (https://github.com/ant-design/ant-design-icons), +licensed under the MIT License: + +-------------------------------------------------------------------------------- +MIT LICENSE + +Copyright (c) 2018-present Ant UED, https://xtech.antfin.com/ + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +-------------------------------------------------------------------------------- diff --git a/scripts/check-pack.mjs b/scripts/check-pack.mjs index 66ada02..cbe7339 100644 --- a/scripts/check-pack.mjs +++ b/scripts/check-pack.mjs @@ -191,6 +191,38 @@ for (const locale of coreLocales) { } } +/** + * Third-party code that ships inside dist carries its licence with it. The MIT + * licence asks for its copyright line and permission notice in every copy, so + * both have to be in a packed file, not just in the repository. + */ +const THIRD_PARTY_NOTICES = [ + { + what: "Ant Design Icons path data (src/components/Form/feedbackIcons.tsx)", + file: "NOTICE", + mustContain: [ + "Copyright (c) 2018-present Ant UED, https://xtech.antfin.com/", + "Permission is hereby granted, free of charge, to any person obtaining", + "The above copyright notice and this permission notice shall be\nincluded in all copies or substantial portions of the Software.", + ], + }, +]; + +for (const { what, file, mustContain } of THIRD_PARTY_NOTICES) { + if (!packedSet.has(file)) { + failures.push(`${what}: its licence lives in "${file}", which is not packed`); + continue; + } + const text = await readFile(resolve(root, file), "utf8"); + for (const line of mustContain) { + if (!text.includes(line)) { + failures.push( + `${what}: "${file}" lacks the licence text "${line.split("\n")[0]}"`, + ); + } + } +} + if (failures.length > 0) { console.error(`Packed artifact check failed (${failures.length}):\n`); for (const f of failures) console.error(` ${f}`); From 4ee64c233377df05db354d9a8e808331233f34b7 Mon Sep 17 00:00:00 2001 From: Jongeun <jongeun@lablup.com> Date: Tue, 29 Sep 2026 02:46:04 +0000 Subject: [PATCH 59/87] docs(Form): say where isEqual differs from lodash --- src/components/Form/isEqual.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/components/Form/isEqual.ts b/src/components/Form/isEqual.ts index a14b8cd..846b613 100644 --- a/src/components/Form/isEqual.ts +++ b/src/components/Form/isEqual.ts @@ -6,6 +6,11 @@ * `Error`, `Map` and `Set` (order-insensitive), and cycles. Any other * built-in tag (`File`, `Blob`, DOM nodes, …) is equal only by reference, as * in lodash. + * + * It differs from lodash only for `ArrayBuffer` (by reference here, by bytes + * in lodash), `DataView` (keys only here, so views over different bytes are + * equal; by bytes in lodash) and a boxed primitive against its primitive + * (`Object(1)` vs `1`: unequal here, equal in lodash). */ const toTag = (value: unknown) => Object.prototype.toString.call(value); const hasOwn = (value: object, key: PropertyKey) => From c5efb82123d5849925160b27fcb688bbc0817717 Mon Sep 17 00:00:00 2001 From: Jongeun <jongeun@lablup.com> Date: Tue, 29 Sep 2026 03:00:45 +0000 Subject: [PATCH 60/87] fix(DataGridSettingsModal): keep the pick-up announcement on keyboard reorder Right after a keyboard pick-up, dnd-kit reports the picked-up row as over itself, so "Picked up {column}" was replaced at once by "{column} moved to position N" in a real browser. A move is now announced only when the position changes. The test gives the rows a layout so dnd-kit's keyboard sensor runs its collision detection in jsdom, and covers pick-up, move, drop and the applied order. --- .../DataGrid/DataGridModals.test.tsx | 60 ++++++++++++++++++- .../DataGrid/DataGridSettingsModal.tsx | 32 ++++++---- 2 files changed, 80 insertions(+), 12 deletions(-) diff --git a/src/components/DataGrid/DataGridModals.test.tsx b/src/components/DataGrid/DataGridModals.test.tsx index 0f03b4f..61b0fa5 100644 --- a/src/components/DataGrid/DataGridModals.test.tsx +++ b/src/components/DataGrid/DataGridModals.test.tsx @@ -1,5 +1,5 @@ -import { describe, expect, it, vi } from "vitest"; -import { render, screen, within } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { InternationalizationProvider } from "@astryxdesign/core/i18n"; @@ -130,6 +130,62 @@ describe("DataGridSettingsModal", () => { ).toBeInTheDocument(); }); + describe("with layout", () => { + // jsdom has no layout: give each row a 32px slot so dnd-kit can measure. + const ROW_HEIGHT = 32; + let restore: () => void; + beforeEach(() => { + const original = Element.prototype.getBoundingClientRect; + Element.prototype.getBoundingClientRect = function (this: Element) { + const row = this.closest(".uic-data-grid-dialog__row"); + if (!row?.parentElement) return original.call(this); + const index = [...row.parentElement.children].indexOf(row); + const top = index * ROW_HEIGHT; + return { + x: 0, + y: top, + top, + left: 0, + right: 300, + bottom: top + ROW_HEIGHT, + width: 300, + height: ROW_HEIGHT, + toJSON: () => ({}), + } as DOMRect; + }; + restore = () => { + Element.prototype.getBoundingClientRect = original; + }; + }); + afterEach(() => restore()); + + const liveText = () => + [...document.querySelectorAll("[aria-live]")] + .map((region) => region.textContent) + .join(""); + + it("reorders by keyboard and keeps the pick-up announcement", async () => { + const { onApply } = renderSettings(); + screen.getByRole("button", { name: "Reorder Owner" }).focus(); + + await userEvent.keyboard(" "); + await waitFor(() => + expect(liveText()).toBe("Picked up Owner. It is at position 2 of 3."), + ); + + await userEvent.keyboard("{ArrowDown}"); + await waitFor(() => expect(liveText()).toBe("Owner moved to position 3 of 3.")); + + await userEvent.keyboard(" "); + await waitFor(() => expect(liveText()).toBe("Owner dropped at position 3 of 3.")); + + await userEvent.click(screen.getByRole("button", { name: "Apply" })); + expect(onApply).toHaveBeenCalledWith( + expect.objectContaining({ columnOrder: ["name", "size", "owner"] }), + ); + }); + }); + it("renders no drag handles when not reorderable", () => { renderSettings({ isReorderable: false }); expect(handles(screen.getByRole("dialog"))).toHaveLength(0); diff --git a/src/components/DataGrid/DataGridSettingsModal.tsx b/src/components/DataGrid/DataGridSettingsModal.tsx index 19c93dd..a57e7ef 100644 --- a/src/components/DataGrid/DataGridSettingsModal.tsx +++ b/src/components/DataGrid/DataGridSettingsModal.tsx @@ -19,7 +19,13 @@ * }} * /> */ -import { useState, type CSSProperties, type ReactElement, type ReactNode } from "react"; +import { + useRef, + useState, + type CSSProperties, + type ReactElement, + type ReactNode, +} from "react"; import { DndContext, KeyboardSensor, @@ -184,16 +190,22 @@ function SettingsBody({ position: order.indexOf(String(key)) + 1, total: order.length, }); + // dnd-kit reports the picked-up row as "over" itself right after pick-up; + // announcing only a change of position keeps the pick-up message. + const lastOverRef = useRef<UniqueIdentifier | null>(null); const announcements: Announcements = { - onDragStart: ({ active }) => - t("uic.DataGrid.reorderPickedUp", positionOf(active.id)), - onDragOver: ({ active, over }) => - over - ? t("uic.DataGrid.reorderMoved", { - ...positionOf(over.id), - column: labelOf(active.id), - }) - : undefined, + onDragStart: ({ active }) => { + lastOverRef.current = active.id; + return t("uic.DataGrid.reorderPickedUp", positionOf(active.id)); + }, + onDragOver: ({ active, over }) => { + if (!over || over.id === lastOverRef.current) return undefined; + lastOverRef.current = over.id; + return t("uic.DataGrid.reorderMoved", { + ...positionOf(over.id), + column: labelOf(active.id), + }); + }, onDragEnd: ({ active, over }) => t("uic.DataGrid.reorderDropped", { ...positionOf(over?.id ?? active.id), From 12e83e9351b3a1a14889035360f2dae8276b4f00 Mon Sep 17 00:00:00 2001 From: Jongeun <jongeun@lablup.com> Date: Tue, 29 Sep 2026 03:00:46 +0000 Subject: [PATCH 61/87] chore: prepare 0.2.0-alpha.11 --- CHANGELOG.md | 27 +++++++++++++++++++++++++++ package.json | 2 +- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e7ba87..d138f63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,33 @@ Versioning follows the policy in [CONTRIBUTING.md](CONTRIBUTING.md#versioning). ## [Unreleased] +## [0.2.0-alpha.11] + +Review fixes for the table cluster, and translation fixes. + +### Fixed + +- `DataGrid` select-all honours `selection.getIsItemEnabled`: it adds and + removes enabled rows only, a disabled row keeps its state, and the header's + checked and indeterminate states count enabled rows only. +- `DataGrid` passes a column's `getCellProps(item, index)` the row's index on + the page; it always received 0. +- `DataGridSettingsModal` drag handles are named (`uic.DataGrid.reorderColumn`, + "Reorder {column}") instead of focusable but `aria-hidden`, move through the + list by keyboard (Space, arrow keys, Space; Escape cancels), and announce + pick-up, moves, drop and cancel with the column's label and position. + Five more catalog keys (`uic.DataGrid.reorderInstructions`, + `reorderPickedUp`, `reorderMoved`, `reorderDropped`, `reorderCancelled`), + all six translated in every shipped locale. +- `uic.common.cancel` and `uic.common.delete` read as button labels, not + infinitives or the wrong word, in de, el, fi, id, it, ja, mn, ms, pl, + pt-PT, tr and vi; `uic.common.apply` in ms and th. +- `uic.common.ok`, `uic.common.retry` and the Card, Row and Text skeletons' + "Loading" are translated in the 18 locales that lacked them (Mongolian + OK excepted). +- NOTICE carries the MIT licence text of Ant Design Icons, whose path data + the form feedback glyphs use, and `check:pack` asserts it is packed. + ## [0.2.0-alpha.10] backend.ai-ui's table cluster (DataGrid, its dialogs, BulkErrorModal) and diff --git a/package.json b/package.json index fd302f0..8a4711d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@lablup/ui-common", - "version": "0.2.0-alpha.10", + "version": "0.2.0-alpha.11", "description": "Lablup's UI layer on Astryx: the Astryx surface re-exported 1:1, the Lablup theme, and shared components", "license": "Apache-2.0", "author": "Lablup Inc.", From 1017f346737e0f1cffbe27f4cecc830dcaacffbf Mon Sep 17 00:00:00 2001 From: Jongeun <jongeun@lablup.com> Date: Tue, 29 Sep 2026 06:18:00 +0000 Subject: [PATCH 62/87] refactor!: drop the --uic- prefix from custom properties Custom properties take Astryx's naming as is. - The Lablup theme's info hue is --color-info, the name the Backend.AI WebUI theme declares, in the theme's tokens so a theme extending it can override it the same way. - Hooks that only carried a theme value are gone: Form reads --color-text-description, TextHighlighter --color-warning-border-hover, StatCard --color-info, each with an Astryx fallback; ProgressWithLabel's color prop paints the fill directly; StatCard, ErrorState and ProgressWithLabel variants read their tokens instead of a tone property. - Component knobs are --<component>-<property>, the form Astryx core uses for its own (--dialog-dir-x, --spinner-color). componentStyles.test.ts now enforces the rule and fails when a knob collides with a name Astryx core, lab or the neutral theme declares or reads. CONTRIBUTING's styling rule says the same. --- CONTRIBUTING.md | 20 ++- README.md | 12 +- astryx/components/BoardItemTitle.doc.mjs | 2 +- astryx/components/Form.doc.mjs | 2 +- astryx/components/NotificationStack.doc.mjs | 2 +- astryx/components/OverlayScrollbar.doc.mjs | 2 +- astryx/components/ProgressWithLabel.doc.mjs | 2 +- astryx/components/TextHighlighter.doc.mjs | 2 +- astryx/components/UnitGrid.doc.mjs | 4 +- docs/astryx.md | 2 +- migration/0.1-to-0.2.json | 10 +- src/components/AlertModal/AlertModal.test.tsx | 4 +- .../BoardItemTitle/BoardItemTitle.css | 4 +- src/components/CountBadge/CountBadge.css | 4 +- src/components/CountBadge/CountBadge.test.tsx | 4 +- src/components/CountBadge/CountBadge.tsx | 4 +- src/components/DataGrid/DataGrid.css | 6 +- src/components/DataGrid/DataGrid.test.tsx | 4 +- src/components/DataGrid/DataGrid.tsx | 4 +- .../DataGrid/DataGridExportModal.tsx | 4 +- .../DataGrid/DataGridSettingsModal.tsx | 4 +- src/components/DigitPopIn/DigitPopIn.css | 22 ++- src/components/DigitPopIn/DigitPopIn.test.tsx | 2 +- src/components/DigitPopIn/DigitPopIn.tsx | 2 +- src/components/DividedRow/DividedRow.css | 2 +- src/components/DividedRow/DividedRow.test.tsx | 2 +- src/components/DividedRow/DividedRow.tsx | 2 +- src/components/ErrorState/ErrorState.css | 16 +- src/components/Form/FormItemVisual.css | 30 ++-- src/components/ListBanner/ListBanner.css | 2 +- src/components/ListBanner/ListBanner.test.tsx | 4 +- src/components/ListBanner/ListBanner.tsx | 2 +- src/components/Modal/Modal.css | 10 +- src/components/Modal/Modal.test.tsx | 4 +- src/components/Modal/Modal.tsx | 8 +- src/components/Modal/modalStack.ts | 6 +- .../NotificationStack/NotificationStack.css | 6 +- .../NotificationStack/NotificationStack.tsx | 6 +- .../OverlayScrollbar/OverlayScrollbar.css | 2 +- .../OverlayScrollbar/OverlayScrollbar.tsx | 2 +- .../ProgressWithLabel/ProgressWithLabel.css | 28 ++-- .../ProgressWithLabel.test.tsx | 7 +- .../ProgressWithLabel/ProgressWithLabel.tsx | 13 +- src/components/StatCard/StatCard.css | 70 ++++++--- .../TextHighlighter/TextHighlighter.css | 5 +- .../TextHighlighter/TextHighlighter.test.tsx | 2 +- .../TextHighlighter/TextHighlighter.tsx | 5 +- src/components/UnitGrid/UnitGrid.css | 28 ++-- src/components/UnitGrid/UnitGrid.test.tsx | 13 +- src/components/UnitGrid/UnitGrid.tsx | 12 +- src/components/componentStyles.test.ts | 147 ++++++++++++++++-- src/globalStyles.test.ts | 2 +- src/legacy-tokens.css | 4 +- src/theme/lablup/built/lablup.js | 9 +- src/theme/lablup/built/theme.css | 2 +- src/theme/lablup/lablupTheme.test.ts | 7 +- src/theme/lablup/lablupTheme.ts | 17 +- 57 files changed, 373 insertions(+), 230 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 563d71b..abaf87c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -254,8 +254,24 @@ A string with no prop is a bug. So is a prop with no catalog default. - Every rule inside `@layer ui-common`. - Class names are BEM with a `uic-` prefix: `uic-page-header__title`. - Values come from Astryx tokens, `var(--color-...)`, `var(--spacing-...)`. - No new `--token-*` names. A component's own custom properties start with - `--uic-`. + No new `--token-*` names. +- Custom properties use Astryx's naming as is, with no ui-common prefix: + - A theme value Astryx has no token for is a theme token in Astryx's + form, `--color-info`, and a component reads it with an Astryx fallback: + `var(--color-info, var(--color-accent))`. The names are the ones the + Backend.AI WebUI theme declares, so a product theme that has them needs + no setter. The list is `THEME_EXTENSION_TOKENS` in + `src/components/componentStyles.test.ts`. + - A component knob (z-index, geometry, motion, a value a prop writes) is + `--<component>-<property>`, the component name in kebab case, the form + Astryx core uses for its own (`--dialog-dir-x`, `--spinner-color`, + `--table-sticky-background`): `--modal-z`, `--data-grid-max-height`, + `--unit-grid-group-1`. + - A value that only picks a token for a variant is not a custom + property: the variant rule reads the token. + - A name must not be one Astryx core, lab or the neutral theme declares + or reads, nor one the WebUI declares (`--bai-*`, `--token-*`, its theme + tokens). `componentStyles.test.ts` fails on a collision. - No colour literals. A length literal is allowed only where Astryx has no token (a media query breakpoint, a page width, a readable measure), with a comment saying so. diff --git a/README.md b/README.md index 69add27..66f7505 100644 --- a/README.md +++ b/README.md @@ -305,12 +305,12 @@ const [form] = Form.useForm(); `__explain`, `__explain-error`, `__explain-warning` and `__extra`. The item carries `data-layout`, `data-size` and `data-status`. These class names are the public hooks for tests and product CSS. -- Four custom properties adjust it: `--uic-form-item-margin-bottom` - (default `--spacing-6`), `--uic-form-item-gap` (label to control in a - vertical item, default `--spacing-2`) and - `--uic-form-item-description-color` (help, extra, the tooltip glyph and - the optional suffix, default `--color-text-secondary`) and - `--uic-form-item-line-height` (default `--text-body-leading`). +- Three custom properties adjust it: `--form-item-margin-bottom` + (default `--spacing-6`), `--form-item-gap` (label to control in a + vertical item, default `--spacing-2`) and `--form-item-line-height` + (default `--text-body-leading`). Help, extra, the tooltip glyph and the + optional suffix take the theme's `--color-text-description` where the + theme declares one, else `--color-text-secondary`. ### What is hidden diff --git a/astryx/components/BoardItemTitle.doc.mjs b/astryx/components/BoardItemTitle.doc.mjs index 22a5852..92c16f3 100644 --- a/astryx/components/BoardItemTitle.doc.mjs +++ b/astryx/components/BoardItemTitle.doc.mjs @@ -40,7 +40,7 @@ export default { ], usage: { description: - "At the top of a dashboard panel or a board item. Other div attributes (className, style, data-*) reach the row. The --uic-board-item-title-z property sets its z-index (default 50).", + "At the top of a dashboard panel or a board item. Other div attributes (className, style, data-*) reach the row. The --board-item-title-z property sets its z-index (default 50).", }, examples: [ { diff --git a/astryx/components/Form.doc.mjs b/astryx/components/Form.doc.mjs index e386400..b55456d 100644 --- a/astryx/components/Form.doc.mjs +++ b/astryx/components/Form.doc.mjs @@ -102,7 +102,7 @@ export default { ], usage: { description: - "Data-entry screens whose state, validation and submit need more than a few controlled fields. Style hooks: .uic-form-item and its __label, __control, __explain-error, __extra elements, and --uic-form-item-margin-bottom, --uic-form-item-gap, --uic-form-item-description-color and --uic-form-item-line-height. A control reaches its item's status through Form.Item.useStatus() or FormItemInputContext.", + "Data-entry screens whose state, validation and submit need more than a few controlled fields. Style hooks: .uic-form-item and its __label, __control, __explain-error, __extra elements, and --form-item-margin-bottom, --form-item-gap and --form-item-line-height. Help, extra and the tooltip glyph take the theme's --color-text-description when it declares one (default --color-text-secondary). A control reaches its item's status through Form.Item.useStatus() or FormItemInputContext.", }, examples: [ { diff --git a/astryx/components/NotificationStack.doc.mjs b/astryx/components/NotificationStack.doc.mjs index bd21b82..fea54db 100644 --- a/astryx/components/NotificationStack.doc.mjs +++ b/astryx/components/NotificationStack.doc.mjs @@ -45,7 +45,7 @@ export default { ], usage: { description: - "Mount one per app. Set --uic-notification-stack-z to fit your layer ladder (default 11000, above Modal's band) and --uic-notification-stack-inset-top to keep a header clear.", + "Mount one per app. Set --notification-stack-z to fit your layer ladder (default 11000, above Modal's band) and --notification-stack-inset-top to keep a header clear.", bestPractices: [ { guidance: true, diff --git a/astryx/components/OverlayScrollbar.doc.mjs b/astryx/components/OverlayScrollbar.doc.mjs index be1a076..d830317 100644 --- a/astryx/components/OverlayScrollbar.doc.mjs +++ b/astryx/components/OverlayScrollbar.doc.mjs @@ -27,7 +27,7 @@ export default { ], usage: { description: - "Render it inside the target's positioned ancestor. Raise --uic-overlay-scrollbar-z (default 1) to clear sticky chrome inside the column.", + "Render it inside the target's positioned ancestor. Raise --overlay-scrollbar-z (default 1) to clear sticky chrome inside the column.", bestPractices: [ { guidance: true, diff --git a/astryx/components/ProgressWithLabel.doc.mjs b/astryx/components/ProgressWithLabel.doc.mjs index 75ef539..665f744 100644 --- a/astryx/components/ProgressWithLabel.doc.mjs +++ b/astryx/components/ProgressWithLabel.doc.mjs @@ -11,7 +11,7 @@ export default { category: "Data Display", keywords: ["progress", "usage bar", "resource", "labelled progress", "meter"], description: - "A bar that carries its labels: label at the start and valueLabel at the end, over a fill of value percent, for compact resource readouts in cells and cards. A missing or NaN value draws no fill and greys the value label. The fill is color or --uic-progress-with-label-color (default --color-success); the frame's corner is --uic-progress-with-label-radius (default --radius-inner).", + "A bar that carries its labels: label at the start and valueLabel at the end, over a fill of value percent, for compact resource readouts in cells and cards. A missing or NaN value draws no fill and greys the value label. The fill is color (default --color-success); the frame's corner is --progress-with-label-radius (default --radius-inner).", props: [ { name: "label", diff --git a/astryx/components/TextHighlighter.doc.mjs b/astryx/components/TextHighlighter.doc.mjs index 95aaebb..bcea87d 100644 --- a/astryx/components/TextHighlighter.doc.mjs +++ b/astryx/components/TextHighlighter.doc.mjs @@ -11,7 +11,7 @@ export default { category: "Data Display", keywords: ["highlight", "search", "mark", "keyword", "match"], description: - "Marks every case-insensitive occurrence of keyword in a string, for search results. The keyword matches literally. Without a keyword the text renders plain; without text nothing renders. The mark is --uic-text-highlighter-background (default --color-warning-muted), set on the highlighter or any ancestor.", + "Marks every case-insensitive occurrence of keyword in a string, for search results. The keyword matches literally. Without a keyword the text renders plain; without text nothing renders. The mark is the theme's --color-warning-border-hover when it declares one, else --color-warning-muted.", props: [ { name: "children", diff --git a/astryx/components/UnitGrid.doc.mjs b/astryx/components/UnitGrid.doc.mjs index aab9c60..4059bc9 100644 --- a/astryx/components/UnitGrid.doc.mjs +++ b/astryx/components/UnitGrid.doc.mjs @@ -18,7 +18,7 @@ export default { "heatmap", ], description: - "Groups of unit squares packed onto one shared lattice, serpentine or word-wrap. Each group is a tinted rounded plate with its initial on one cell; hovering it opens a hover card with the caller's content and, with onHueOverrideChange, a palette picker. Unit colours may be var() or color-mix() strings: the component resolves them to pick the initial's ink by WCAG contrast. The seven default hues and the two inks are --uic-unit-grid-group-1..7 and --uic-unit-grid-ink-dark/-light on the root; --uic-unit-grid-popover-z sets the hover card's z-index.", + "Groups of unit squares packed onto one shared lattice, serpentine or word-wrap. Each group is a tinted rounded plate with its initial on one cell; hovering it opens a hover card with the caller's content and, with onHueOverrideChange, a palette picker. Unit colours may be var() or color-mix() strings: the component resolves them to pick the initial's ink by WCAG contrast. The seven default hues and the two inks are --unit-grid-group-1..7 and --unit-grid-ink-dark/-light on the root; --unit-grid-popover-z sets the hover card's z-index.", props: [ { name: "groups", @@ -49,7 +49,7 @@ export default { name: "groupPalette", type: "string[]", description: "Group hues, cycled in flow order.", - default: "the seven --uic-unit-grid-group-N hues", + default: "the seven --unit-grid-group-N hues", }, { name: "hueOverrides", diff --git a/docs/astryx.md b/docs/astryx.md index 6fb4d42..8d5cf8e 100644 --- a/docs/astryx.md +++ b/docs/astryx.md @@ -67,7 +67,7 @@ reads the diff. That diff is the upgrade review. `@lablup/ui-common/theme/lablup` is an Astryx theme source. It extends neutral and changes only the brand: the orange accent seed, the four status hues, and the font family name. Info has no Astryx token, so the theme declares -`--uic-color-info` as a theme-local token. +`--color-info`, in Astryx's own naming (see CONTRIBUTING, "Styling"). `astryx theme build` compiles it into `theme/lablup/built` (a JS theme object marked as pre-built) and `theme/lablup/theme.css`. Both are committed. diff --git a/migration/0.1-to-0.2.json b/migration/0.1-to-0.2.json index 418ec4a..afa4c42 100644 --- a/migration/0.1-to-0.2.json +++ b/migration/0.1-to-0.2.json @@ -544,15 +544,7 @@ "specifiers": ["@lablup/ui-common", "@lablup/ui-common/components/DigitPopIn"], "propChanges": [], "classRenames": { "digit-pop-in": "uic-digit-pop-in" }, - "customPropertyRenames": { - "--digit-pop-in-duration": "--uic-digit-pop-in-duration", - "--digit-pop-in-distance": "--uic-digit-pop-in-distance", - "--digit-pop-in-stagger": "--uic-digit-pop-in-stagger", - "--digit-pop-in-blur": "--uic-digit-pop-in-blur", - "--digit-pop-in-ease": "--uic-digit-pop-in-ease", - "--digit-pop-in-index": "--uic-digit-pop-in-index" - }, - "notes": [] + "notes": ["The --digit-pop-in-* custom properties keep their 0.1 names."] }, { "name": "SkeletonCard", diff --git a/src/components/AlertModal/AlertModal.test.tsx b/src/components/AlertModal/AlertModal.test.tsx index 755e2f8..0004aad 100644 --- a/src/components/AlertModal/AlertModal.test.tsx +++ b/src/components/AlertModal/AlertModal.test.tsx @@ -29,7 +29,7 @@ const renderAlert = (props: Partial<ComponentProps<typeof AlertModal>> = {}) => const getMask = () => document.querySelector(".uic-modal__mask") as HTMLElement; const levelOf = (root: Element | null | undefined) => - Number((root as HTMLElement).style.getPropertyValue("--uic-modal-level")); + Number((root as HTMLElement).style.getPropertyValue("--modal-level")); describe("AlertModal", () => { it("portals to document.body without a native <dialog>", () => { @@ -158,7 +158,7 @@ describe("AlertModal", () => { screen .getByRole("alertdialog") .closest<HTMLElement>(".uic-modal") - ?.style.getPropertyValue("--uic-modal-z"), + ?.style.getPropertyValue("--modal-z"), ).toBe("5000"); }); }); diff --git a/src/components/BoardItemTitle/BoardItemTitle.css b/src/components/BoardItemTitle/BoardItemTitle.css index 9b664d8..d43dbde 100644 --- a/src/components/BoardItemTitle/BoardItemTitle.css +++ b/src/components/BoardItemTitle/BoardItemTitle.css @@ -1,5 +1,5 @@ /* - * BoardItemTitle: the sticky title row. --uic-board-item-title-z lifts it + * BoardItemTitle: the sticky title row. --board-item-title-z lifts it * over the panel's own content (sticky table headers and fixed columns); * a product with a z-index ladder sets it from there. */ @@ -7,7 +7,7 @@ .uic-board-item-title { position: sticky; top: 0; - z-index: var(--uic-board-item-title-z, 50); + z-index: var(--board-item-title-z, 50); flex-shrink: 0; min-width: 0; padding-block: var(--spacing-5); diff --git a/src/components/CountBadge/CountBadge.css b/src/components/CountBadge/CountBadge.css index d60d67e..a23656d 100644 --- a/src/components/CountBadge/CountBadge.css +++ b/src/components/CountBadge/CountBadge.css @@ -15,8 +15,8 @@ overlay hangs off the wrong edge. */ inset-inline-end: 0; transform: translate( - calc(50% + var(--uic-count-badge-offset-x)), - calc(-50% + var(--uic-count-badge-offset-y)) + calc(50% + var(--count-badge-offset-x)), + calc(-50% + var(--count-badge-offset-y)) ); display: inline-flex; align-items: center; diff --git a/src/components/CountBadge/CountBadge.test.tsx b/src/components/CountBadge/CountBadge.test.tsx index dfaf072..8b29a03 100644 --- a/src/components/CountBadge/CountBadge.test.tsx +++ b/src/components/CountBadge/CountBadge.test.tsx @@ -80,8 +80,8 @@ describe("CountBadge", () => { ); const overlay = screen.getByRole("status"); expect(overlay).toHaveClass("uic-count-badge__overlay--sm"); - expect(overlay.style.getPropertyValue("--uic-count-badge-offset-x")).toBe("4px"); - expect(overlay.style.getPropertyValue("--uic-count-badge-offset-y")).toBe("-2px"); + expect(overlay.style.getPropertyValue("--count-badge-offset-x")).toBe("4px"); + expect(overlay.style.getPropertyValue("--count-badge-offset-y")).toBe("-2px"); }); it("forwards the remaining props to the Badge", () => { diff --git a/src/components/CountBadge/CountBadge.tsx b/src/components/CountBadge/CountBadge.tsx index faf7feb..ec9d79f 100644 --- a/src/components/CountBadge/CountBadge.tsx +++ b/src/components/CountBadge/CountBadge.tsx @@ -82,8 +82,8 @@ export function CountBadge({ .join(" ")} style={ { - "--uic-count-badge-offset-x": `${offset?.[0] ?? 0}px`, - "--uic-count-badge-offset-y": `${offset?.[1] ?? 0}px`, + "--count-badge-offset-x": `${offset?.[0] ?? 0}px`, + "--count-badge-offset-y": `${offset?.[1] ?? 0}px`, } as CSSProperties } role="status" diff --git a/src/components/DataGrid/DataGrid.css b/src/components/DataGrid/DataGrid.css index 04f448c..5465a4b 100644 --- a/src/components/DataGrid/DataGrid.css +++ b/src/components/DataGrid/DataGrid.css @@ -83,14 +83,14 @@ inline min-width the table writes once any column has a pixel width. */ .uic-data-grid__body--scroll-x table:not(table table) { table-layout: auto; - width: var(--uic-data-grid-scroll-width); + width: var(--data-grid-scroll-width); min-width: 100% !important; } /* `maxHeight`: caps the table's scroll wrapper, found as the element around the table (the resize plugin adds a boxless wrapper, so its depth varies). */ .uic-data-grid__body--scroll-y div:has(> table):not(table div) { - max-height: var(--uic-data-grid-max-height); + max-height: var(--data-grid-max-height); overflow-y: auto; } @@ -117,7 +117,7 @@ } .uic-data-grid-dialog__list { - max-height: var(--uic-data-grid-dialog-list-height); + max-height: var(--data-grid-dialog-list-height); overflow-y: auto; } diff --git a/src/components/DataGrid/DataGrid.test.tsx b/src/components/DataGrid/DataGrid.test.tsx index 4eec632..09e6a9a 100644 --- a/src/components/DataGrid/DataGrid.test.tsx +++ b/src/components/DataGrid/DataGrid.test.tsx @@ -447,7 +447,7 @@ describe("DataGrid scrolling", () => { const { container } = renderGrid({ data: makeRows(1), scrollWidth: value }); const body = bodyOf(container); expect(body).toHaveClass("uic-data-grid__body--scroll-x"); - expect(body.style.getPropertyValue("--uic-data-grid-scroll-width")).toBe(expected); + expect(body.style.getPropertyValue("--data-grid-scroll-width")).toBe(expected); }); it("releases max-width on width-less columns only", () => { @@ -470,7 +470,7 @@ describe("DataGrid scrolling", () => { }); const body = bodyOf(container); expect(body).toHaveClass("uic-data-grid__body--scroll-y"); - expect(body.style.getPropertyValue("--uic-data-grid-max-height")).toBe("500px"); + expect(body.style.getPropertyValue("--data-grid-max-height")).toBe("500px"); const [pinned, plain] = container.querySelectorAll<HTMLTableCellElement>("thead th"); expect(pinned?.style.zIndex).toBe("3"); diff --git a/src/components/DataGrid/DataGrid.tsx b/src/components/DataGrid/DataGrid.tsx index 4627dbb..af35f7a 100644 --- a/src/components/DataGrid/DataGrid.tsx +++ b/src/components/DataGrid/DataGrid.tsx @@ -978,10 +978,10 @@ export function DataGrid<T extends object = AnyRow>({ style={ { ...(isScrollX - ? { "--uic-data-grid-scroll-width": toCssLength(scrollWidth) } + ? { "--data-grid-scroll-width": toCssLength(scrollWidth) } : null), ...(isScrollY - ? { "--uic-data-grid-max-height": toCssLength(maxHeight) } + ? { "--data-grid-max-height": toCssLength(maxHeight) } : null), } as CSSProperties } diff --git a/src/components/DataGrid/DataGridExportModal.tsx b/src/components/DataGrid/DataGridExportModal.tsx index 1f4051c..01d6193 100644 --- a/src/components/DataGrid/DataGridExportModal.tsx +++ b/src/components/DataGrid/DataGridExportModal.tsx @@ -172,9 +172,7 @@ function ExportBody({ /> <div className="uic-data-grid-dialog__list" - style={ - { "--uic-data-grid-dialog-list-height": LIST_HEIGHT } as CSSProperties - } + style={{ "--data-grid-dialog-list-height": LIST_HEIGHT } as CSSProperties} > <VStack gap={0} align="stretch"> {shownOptions.map((option) => ( diff --git a/src/components/DataGrid/DataGridSettingsModal.tsx b/src/components/DataGrid/DataGridSettingsModal.tsx index a57e7ef..7d0af51 100644 --- a/src/components/DataGrid/DataGridSettingsModal.tsx +++ b/src/components/DataGrid/DataGridSettingsModal.tsx @@ -286,9 +286,7 @@ function SettingsBody({ /> <div className="uic-data-grid-dialog__list" - style={ - { "--uic-data-grid-dialog-list-height": LIST_HEIGHT } as CSSProperties - } + style={{ "--data-grid-dialog-list-height": LIST_HEIGHT } as CSSProperties} > {isDragDisabled ? ( list diff --git a/src/components/DigitPopIn/DigitPopIn.css b/src/components/DigitPopIn/DigitPopIn.css index 1702be2..a387698 100644 --- a/src/components/DigitPopIn/DigitPopIn.css +++ b/src/components/DigitPopIn/DigitPopIn.css @@ -9,11 +9,11 @@ */ @layer ui-common { .uic-digit-pop-in { - --uic-digit-pop-in-duration: var(--duration-medium-max); - --uic-digit-pop-in-distance: 0.5rem; - --uic-digit-pop-in-stagger: 70ms; - --uic-digit-pop-in-blur: 2px; - --uic-digit-pop-in-ease: cubic-bezier(0.34, 1.45, 0.64, 1); + --digit-pop-in-duration: var(--duration-medium-max); + --digit-pop-in-distance: 0.5rem; + --digit-pop-in-stagger: 70ms; + --digit-pop-in-blur: 2px; + --digit-pop-in-ease: cubic-bezier(0.34, 1.45, 0.64, 1); display: inline-block; position: relative; } @@ -25,11 +25,9 @@ .uic-digit-pop-in__digit { display: inline-block; - animation: uic-digit-pop-in var(--uic-digit-pop-in-duration) - var(--uic-digit-pop-in-ease) both; - animation-delay: calc( - var(--uic-digit-pop-in-index, 0) * var(--uic-digit-pop-in-stagger) - ); + animation: uic-digit-pop-in var(--digit-pop-in-duration) var(--digit-pop-in-ease) + both; + animation-delay: calc(var(--digit-pop-in-index, 0) * var(--digit-pop-in-stagger)); } /* The text a screen reader gets in place of the per-character copy. */ @@ -47,9 +45,9 @@ @keyframes uic-digit-pop-in { from { - transform: translateY(var(--uic-digit-pop-in-distance)); + transform: translateY(var(--digit-pop-in-distance)); opacity: 0; - filter: blur(var(--uic-digit-pop-in-blur)); + filter: blur(var(--digit-pop-in-blur)); } to { transform: translateY(0); diff --git a/src/components/DigitPopIn/DigitPopIn.test.tsx b/src/components/DigitPopIn/DigitPopIn.test.tsx index 16fe4b5..586577c 100644 --- a/src/components/DigitPopIn/DigitPopIn.test.tsx +++ b/src/components/DigitPopIn/DigitPopIn.test.tsx @@ -33,7 +33,7 @@ describe("DigitPopIn", () => { const { container } = render(<DigitPopIn text="1,234" />); const chars = digits(container); expect(chars.map((char) => char.textContent)).toEqual(["1", ",", "2", "3", "4"]); - expect(chars[4]?.style.getPropertyValue("--uic-digit-pop-in-index")).toBe("4"); + expect(chars[4]?.style.getPropertyValue("--digit-pop-in-index")).toBe("4"); }); it("hides the characters from assistive tech and exposes the whole text", () => { diff --git a/src/components/DigitPopIn/DigitPopIn.tsx b/src/components/DigitPopIn/DigitPopIn.tsx index 8df3d54..cb3e956 100644 --- a/src/components/DigitPopIn/DigitPopIn.tsx +++ b/src/components/DigitPopIn/DigitPopIn.tsx @@ -44,7 +44,7 @@ export function DigitPopIn({ text, className = "" }: DigitPopInProps): JSX.Eleme // the text changes. key={index} className="uic-digit-pop-in__digit" - style={{ "--uic-digit-pop-in-index": index } as CSSProperties} + style={{ "--digit-pop-in-index": index } as CSSProperties} > {char === " " ? " " : char} </span> diff --git a/src/components/DividedRow/DividedRow.css b/src/components/DividedRow/DividedRow.css index 30bb05d..d958b92 100644 --- a/src/components/DividedRow/DividedRow.css +++ b/src/components/DividedRow/DividedRow.css @@ -17,7 +17,7 @@ .uic-divided-row__divider { position: absolute; - right: calc(-0.5 * var(--uic-divided-row-column-gap, 0px)); + right: calc(-0.5 * var(--divided-row-column-gap, 0px)); background: var(--color-border); pointer-events: none; } diff --git a/src/components/DividedRow/DividedRow.test.tsx b/src/components/DividedRow/DividedRow.test.tsx index f3cc2d9..a767cff 100644 --- a/src/components/DividedRow/DividedRow.test.tsx +++ b/src/components/DividedRow/DividedRow.test.tsx @@ -81,7 +81,7 @@ describe("DividedRow", () => { const row = container.firstElementChild as HTMLElement; expect(row.style.rowGap).toBe("var(--spacing-8)"); expect(row.style.columnGap).toBe("var(--spacing-12)"); - expect(row.style.getPropertyValue("--uic-divided-row-column-gap")).toBe( + expect(row.style.getPropertyValue("--divided-row-column-gap")).toBe( "var(--spacing-12)", ); expect(row.style.flexWrap).toBe("wrap"); diff --git a/src/components/DividedRow/DividedRow.tsx b/src/components/DividedRow/DividedRow.tsx index be56877..9bd5b36 100644 --- a/src/components/DividedRow/DividedRow.tsx +++ b/src/components/DividedRow/DividedRow.tsx @@ -121,7 +121,7 @@ export function DividedRow({ flexWrap: wrap, rowGap: toLength(rowGap), columnGap: toLength(columnGap), - "--uic-divided-row-column-gap": toLength(columnGap), + "--divided-row-column-gap": toLength(columnGap), ...style, } as CSSProperties } diff --git a/src/components/ErrorState/ErrorState.css b/src/components/ErrorState/ErrorState.css index 355f355..04ea7f5 100644 --- a/src/components/ErrorState/ErrorState.css +++ b/src/components/ErrorState/ErrorState.css @@ -11,19 +11,17 @@ min-height: 12.5rem; } - /* The icon disc. Tone sets the two custom properties below. + /* The icon disc, in the error tone unless a variant below retones it. Literals: Astryx's icon scale stops at `lg`; the disc is the 0.1 size. */ .uic-error-state__icon { - --uic-error-state-tone: var(--color-error); - --uic-error-state-tone-muted: var(--color-error-muted); display: flex; align-items: center; justify-content: center; width: 4rem; height: 4rem; margin-bottom: var(--spacing-6); - color: var(--uic-error-state-tone); - background-color: var(--uic-error-state-tone-muted); + color: var(--color-error); + background-color: var(--color-error-muted); border-radius: var(--radius-full); } @@ -33,13 +31,13 @@ } .uic-error-state--warning .uic-error-state__icon { - --uic-error-state-tone: var(--color-warning); - --uic-error-state-tone-muted: var(--color-warning-muted); + color: var(--color-warning); + background-color: var(--color-warning-muted); } .uic-error-state--accent .uic-error-state__icon { - --uic-error-state-tone: var(--color-accent); - --uic-error-state-tone-muted: var(--color-accent-muted); + color: var(--color-accent); + background-color: var(--color-accent-muted); } .uic-error-state__title { diff --git a/src/components/Form/FormItemVisual.css b/src/components/Form/FormItemVisual.css index 2116aa2..70314d8 100644 --- a/src/components/Form/FormItemVisual.css +++ b/src/components/Form/FormItemVisual.css @@ -1,11 +1,11 @@ /* * The form item shell and the inline form, laid out like antd's form * stylesheet. Hooks a product may set: - * --uic-form-item-margin-bottom space below each item - * --uic-form-item-gap label-to-control gap in a vertical item - * --uic-form-item-description-color explain, extra, tooltip glyph and the - * "(optional)" suffix - * --uic-form-item-line-height the item's line height + * --form-item-margin-bottom space below each item + * --form-item-gap label-to-control gap in a vertical item + * --form-item-line-height the item's line height + * Explain, extra, the tooltip glyph and the "(optional)" suffix take the + * theme's `--color-text-description` where it declares one. */ @layer ui-common { /* An inline form wraps its items on one line (antd `.ant-form-inline`). */ @@ -27,9 +27,9 @@ .uic-form-item { box-sizing: border-box; vertical-align: top; - margin-bottom: var(--uic-form-item-margin-bottom, var(--spacing-6)); + margin-bottom: var(--form-item-margin-bottom, var(--spacing-6)); font-size: var(--font-size-base); - line-height: var(--uic-form-item-line-height, var(--text-body-leading)); + line-height: var(--form-item-line-height, var(--text-body-leading)); } .uic-form-item[data-layout="inline"] { @@ -74,7 +74,7 @@ .uic-form-item__label-col[data-wrap] { overflow: unset; - line-height: var(--uic-form-item-line-height, var(--text-body-leading)); + line-height: var(--form-item-line-height, var(--text-body-leading)); white-space: unset; } @@ -84,7 +84,7 @@ } .uic-form-item[data-layout="vertical"] .uic-form-item__label-col { - padding: 0 0 var(--uic-form-item-gap, var(--spacing-2)); + padding: 0 0 var(--form-item-gap, var(--spacing-2)); margin: 0; white-space: initial; text-align: start; @@ -103,7 +103,7 @@ height: var(--size-element-md); color: var(--color-text-primary); font-size: var(--font-size-base); - line-height: var(--uic-form-item-line-height, var(--text-body-leading)); + line-height: var(--form-item-line-height, var(--text-body-leading)); } .uic-form-item[data-layout="vertical"] .uic-form-item__label { @@ -155,14 +155,14 @@ .uic-form-item__optional { display: inline-block; margin-inline-start: var(--spacing-1); - color: var(--uic-form-item-description-color, var(--color-text-secondary)); + color: var(--color-text-description, var(--color-text-secondary)); } .uic-form-item__tooltip { display: inline-flex; align-items: center; margin-inline-start: var(--spacing-1); - color: var(--uic-form-item-description-color, var(--color-text-secondary)); + color: var(--color-text-description, var(--color-text-secondary)); cursor: help; writing-mode: horizontal-tb; } @@ -222,9 +222,9 @@ .uic-form-item__explain, .uic-form-item__extra { clear: both; - color: var(--uic-form-item-description-color, var(--color-text-secondary)); + color: var(--color-text-description, var(--color-text-secondary)); font-size: var(--font-size-base); - line-height: var(--uic-form-item-line-height, var(--text-body-leading)); + line-height: var(--form-item-line-height, var(--text-body-leading)); } .uic-form-item__extra { @@ -252,7 +252,7 @@ align-items: center; justify-content: center; height: calc( - var(--font-size-base) * var(--uic-form-item-line-height, var(--text-body-leading)) + var(--font-size-base) * var(--form-item-line-height, var(--text-body-leading)) ); color: var(--color-accent); font-size: var(--font-size-base); diff --git a/src/components/ListBanner/ListBanner.css b/src/components/ListBanner/ListBanner.css index 625813d..fba5b1d 100644 --- a/src/components/ListBanner/ListBanner.css +++ b/src/components/ListBanner/ListBanner.css @@ -1,6 +1,6 @@ @layer ui-common { .uic-list-banner__list { - max-height: var(--uic-list-banner-max-height); + max-height: var(--list-banner-max-height); margin: 0; padding: 0; padding-top: var(--spacing-1); diff --git a/src/components/ListBanner/ListBanner.test.tsx b/src/components/ListBanner/ListBanner.test.tsx index 194a870..9f7590b 100644 --- a/src/components/ListBanner/ListBanner.test.tsx +++ b/src/components/ListBanner/ListBanner.test.tsx @@ -27,7 +27,7 @@ describe("ListBanner", () => { <ListBanner status="info" title="t" items={[{ content: "x" }]} />, ); expect( - screen.getByRole("list").style.getPropertyValue("--uic-list-banner-max-height"), + screen.getByRole("list").style.getPropertyValue("--list-banner-max-height"), ).toBe("165px"); unmount(); @@ -35,7 +35,7 @@ describe("ListBanner", () => { <ListBanner status="info" title="t" maxHeight={80} items={[{ content: "x" }]} />, ); const list = screen.getByRole("list"); - expect(list.style.getPropertyValue("--uic-list-banner-max-height")).toBe("80px"); + expect(list.style.getPropertyValue("--list-banner-max-height")).toBe("80px"); expect(list).toHaveClass("uic-list-banner__list"); expect(list).toHaveAttribute("tabindex", "0"); }); diff --git a/src/components/ListBanner/ListBanner.tsx b/src/components/ListBanner/ListBanner.tsx index 1f743ed..bf6f408 100644 --- a/src/components/ListBanner/ListBanner.tsx +++ b/src/components/ListBanner/ListBanner.tsx @@ -51,7 +51,7 @@ export function ListBanner({ className="uic-list-banner__list" style={ { - "--uic-list-banner-max-height": + "--list-banner-max-height": typeof maxHeight === "number" ? `${maxHeight}px` : maxHeight, // Inline: product sheets reset `ul` unlayered, which beats any layer. listStyle: "circle inside", diff --git a/src/components/Modal/Modal.css b/src/components/Modal/Modal.css index 40a0bd4..54a0991 100644 --- a/src/components/Modal/Modal.css +++ b/src/components/Modal/Modal.css @@ -3,13 +3,13 @@ * top layer and `::backdrop`, restated for a portalled tree. */ @layer ui-common { - /* The stack writes `--uic-modal-z` on open (modalStack.ts). The literal is + /* The stack writes `--modal-z` on open (modalStack.ts). The literal is only the fallback for a root that has not claimed a level yet: the default band's base. */ .uic-modal { position: fixed; inset: 0; - z-index: var(--uic-modal-z, 1100); + z-index: var(--modal-z, 1100); display: flex; align-items: center; justify-content: center; @@ -31,8 +31,8 @@ /* The animated element, apart from the mask, so only this subtree becomes a containing block while the keyframe's transform runs. */ .uic-modal__wrap { - --uic-modal-dir-x: 0px; - --uic-modal-dir-y: var(--spacing-4); + --modal-dir-x: 0px; + --modal-dir-y: var(--spacing-4); position: relative; display: flex; /* The surface can be narrower than the wrap (Dialog caps it), so the @@ -54,7 +54,7 @@ @keyframes uic-modal-enter { from { opacity: 0; - transform: translate(var(--uic-modal-dir-x), var(--uic-modal-dir-y)) scale(0.95); + transform: translate(var(--modal-dir-x), var(--modal-dir-y)) scale(0.95); } to { diff --git a/src/components/Modal/Modal.test.tsx b/src/components/Modal/Modal.test.tsx index ab55f9d..d007d56 100644 --- a/src/components/Modal/Modal.test.tsx +++ b/src/components/Modal/Modal.test.tsx @@ -49,8 +49,8 @@ const rootOf = (name?: string) => const getMask = () => document.querySelector(".uic-modal__mask") as HTMLElement; const levelOf = (root: HTMLElement) => - Number(root.style.getPropertyValue("--uic-modal-level")); -const zOf = (root: HTMLElement) => Number(root.style.getPropertyValue("--uic-modal-z")); + Number(root.style.getPropertyValue("--modal-level")); +const zOf = (root: HTMLElement) => Number(root.style.getPropertyValue("--modal-z")); afterEach(() => { configureModalZIndex(); diff --git a/src/components/Modal/Modal.tsx b/src/components/Modal/Modal.tsx index a8248e3..853dd9e 100644 --- a/src/components/Modal/Modal.tsx +++ b/src/components/Modal/Modal.tsx @@ -321,12 +321,12 @@ export function Modal({ window.matchMedia("(prefers-reduced-motion: reduce)").matches; if (!reduced && trigger instanceof HTMLElement && trigger !== document.body) { const { x, y } = getDialogDirection(trigger); - node.style.setProperty("--uic-modal-dir-x", `${x}px`); - node.style.setProperty("--uic-modal-dir-y", `${y}px`); + node.style.setProperty("--modal-dir-x", `${x}px`); + node.style.setProperty("--modal-dir-y", `${y}px`); } return () => { - node.style.removeProperty("--uic-modal-dir-x"); - node.style.removeProperty("--uic-modal-dir-y"); + node.style.removeProperty("--modal-dir-x"); + node.style.removeProperty("--modal-dir-y"); }; }, [isActive, containerRef]); diff --git a/src/components/Modal/modalStack.ts b/src/components/Modal/modalStack.ts index 3741dce..d478e50 100644 --- a/src/components/Modal/modalStack.ts +++ b/src/components/Modal/modalStack.ts @@ -46,8 +46,8 @@ export function configureModalZIndex(next?: Partial<ModalZIndexBand>): void { band = { ...DEFAULT_BAND, ...next }; } -const LEVEL_CSS_VAR = "--uic-modal-level"; -const Z_INDEX_CSS_VAR = "--uic-modal-z"; +const LEVEL_CSS_VAR = "--modal-level"; +const Z_INDEX_CSS_VAR = "--modal-z"; /** A claim on the stack. Released by reference, never by level. */ export interface ModalLevelEntry { @@ -207,7 +207,7 @@ export function releaseModalLevel(entry: ModalLevelEntry): void { /** * Claims a level for `rootRef` while `isOpen`, and writes the level and the - * resolved z-index onto the root as `--uic-modal-level` and `--uic-modal-z`. + * resolved z-index onto the root as `--modal-level` and `--modal-z`. * Returns whether this surface is the topmost one; gate a focus trap on it. */ export function useModalLevel( diff --git a/src/components/NotificationStack/NotificationStack.css b/src/components/NotificationStack/NotificationStack.css index e4e3ecb..5be2105 100644 --- a/src/components/NotificationStack/NotificationStack.css +++ b/src/components/NotificationStack/NotificationStack.css @@ -21,13 +21,13 @@ /* Stops below the reserved top inset (an app header), so what is there stays reachable however many notices are open. */ max-height: calc( - 100dvh - var(--uic-notification-stack-inset-top, 0px) - var(--spacing-6) * 2 + 100dvh - var(--notification-stack-inset-top, 0px) - var(--spacing-6) * 2 ); overflow: hidden auto; overscroll-behavior: contain; /* One above the top of Modal's default z-index band (1100..10999), so a notice stays visible and clickable over an open modal. */ - z-index: var(--uic-notification-stack-z, 11000); + z-index: var(--notification-stack-z, 11000); pointer-events: none; } @@ -75,7 +75,7 @@ the dismiss button in it, off screen. Not on the item: the exit animation drives the item's own `max-height`. */ .uic-notification-stack__body { - max-height: var(--uic-notification-body-max-height, 30vh); + max-height: var(--notification-stack-body-max-height, 30vh); overflow-y: auto; overscroll-behavior: contain; } diff --git a/src/components/NotificationStack/NotificationStack.tsx b/src/components/NotificationStack/NotificationStack.tsx index 3d231c5..0f047ae 100644 --- a/src/components/NotificationStack/NotificationStack.tsx +++ b/src/components/NotificationStack/NotificationStack.tsx @@ -21,10 +21,10 @@ * on screen; the stack scrolls once it reaches the top inset. * * Layout hooks, set on the stack or an ancestor: - * `--uic-notification-stack-z` (stacking order, default 11000, one above - * `Modal`'s default band), `--uic-notification-stack-inset-top` (space kept + * `--notification-stack-z` (stacking order, default 11000, one above + * `Modal`'s default band), `--notification-stack-inset-top` (space kept * free above the stack, such as an app header, default 0) and - * `--uic-notification-body-max-height` (one notice's scroll cap, default 30vh). + * `--notification-stack-body-max-height` (one notice's scroll cap, default 30vh). * * e2e hooks: each notice carries `data-notification-key`, `data-status` and * `data-paused`; its title and description carry `data-testid` diff --git a/src/components/OverlayScrollbar/OverlayScrollbar.css b/src/components/OverlayScrollbar/OverlayScrollbar.css index 5c412e6..ef6f44d 100644 --- a/src/components/OverlayScrollbar/OverlayScrollbar.css +++ b/src/components/OverlayScrollbar/OverlayScrollbar.css @@ -27,7 +27,7 @@ /* Only the thumb takes pointer events; content under the track stays clickable. */ pointer-events: none; - z-index: var(--uic-overlay-scrollbar-z, 1); + z-index: var(--overlay-scrollbar-z, 1); } /* Presence is the scrollability cue: the thumb stays while the content diff --git a/src/components/OverlayScrollbar/OverlayScrollbar.tsx b/src/components/OverlayScrollbar/OverlayScrollbar.tsx index 24f7f11..4d81ba5 100644 --- a/src/components/OverlayScrollbar/OverlayScrollbar.tsx +++ b/src/components/OverlayScrollbar/OverlayScrollbar.tsx @@ -14,7 +14,7 @@ * `position: absolute`. On touch-primary platforms it renders nothing and * leaves the native indicator alone. * - * The track's stacking order is `--uic-overlay-scrollbar-z` (default 1); + * The track's stacking order is `--overlay-scrollbar-z` (default 1); * raise it to clear sticky chrome inside the scroll column. * * @example diff --git a/src/components/ProgressWithLabel/ProgressWithLabel.css b/src/components/ProgressWithLabel/ProgressWithLabel.css index ccc093c..dcab419 100644 --- a/src/components/ProgressWithLabel/ProgressWithLabel.css +++ b/src/components/ProgressWithLabel/ProgressWithLabel.css @@ -11,7 +11,7 @@ /* A hairline between the frame and the fill. */ padding: var(--border-width); border: var(--border-width) solid var(--color-border-emphasized); - border-radius: var(--uic-progress-with-label-radius, var(--radius-inner)); + border-radius: var(--progress-with-label-radius, var(--radius-inner)); background-color: var(--color-background-muted); } @@ -19,18 +19,6 @@ flex: 1; } - .uic-progress-with-label--sm { - --uic-progress-with-label-font-size: var(--font-size-sm); - } - - .uic-progress-with-label--md { - --uic-progress-with-label-font-size: var(--font-size-base); - } - - .uic-progress-with-label--lg { - --uic-progress-with-label-font-size: var(--font-size-lg); - } - .uic-progress-with-label__fill { position: absolute; top: 0; @@ -39,7 +27,7 @@ height: 100%; overflow: hidden; opacity: 0.7; - background-color: var(--uic-progress-with-label-color, var(--color-success)); + background-color: var(--color-success); } /* Positioned, so it paints above the fill. */ @@ -52,8 +40,16 @@ min-width: 0; } - .uic-progress-with-label__text { - font-size: var(--uic-progress-with-label-font-size); + .uic-progress-with-label--sm .uic-progress-with-label__text { + font-size: var(--font-size-sm); + } + + .uic-progress-with-label--md .uic-progress-with-label__text { + font-size: var(--font-size-base); + } + + .uic-progress-with-label--lg .uic-progress-with-label__text { + font-size: var(--font-size-lg); } .uic-progress-with-label__value { diff --git a/src/components/ProgressWithLabel/ProgressWithLabel.test.tsx b/src/components/ProgressWithLabel/ProgressWithLabel.test.tsx index b6f3ed5..f6a8495 100644 --- a/src/components/ProgressWithLabel/ProgressWithLabel.test.tsx +++ b/src/components/ProgressWithLabel/ProgressWithLabel.test.tsx @@ -69,9 +69,10 @@ describe("ProgressWithLabel", () => { />, ); const root = rootOf(container); - expect(root.style.getPropertyValue("--uic-progress-with-label-color")).toBe( - "var(--color-error)", - ); + expect( + container.querySelector<HTMLElement>(".uic-progress-with-label__fill")?.style + .backgroundColor, + ).toBe("var(--color-error)"); expect(root).toHaveClass("uic-progress-with-label--lg"); expect(root.style.borderStyle).toBe("none"); expect(screen.getByText("x").style.height).toBe("8px"); diff --git a/src/components/ProgressWithLabel/ProgressWithLabel.tsx b/src/components/ProgressWithLabel/ProgressWithLabel.tsx index a6f6e91..1132a74 100644 --- a/src/components/ProgressWithLabel/ProgressWithLabel.tsx +++ b/src/components/ProgressWithLabel/ProgressWithLabel.tsx @@ -6,9 +6,8 @@ * table cells and cards ("CPU 3 / 8 cores"). A missing or NaN `value` draws * no fill and greys the value label. * - * The fill is `color`, or `--uic-progress-with-label-color` (default Astryx - * `--color-success`). The frame's corner is `--uic-progress-with-label-radius` - * (default `--radius-inner`). + * The fill is `color`, by default Astryx `--color-success`. The frame's + * corner is `--progress-with-label-radius` (default `--radius-inner`). * * @example * <ProgressWithLabel label="CPU" valueLabel="3 / 8" value={37.5} width={160} /> @@ -68,13 +67,13 @@ export function ProgressWithLabel({ .join(" ")} style={{ ...(hasWidth ? { width } : null), - ...(color - ? ({ "--uic-progress-with-label-color": color } as CSSProperties) - : null), ...style, }} > - <div className="uic-progress-with-label__fill" style={{ width: `${fill}%` }} /> + <div + className="uic-progress-with-label__fill" + style={{ width: `${fill}%`, ...(color ? { backgroundColor: color } : null) }} + /> <div className="uic-progress-with-label__labels"> <Text className="uic-progress-with-label__text" style={labelStyle}> {label} diff --git a/src/components/StatCard/StatCard.css b/src/components/StatCard/StatCard.css index 0c0bbca..116273b 100644 --- a/src/components/StatCard/StatCard.css +++ b/src/components/StatCard/StatCard.css @@ -4,9 +4,6 @@ */ @layer ui-common { .uic-stat-card { - /* The tone colour, read by the corner accent, the value and the icon. */ - --uic-stat-card-tone: transparent; - --uic-stat-card-tone-muted: var(--color-background-muted); position: relative; display: flex; flex-direction: column; @@ -16,7 +13,8 @@ overflow: hidden; } - /* The tone at a glance, visible even when the value is zero. */ + /* The tone at a glance, visible even when the value is zero. The tone + rules at the end colour it; the default tone leaves it clear. */ .uic-stat-card::before { content: ""; position: absolute; @@ -24,7 +22,7 @@ inset-inline-start: 0; width: var(--spacing-3); height: var(--spacing-3); - background-color: var(--uic-stat-card-tone); + background-color: transparent; clip-path: polygon(0 0, 100% 0, 0 100%); pointer-events: none; } @@ -51,7 +49,7 @@ width: var(--size-element-sm); height: var(--size-element-sm); border-radius: var(--radius-inner); - background: var(--uic-stat-card-tone-muted); + background: var(--color-background-muted); color: var(--color-icon-secondary); flex-shrink: 0; } @@ -162,34 +160,58 @@ height: var(--size-element-md); } - /* Tones. */ - .uic-stat-card--tone-success { - --uic-stat-card-tone: var(--color-success); - --uic-stat-card-tone-muted: var(--color-success-muted); + /* Tones: the corner accent, the icon disc and the value and icon colour. */ + .uic-stat-card--tone-success::before { + background-color: var(--color-success); } - .uic-stat-card--tone-warning { - --uic-stat-card-tone: var(--color-warning); - --uic-stat-card-tone-muted: var(--color-warning-muted); + .uic-stat-card--tone-success .uic-stat-card__icon { + background: var(--color-success-muted); } - .uic-stat-card--tone-danger { - --uic-stat-card-tone: var(--color-error); - --uic-stat-card-tone-muted: var(--color-error-muted); + .uic-stat-card--tone-success :is(.uic-stat-card__value, .uic-stat-card__icon) { + color: var(--color-success); + } + + .uic-stat-card--tone-warning::before { + background-color: var(--color-warning); + } + + .uic-stat-card--tone-warning .uic-stat-card__icon { + background: var(--color-warning-muted); + } + + .uic-stat-card--tone-warning :is(.uic-stat-card__value, .uic-stat-card__icon) { + color: var(--color-warning); + } + + .uic-stat-card--tone-danger::before { + background-color: var(--color-error); + } + + .uic-stat-card--tone-danger .uic-stat-card__icon { + background: var(--color-error-muted); + } + + .uic-stat-card--tone-danger :is(.uic-stat-card__value, .uic-stat-card__icon) { + color: var(--color-error); + } + + /* Info has no Astryx token: a theme may declare `--color-info` (the Lablup + theme does), and the accent stands in where none does. */ + .uic-stat-card--tone-info::before { + background-color: var(--color-info, var(--color-accent)); } - /* Info has no Astryx token; the Lablup theme declares `--uic-color-info`. */ - .uic-stat-card--tone-info { - --uic-stat-card-tone: var(--uic-color-info, var(--color-accent)); - --uic-stat-card-tone-muted: color-mix( + .uic-stat-card--tone-info .uic-stat-card__icon { + background: color-mix( in srgb, - var(--uic-color-info, var(--color-accent)) 12%, + var(--color-info, var(--color-accent)) 12%, transparent ); } - .uic-stat-card:not(.uic-stat-card--tone-default) .uic-stat-card__value, - .uic-stat-card:not(.uic-stat-card--tone-default) .uic-stat-card__icon { - color: var(--uic-stat-card-tone); + .uic-stat-card--tone-info :is(.uic-stat-card__value, .uic-stat-card__icon) { + color: var(--color-info, var(--color-accent)); } } diff --git a/src/components/TextHighlighter/TextHighlighter.css b/src/components/TextHighlighter/TextHighlighter.css index f5a2ffa..faac869 100644 --- a/src/components/TextHighlighter/TextHighlighter.css +++ b/src/components/TextHighlighter/TextHighlighter.css @@ -1,8 +1,5 @@ @layer ui-common { .uic-text-highlighter__match { - background-color: var( - --uic-text-highlighter-background, - var(--color-warning-muted) - ); + background-color: var(--color-warning-border-hover, var(--color-warning-muted)); } } diff --git a/src/components/TextHighlighter/TextHighlighter.test.tsx b/src/components/TextHighlighter/TextHighlighter.test.tsx index 1bf8aef..675c786 100644 --- a/src/components/TextHighlighter/TextHighlighter.test.tsx +++ b/src/components/TextHighlighter/TextHighlighter.test.tsx @@ -56,7 +56,7 @@ describe("TextHighlighter", () => { it("paints the mark from the custom property, defaulting to warning-muted", () => { const css = readFileSync(join(__dirname, "TextHighlighter.css"), "utf8"); expect(css).toMatch( - /background-color:\s*var\(\s*--uic-text-highlighter-background,\s*var\(--color-warning-muted\)\s*\)/, + /background-color:\s*var\(\s*--color-warning-border-hover,\s*var\(--color-warning-muted\)\s*\)/, ); }); }); diff --git a/src/components/TextHighlighter/TextHighlighter.tsx b/src/components/TextHighlighter/TextHighlighter.tsx index ededcc3..3f3e70d 100644 --- a/src/components/TextHighlighter/TextHighlighter.tsx +++ b/src/components/TextHighlighter/TextHighlighter.tsx @@ -5,8 +5,9 @@ * results. The keyword is matched literally (regex characters are escaped). * Without a keyword the text renders plain; without text nothing renders. * - * The mark's background is `--uic-text-highlighter-background`, by default - * Astryx `--color-warning-muted`; set it on any ancestor to change it. + * The mark's background is the theme's `--color-warning-border-hover` where + * the theme declares one (the Backend.AI WebUI theme does), else Astryx + * `--color-warning-muted`. * * @example * <TextHighlighter keyword={search}>{row.name}</TextHighlighter> diff --git a/src/components/UnitGrid/UnitGrid.css b/src/components/UnitGrid/UnitGrid.css index 1121252..b6c3cd9 100644 --- a/src/components/UnitGrid/UnitGrid.css +++ b/src/components/UnitGrid/UnitGrid.css @@ -1,5 +1,5 @@ /* - * UnitGrid. The seven group hues and the two letter inks are --uic- custom + * UnitGrid. The seven group hues and the two letter inks are custom * properties on the root, so a product can hand in its own palette from a * class of its own. Hue overrides are stored by palette index: keep index N * meaning the same hue in light and dark. @@ -9,23 +9,23 @@ */ @layer ui-common { .uic-unit-grid { - --uic-unit-grid-group-1: var(--color-icon-purple); - --uic-unit-grid-group-2: var(--color-icon-blue); - --uic-unit-grid-group-3: var(--color-icon-green); - --uic-unit-grid-group-4: var(--color-icon-cyan); - --uic-unit-grid-group-5: var(--color-icon-orange); - --uic-unit-grid-group-6: var(--color-icon-red); - --uic-unit-grid-group-7: var(--color-icon-gray); - --uic-unit-grid-cell-stroke: var(--color-border); + --unit-grid-group-1: var(--color-icon-purple); + --unit-grid-group-2: var(--color-icon-blue); + --unit-grid-group-3: var(--color-icon-green); + --unit-grid-group-4: var(--color-icon-cyan); + --unit-grid-group-5: var(--color-icon-orange); + --unit-grid-group-6: var(--color-icon-red); + --unit-grid-group-7: var(--color-icon-gray); + --unit-grid-cell-stroke: var(--color-border); /* Translucent, so a partial cell stays readable when the unit's own fill is the same muted colour. */ - --uic-unit-grid-cell-empty: color-mix( + --unit-grid-cell-empty: color-mix( in srgb, var(--color-background-muted) 60%, transparent ); - --uic-unit-grid-ink-dark: var(--color-on-light); - --uic-unit-grid-ink-light: var(--color-on-dark); + --unit-grid-ink-dark: var(--color-on-light); + --unit-grid-ink-light: var(--color-on-dark); } .uic-unit-grid__wrapper { @@ -47,13 +47,13 @@ /* * Fixed, so it escapes a host card's overflow clipping; the component sets - * the coordinates and the border colour inline. --uic-unit-grid-popover-z + * the coordinates and the border colour inline. --unit-grid-popover-z * places it on a product's z-index ladder. The width bounds are a readable * measure, which Astryx has no token for. */ .uic-unit-grid__popover { position: fixed; - z-index: var(--uic-unit-grid-popover-z, 1000); + z-index: var(--unit-grid-popover-z, 1000); min-width: 200px; max-width: 320px; padding: var(--spacing-2); diff --git a/src/components/UnitGrid/UnitGrid.test.tsx b/src/components/UnitGrid/UnitGrid.test.tsx index 0a59730..f86d58a 100644 --- a/src/components/UnitGrid/UnitGrid.test.tsx +++ b/src/components/UnitGrid/UnitGrid.test.tsx @@ -256,12 +256,12 @@ describe("UnitGrid strings and theming", () => { expect(screen.getByRole("button", { name: "Hue 4" })).toBeInTheDocument(); }); - it("paints groups from the --uic-unit-grid-group-N hues unless given a palette", () => { + it("paints groups from the --unit-grid-group-N hues unless given a palette", () => { const plate = (container: HTMLElement, key: string) => container.querySelector<SVGPathElement>(`path[data-group-key="${key}"]`)!; const { container, rerender } = render(<UnitGrid groups={GROUPS} columns={8} />); - expect(plate(container, "alpha").style.fill).toBe("var(--uic-unit-grid-group-1)"); - expect(plate(container, "beta").style.fill).toBe("var(--uic-unit-grid-group-2)"); + expect(plate(container, "alpha").style.fill).toBe("var(--unit-grid-group-1)"); + expect(plate(container, "beta").style.fill).toBe("var(--unit-grid-group-2)"); rerender( <UnitGrid groups={GROUPS} @@ -281,10 +281,9 @@ describe("UnitGrid strings and theming", () => { ); expect(inks).toHaveLength(2); for (const ink of inks) { - expect([ - "var(--uic-unit-grid-ink-dark)", - "var(--uic-unit-grid-ink-light)", - ]).toContain(ink); + expect(["var(--unit-grid-ink-dark)", "var(--unit-grid-ink-light)"]).toContain( + ink, + ); } }); diff --git a/src/components/UnitGrid/UnitGrid.tsx b/src/components/UnitGrid/UnitGrid.tsx index 25b9aa3..19bad08 100644 --- a/src/components/UnitGrid/UnitGrid.tsx +++ b/src/components/UnitGrid/UnitGrid.tsx @@ -54,7 +54,7 @@ const DEFAULT_MAX_UNITS_PER_GROUP = 256; /** The seven hues declared in UnitGrid.css. */ const DEFAULT_PALETTE = Array.from( { length: 7 }, - (_, i) => `var(--uic-unit-grid-group-${i + 1})`, + (_, i) => `var(--unit-grid-group-${i + 1})`, ); // Hover card geometry: clamp width, flip threshold, anchor offset, and the // hide delay that lets the pointer travel onto the card. @@ -67,11 +67,11 @@ const PLATE_DASH_PATTERN = "6 4"; // The initial is vertically centred and fills grow bottom-up, so below this // fraction the initial sits on the empty underlay: pick ink against that. const LETTER_ON_EMPTY_MAX_FRACTION = 0.6; -const EMPTY_FILL_COLOR = "var(--uic-unit-grid-cell-empty)"; +const EMPTY_FILL_COLOR = "var(--unit-grid-cell-empty)"; // The empty fill is translucent; composite it over this before judging it. const EMPTY_FILL_BACKDROP = "var(--color-background-card)"; -const INK_DARK = "var(--uic-unit-grid-ink-dark)"; -const INK_LIGHT = "var(--uic-unit-grid-ink-light)"; +const INK_DARK = "var(--unit-grid-ink-dark)"; +const INK_LIGHT = "var(--unit-grid-ink-light)"; const cx = (...names: Array<string | false | undefined>) => names.filter(Boolean).join(" "); @@ -228,7 +228,7 @@ export interface UnitGridProps extends Omit< groups: UnitGridGroup[]; /** How groups flow along the lattice. @default 'serpentine' */ layout?: UnitGridLayout; - /** Group hues, cycled in flow order. @default the seven --uic-unit-grid-group-N hues */ + /** Group hues, cycled in flow order. @default the seven --unit-grid-group-N hues */ groupPalette?: string[]; /** Controlled palette-index overrides, keyed by group key. */ hueOverrides?: Record<string, number>; @@ -566,7 +566,7 @@ export function UnitGrid({ fill: isPartial ? EMPTY_FILL_COLOR : cell.unit.color, stroke: cellHovered ? hueFor(cell.groupKey) - : "var(--uic-unit-grid-cell-stroke)", + : "var(--unit-grid-cell-stroke)", }} strokeWidth={cellHovered ? 1 : 0.5} /> diff --git a/src/components/componentStyles.test.ts b/src/components/componentStyles.test.ts index cc959d1..9d5c67c 100644 --- a/src/components/componentStyles.test.ts +++ b/src/components/componentStyles.test.ts @@ -5,9 +5,12 @@ * to it and the app's `components` layer and unlayered rules beat it. * - Every class selector is a `uic-` BEM name. Astryx's own classes are * not restyled from here. - * - Every `var()` names an Astryx token or a `--uic-` property of ui-common's - * own. No 0.1 `--token-*` name, and no colour literal: a colour comes from - * the theme. + * - Every `var()` names an Astryx token, a theme token Astryx lacks (read + * with a fallback), or a knob of the component, `--<component>-<property>`. + * No 0.1 `--token-*` name, and no colour literal: a colour comes from the + * theme. + * - No knob collides with a custom property Astryx core, lab or the neutral + * theme declares or reads, or with a WebUI name. * - No focus styling. Astryx primitives draw focus; a second indicator here * is the shape of the 0.1 contrast defect (issue #7). */ @@ -27,6 +30,44 @@ const ASTRYX_TOKENS = new Set( ].map((m) => m[1] ?? ""), ); +/** + * Theme tokens Astryx has no token for, in Astryx's naming. The Backend.AI + * WebUI theme declares all three (`--color-info` the Lablup theme too); a + * component reads them only with an Astryx fallback. + */ +const THEME_EXTENSION_TOKENS = new Set([ + "--color-info", + "--color-text-description", + "--color-warning-border-hover", +]); + +/** Every custom property Astryx core, lab or the neutral theme declares or reads. */ +const ASTRYX_NAMES = new Set( + [ + "node_modules/@astryxdesign/core/dist/astryx.css", + "node_modules/@astryxdesign/lab/dist/lab.css", + "node_modules/@astryxdesign/theme-neutral/dist/theme.css", + "node_modules/@astryxdesign/core/dist/theme/tokens.stylex.js", + ].flatMap((file) => + [...readFileSync(join(ROOT, file), "utf8").matchAll(/(--[A-Za-z_][\w-]*)/g)].map( + (m) => m[1] ?? "", + ), + ), +); + +/** + * Astryx component variables a ui-common sheet sets on purpose, to tune the + * Astryx primitive it wraps. Any other Astryx name here is a collision. + */ +const ASTRYX_VARIABLES_SET = new Set([ + "--container-padding-block-start", + "--container-padding-block-end", +]); + +/** `UnitGrid` -> `unit-grid`. */ +const kebab = (name: string) => + name.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase(); + function cssFiles(dir: string): string[] { const out: string[] = []; for (const entry of readdirSync(dir, { withFileTypes: true })) { @@ -62,15 +103,30 @@ describe("component stylesheets", () => { expect(foreign).toEqual([]); }); - it.each(FILES)("$name reads Astryx tokens and --uic- properties only", ({ css }) => { - const names = [...css.matchAll(/var\(\s*(--[A-Za-z0-9-]+)/g)].map( - (m) => m[1] ?? "", - ); - const unknown = names.filter( - (n) => !ASTRYX_TOKENS.has(n) && !n.startsWith("--uic-"), - ); - expect(unknown).toEqual([]); - }); + it.each(FILES)( + "$name reads Astryx tokens, theme tokens and its own knobs only", + ({ name, css }) => { + const own = `--${kebab(name.split(/[\\/]/)[0] ?? "")}-`; + const names = [...css.matchAll(/var\(\s*(--[A-Za-z0-9-]+)/g)].map( + (m) => m[1] ?? "", + ); + const unknown = names.filter( + (n) => + !ASTRYX_TOKENS.has(n) && !THEME_EXTENSION_TOKENS.has(n) && !n.startsWith(own), + ); + expect(unknown).toEqual([]); + }, + ); + + it.each(FILES)( + "$name reads a theme token Astryx lacks only with a fallback", + ({ css }) => { + const bare = [...css.matchAll(/var\(\s*(--[A-Za-z0-9-]+)\s*\)/g)] + .map((m) => m[1] ?? "") + .filter((n) => THEME_EXTENSION_TOKENS.has(n)); + expect(bare).toEqual([]); + }, + ); it.each(FILES)("$name declares no colour literal", ({ css }) => { const literals = [ @@ -105,3 +161,70 @@ describe("StatCard value sizing", () => { expect(body).not.toMatch(/--text-heading/); }); }); + +describe("component custom properties", () => { + /** Every custom property a component sheet or module names, by component. */ + const knobs = new Map<string, string>(); + function collect(dir: string, component: string) { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const path = join(dir, entry.name); + if (entry.isDirectory()) { + collect(path, component); + continue; + } + if ( + !/\.(css|tsx?)$/.test(entry.name) || + /\.(test|stories)\.tsx?$/.test(entry.name) + ) { + continue; + } + const source = withoutComments(readFileSync(path, "utf8")); + for (const m of source.matchAll( + /(?<![\w-])(--[a-z][a-z0-9-]*[a-z0-9])(?![\w-])/g, + )) { + const n = m[1] ?? ""; + if (ASTRYX_TOKENS.has(n) || THEME_EXTENSION_TOKENS.has(n)) continue; + if (ASTRYX_VARIABLES_SET.has(n)) continue; + knobs.set(n, component); + } + } + } + for (const entry of readdirSync(COMPONENTS_DIR, { withFileTypes: true })) { + if (entry.isDirectory()) collect(join(COMPONENTS_DIR, entry.name), entry.name); + } + const all = [...knobs.entries()].map(([n, component]) => ({ n, component })); + + it("finds them", () => { + expect(all.map((k) => k.n)).toEqual( + expect.arrayContaining([ + "--modal-z", + "--data-grid-max-height", + "--digit-pop-in-index", + ]), + ); + }); + + it.each(all)( + "$n is named --<component>-<property> after $component", + ({ n, component }) => { + expect(n.startsWith(`--${kebab(component)}-`)).toBe(true); + }, + ); + + it.each(all)("$n collides with no Astryx or WebUI name", ({ n }) => { + expect(ASTRYX_NAMES.has(n)).toBe(false); + expect(n).not.toMatch( + /^--(uic|bai|token|astryx|color|spacing|radius|font|text|x)-/, + ); + }); + + it("the collision check sees Astryx's component variables", () => { + for (const n of [ + "--dialog-dir-x", + "--table-sticky-background", + "--spinner-color", + ]) { + expect(ASTRYX_NAMES.has(n)).toBe(true); + } + }); +}); diff --git a/src/globalStyles.test.ts b/src/globalStyles.test.ts index 308ed7f..72e3eaf 100644 --- a/src/globalStyles.test.ts +++ b/src/globalStyles.test.ts @@ -41,7 +41,7 @@ const ASTRYX_TOKENS = new Set( ); /** Tokens a ui-common theme declares that Astryx does not. */ -const UI_COMMON_TOKENS = new Set(["--uic-color-info"]); +const UI_COMMON_TOKENS = new Set(["--color-info"]); /** The body of the single `@layer ui-common { ... }` block, or a failure. */ function layerBody(source: string): string { diff --git a/src/legacy-tokens.css b/src/legacy-tokens.css index 0a36e82..a34936d 100644 --- a/src/legacy-tokens.css +++ b/src/legacy-tokens.css @@ -94,9 +94,9 @@ --token-colorFillTertiary: #f9fafb; /* Accent and status. Astryx has no info token; the Lablup theme declares - one as `--uic-color-info`, and other themes fall back to the literal. */ + one as `--color-info`, and other themes fall back to the literal. */ --token-colorError: var(--color-error); - --token-colorInfo: var(--uic-color-info, #0066cc); + --token-colorInfo: var(--color-info, #0066cc); --token-colorLink: var(--color-text-accent); --token-colorLinkHover: #7c3aed; --token-colorPrimary: var(--color-accent); diff --git a/src/theme/lablup/built/lablup.js b/src/theme/lablup/built/lablup.js index e66f742..b4ca430 100644 --- a/src/theme/lablup/built/lablup.js +++ b/src/theme/lablup/built/lablup.js @@ -189,7 +189,8 @@ export const lablupTheme = { "--shadow-inset-warning": "inset 0px 0px 0px 2px #f8d36a4D", "--shadow-inset-error": "inset 0px 0px 0px 2px #de47454D", "--color-background-inverted": "light-dark(#211A16, #FFFBF8)", - "--color-track": "light-dark(#B8A89F, #51443C)" + "--color-track": "light-dark(#B8A89F, #51443C)", + "--color-info": "light-dark(#0066cc, #009bdd)" }, localTokens: { "--astryx-theme-neutral-color-status-fill-accent": "light-dark(#0074e2, #6d9cfe)", @@ -201,8 +202,7 @@ export const lablupTheme = { "--astryx-theme-neutral-color-on-tint-overlay-hover": "light-dark(#fafafa1A, #0a0a0a1A)", "--astryx-theme-neutral-color-on-tint-overlay-pressed": "light-dark(#fafafa33, #0a0a0a33)", "--astryx-theme-neutral-color-destructive-overlay-hover": "light-dark(#ff7f770D, #ee736c0D)", - "--astryx-theme-neutral-color-destructive-overlay-pressed": "light-dark(#ff7f771A, #ee736c1A)", - "--uic-color-info": "light-dark(#0066cc, #009bdd)" + "--astryx-theme-neutral-color-destructive-overlay-pressed": "light-dark(#ff7f771A, #ee736c1A)" }, __localTokenOwners: { "--astryx-theme-neutral-color-status-fill-accent": "neutral", @@ -214,8 +214,7 @@ export const lablupTheme = { "--astryx-theme-neutral-color-on-tint-overlay-hover": "neutral", "--astryx-theme-neutral-color-on-tint-overlay-pressed": "neutral", "--astryx-theme-neutral-color-destructive-overlay-hover": "neutral", - "--astryx-theme-neutral-color-destructive-overlay-pressed": "neutral", - "--uic-color-info": "lablup" + "--astryx-theme-neutral-color-destructive-overlay-pressed": "neutral" }, __localTokenLineage: ["neutral","lablup"], components: { diff --git a/src/theme/lablup/built/theme.css b/src/theme/lablup/built/theme.css index af17aa8..70f3052 100644 --- a/src/theme/lablup/built/theme.css +++ b/src/theme/lablup/built/theme.css @@ -317,6 +317,7 @@ --shadow-inset-error: inset 0px 0px 0px 2px #de47454D; --color-background-inverted: light-dark(#211A16, #FFFBF8); --color-track: light-dark(#B8A89F, #51443C); + --color-info: light-dark(#0066cc, #009bdd); --astryx-theme-neutral-color-status-fill-accent: light-dark(#0074e2, #6d9cfe); --astryx-theme-neutral-color-status-fill-success: light-dark(#198100, #64af4c); --astryx-theme-neutral-color-status-fill-warning: #ffce2f; @@ -327,7 +328,6 @@ --astryx-theme-neutral-color-on-tint-overlay-pressed: light-dark(#fafafa33, #0a0a0a33); --astryx-theme-neutral-color-destructive-overlay-hover: light-dark(#ff7f770D, #ee736c0D); --astryx-theme-neutral-color-destructive-overlay-pressed: light-dark(#ff7f771A, #ee736c1A); - --uic-color-info: light-dark(#0066cc, #009bdd); } .astryx-heading[data-level="1"] { diff --git a/src/theme/lablup/lablupTheme.test.ts b/src/theme/lablup/lablupTheme.test.ts index 4ba464d..105c0ee 100644 --- a/src/theme/lablup/lablupTheme.test.ts +++ b/src/theme/lablup/lablupTheme.test.ts @@ -53,9 +53,10 @@ describe("lablup theme", () => { ]); }); - it("declares the info hue as a theme-local token", () => { - const local = (builtTheme as { localTokens?: Record<string, string> }).localTokens; - expect(pair(local?.[LABLUP_INFO_TOKEN])).toEqual([ + it("declares the info hue as --color-info, Astryx's naming", () => { + expect(LABLUP_INFO_TOKEN).toBe("--color-info"); + const tokens = builtTheme.tokens as Record<string, string>; + expect(pair(tokens[LABLUP_INFO_TOKEN])).toEqual([ tokenValue("base.css", "colorInfo"), tokenValue("themes/orange-dark.css", "colorInfo"), ]); diff --git a/src/theme/lablup/lablupTheme.ts b/src/theme/lablup/lablupTheme.ts index 6365ddb..59ac2d5 100644 --- a/src/theme/lablup/lablupTheme.ts +++ b/src/theme/lablup/lablupTheme.ts @@ -19,10 +19,11 @@ import { neutralIconRegistry, neutralTheme } from "@astryxdesign/theme-neutral"; export const LABLUP_ACCENT: [string, string] = ["#FF7A00", "#DC6B03"]; /** - * Astryx has no info token, so the info hue is a theme-local token. It is - * `uic-` prefixed so it cannot collide with a token Astryx adds later. + * Astryx has no info token, so the theme declares one under Astryx's naming, + * `--color-info`, the same name the Backend.AI WebUI theme uses. Components + * read it with an Astryx fallback, so a theme without it still resolves. */ -export const LABLUP_INFO_TOKEN = "--uic-color-info"; +export const LABLUP_INFO_TOKEN = "--color-info"; export const lablupTheme = defineTheme({ name: "lablup", @@ -41,9 +42,13 @@ export const lablupTheme = defineTheme({ '"Ubuntu Sans", "Pretendard Variable", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', "--font-family-heading": '"Ubuntu Sans", "Pretendard Variable", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', - }, - localTokens: { - [LABLUP_INFO_TOKEN]: ["#0066cc", "#009bdd"], + // In `tokens`, not `localTokens`: a theme extending this one can then + // override it through `tokens`, as the WebUI theme sets it. Astryx's + // `TokenName` does not list it, hence the widened map. + ...({ [LABLUP_INFO_TOKEN]: ["#0066cc", "#009bdd"] } as Record< + string, + [string, string] + >), }, }); From f34cbf2ac9dc763f8de5666f050e98a38a1e98b6 Mon Sep 17 00:00:00 2001 From: Jongeun <jongeun@lablup.com> Date: Tue, 29 Sep 2026 06:45:08 +0000 Subject: [PATCH 63/87] chore: prepare 0.2.0-alpha.12 --- CHANGELOG.md | 45 +++++++++++++++++++++++++++++++++++++++++++++ package.json | 2 +- 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d138f63..c0a5bab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,51 @@ Versioning follows the policy in [CONTRIBUTING.md](CONTRIBUTING.md#versioning). ## [Unreleased] +## [0.2.0-alpha.12] + +Custom properties take Astryx's naming as is: no `--uic-` prefix. + +### Changed + +- **Breaking for alpha consumers: every `--uic-*` custom property is + renamed or gone.** `uic-` class names and `data-uic-*` attributes are + unchanged. The rule is in CONTRIBUTING ("Styling") and + `componentStyles.test.ts` enforces it, including that no name collides + with one Astryx core, lab or the neutral theme declares or reads. + - The Lablup theme's info hue is `--color-info` (was `--uic-color-info`), + the name the Backend.AI WebUI theme uses, declared in the theme's + `tokens`. `LABLUP_INFO_TOKEN` carries the new name. `StatCard`'s info + tone and the legacy `--token-colorInfo` read it. + - Hooks that only carried a theme value are gone; the component reads the + theme token, with an Astryx fallback: + - `--uic-form-item-description-color` -> `--color-text-description` + (fallback `--color-text-secondary`). + - `--uic-text-highlighter-background` -> `--color-warning-border-hover` + (fallback `--color-warning-muted`). + - `--uic-progress-with-label-color` -> removed; the `color` prop paints + the fill directly (default `--color-success`). + - `--uic-stat-card-tone`, `--uic-stat-card-tone-muted`, + `--uic-error-state-tone`, `--uic-error-state-tone-muted` and + `--uic-progress-with-label-font-size` -> removed; the variant rules + read the tokens. + - Component knobs drop the prefix and follow Astryx's component-variable + form, `--<component>-<property>`: + - `--board-item-title-z`, `--count-badge-offset-x`/`-y`, + `--data-grid-scroll-width`, `--data-grid-max-height`, + `--data-grid-dialog-list-height`, `--digit-pop-in-duration`/ + `-distance`/`-stagger`/`-blur`/`-ease`/`-index` (their 0.1 names + again), `--divided-row-column-gap`, `--form-item-margin-bottom`, + `--form-item-gap`, `--form-item-line-height`, + `--list-banner-max-height`, `--modal-z`, `--modal-level`, + `--modal-dir-x`/`-y`, `--notification-stack-z`, + `--notification-stack-inset-top`, `--overlay-scrollbar-z`, + `--progress-with-label-radius`, `--unit-grid-group-1`..`-7`, + `--unit-grid-ink-dark`, `--unit-grid-ink-light`, + `--unit-grid-cell-stroke`, `--unit-grid-cell-empty`, + `--unit-grid-popover-z`: the old name without `uic-`. + - `--uic-notification-body-max-height` -> + `--notification-stack-body-max-height`. + ## [0.2.0-alpha.11] Review fixes for the table cluster, and translation fixes. diff --git a/package.json b/package.json index 8a4711d..df3733b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@lablup/ui-common", - "version": "0.2.0-alpha.11", + "version": "0.2.0-alpha.12", "description": "Lablup's UI layer on Astryx: the Astryx surface re-exported 1:1, the Lablup theme, and shared components", "license": "Apache-2.0", "author": "Lablup Inc.", From 48bb2cfe54fa2021e914d208ca1bb2ea610177b6 Mon Sep 17 00:00:00 2001 From: Jongeun <jongeun@lablup.com> Date: Tue, 29 Sep 2026 09:42:00 +0000 Subject: [PATCH 64/87] fix(DataGrid): let the root shrink as a flex or grid item A wide grid inside a flex or grid parent floored at the table's full width and stretched the parent, so the grid's own horizontal scroll never engaged. The root is now min-width: 0; max-width: 100%. --- CHANGELOG.md | 6 ++++++ src/components/DataGrid/DataGrid.css | 7 +++++++ src/components/DataGrid/DataGrid.test.tsx | 12 ++++++++++++ 3 files changed, 25 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c0a5bab..6f8f32d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ Versioning follows the policy in [CONTRIBUTING.md](CONTRIBUTING.md#versioning). ## [Unreleased] +### Fixed + +- `DataGrid`'s root (`.uic-data-grid`) is `min-width: 0; max-width: 100%`, + so a wide grid inside a flex or grid parent scrolls itself instead of + stretching the parent. + ## [0.2.0-alpha.12] Custom properties take Astryx's naming as is: no `--uic-` prefix. diff --git a/src/components/DataGrid/DataGrid.css b/src/components/DataGrid/DataGrid.css index 5465a4b..f911941 100644 --- a/src/components/DataGrid/DataGrid.css +++ b/src/components/DataGrid/DataGrid.css @@ -5,6 +5,13 @@ * a cell or a detail row. */ @layer ui-common { + /* As a flex or grid item the root would otherwise floor at the table's + full width and stretch its parent; clamped, the table scrolls itself. */ + .uic-data-grid { + min-width: 0; + max-width: 100%; + } + .uic-data-grid__body { /* Astryx's Table bleeds into its container's block padding when it is the first or last child. The table is this wrapper's only child, so both diff --git a/src/components/DataGrid/DataGrid.test.tsx b/src/components/DataGrid/DataGrid.test.tsx index 09e6a9a..898c2cc 100644 --- a/src/components/DataGrid/DataGrid.test.tsx +++ b/src/components/DataGrid/DataGrid.test.tsx @@ -1,3 +1,5 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; import { useState, type ComponentProps } from "react"; import { describe, expect, it, vi } from "vitest"; import { render, screen, within } from "@testing-library/react"; @@ -680,3 +682,13 @@ describe("DataGrid strings", () => { expect(screen.getByText("1 total")).toBeInTheDocument(); }); }); + +describe("DataGrid as a flex or grid item", () => { + // jsdom has no layout, so the stylesheet rule itself is the contract. + it("lets its root shrink so a wide table scrolls instead of stretching the parent", () => { + const css = readFileSync(join(__dirname, "DataGrid.css"), "utf8"); + const root = /\.uic-data-grid\s*\{([^}]*)\}/.exec(css)?.[1] ?? ""; + expect(root).toMatch(/min-width:\s*0;/); + expect(root).toMatch(/max-width:\s*100%;/); + }); +}); From ba45794364a0a597ee6475ac18f14f70b8197910 Mon Sep 17 00:00:00 2001 From: Jongeun <jongeun@lablup.com> Date: Tue, 29 Sep 2026 09:44:49 +0000 Subject: [PATCH 65/87] feat(DoubleToken): take a per-value endContent A value's endContent renders in place of its visible label, so a product can put a copy control around one segment's text. The label stays the accessible name, and highlightKeyword leaves a caller's endContent alone. The end-corner weld now keys on :last-of-type, since a copy control's tooltip trails the tokens as a sibling and :last-child never matched the last token. --- CHANGELOG.md | 10 +++++ astryx/components/DoubleToken.doc.mjs | 5 ++- src/components/DoubleToken/DoubleToken.css | 4 +- .../DoubleToken/DoubleToken.test.tsx | 44 +++++++++++++++++++ src/components/DoubleToken/DoubleToken.tsx | 40 ++++++++++------- 5 files changed, 85 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f8f32d..f76a1e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,8 +5,18 @@ Versioning follows the policy in [CONTRIBUTING.md](CONTRIBUTING.md#versioning). ## [Unreleased] +### Added + +- `DoubleToken`: a value's `endContent` renders in place of its visible + label (a copy control around the text, say). The label stays the + accessible name; `highlightKeyword` does not reach into it. + ### Fixed +- `DoubleToken` squares inner end corners with `:not(:last-of-type)`, so an + element trailing the tokens (a copy control's tooltip) no longer squares + the last token's outer corners. + - `DataGrid`'s root (`.uic-data-grid`) is `min-width: 0; max-width: 100%`, so a wide grid inside a flex or grid parent scrolls itself instead of stretching the parent. diff --git a/astryx/components/DoubleToken.doc.mjs b/astryx/components/DoubleToken.doc.mjs index a70b84e..9a467be 100644 --- a/astryx/components/DoubleToken.doc.mjs +++ b/astryx/components/DoubleToken.doc.mjs @@ -15,8 +15,9 @@ export default { props: [ { name: "values", - type: "Array<string> | Array<{ label: string; color?: TokenColor }>", - description: "The tokens, in order.", + type: "Array<string> | Array<{ label: string; color?: TokenColor; endContent?: ReactNode }>", + description: + "The tokens, in order. A value's endContent replaces its visible label (the label stays the accessible name); the caller renders the label inside it, and highlightKeyword does not reach into it.", }, { name: "highlightKeyword", diff --git a/src/components/DoubleToken/DoubleToken.css b/src/components/DoubleToken/DoubleToken.css index e449c7a..ffd53aa 100644 --- a/src/components/DoubleToken/DoubleToken.css +++ b/src/components/DoubleToken/DoubleToken.css @@ -12,7 +12,9 @@ border-end-start-radius: 0; } - .uic-double-token__item:not(:last-child) { + /* By type, not position: a copy control's tooltip can trail the tokens as a + sibling element, and the last token must still keep its outer corners. */ + .uic-double-token__item:not(:last-of-type) { border-start-end-radius: 0; border-end-end-radius: 0; } diff --git a/src/components/DoubleToken/DoubleToken.test.tsx b/src/components/DoubleToken/DoubleToken.test.tsx index fd675f3..c2cf15a 100644 --- a/src/components/DoubleToken/DoubleToken.test.tsx +++ b/src/components/DoubleToken/DoubleToken.test.tsx @@ -1,3 +1,5 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { render, screen } from "@testing-library/react"; @@ -64,4 +66,46 @@ describe("DoubleToken", () => { expect(tokens[0]).toHaveAttribute("aria-label", "python"); expect(screen.getByText("py")).toHaveClass("uic-text-highlighter__match"); }); + + it("renders a value's endContent in place of its visible label", () => { + const { container } = render( + <DoubleToken + values={[ + { label: "role", color: "default" }, + { + label: "abc-123", + endContent: <span data-testid="copyable">abc-123 (copy)</span>, + }, + ]} + />, + ); + const tokens = Array.from(root(container).children); + expect(tokens[0]).toHaveTextContent("role"); + expect(tokens[1]).toHaveAttribute("aria-label", "abc-123"); + expect(tokens[1]).toContainElement(screen.getByTestId("copyable")); + // The hidden label is still in the DOM, visually hidden. + expect(tokens[1]?.textContent).toBe("abc-123abc-123 (copy)"); + }); + + it("leaves highlighting inside a caller's endContent to the caller", () => { + render( + <DoubleToken + highlightKeyword="py" + values={[ + { label: "python" }, + { label: "pytorch", endContent: <span data-testid="own">pytorch</span> }, + ]} + />, + ); + expect(screen.getAllByText("py")).toHaveLength(1); + expect(screen.getByTestId("own")).toHaveTextContent("pytorch"); + }); + + // A copy control's tooltip renders as a trailing sibling of the tokens, so + // `:last-child` would never match the last token and leave its end square. + it("squares inner end corners by element type, not child position", () => { + const css = readFileSync(join(__dirname, "DoubleToken.css"), "utf8"); + expect(css).toContain(".uic-double-token__item:not(:last-of-type)"); + expect(css).not.toContain(":not(:last-child)"); + }); }); diff --git a/src/components/DoubleToken/DoubleToken.tsx b/src/components/DoubleToken/DoubleToken.tsx index 65153f8..bf72bf5 100644 --- a/src/components/DoubleToken/DoubleToken.tsx +++ b/src/components/DoubleToken/DoubleToken.tsx @@ -8,11 +8,14 @@ * A string value is a blue Token. Values with an empty label are skipped, and * an empty list renders nothing. With `highlightKeyword` each label marks the * keyword (`TextHighlighter`), and the plain label stays the accessible name. + * A value's `endContent` takes the place of its visible label (a copy + * control around the text, say); the caller renders the label in it, + * highlighted or not, and the plain label stays the accessible name. * * @example * <DoubleToken values={[{ label: "CUDA", color: "green" }, "12.4"]} /> */ -import type { ReactElement } from "react"; +import type { ReactElement, ReactNode } from "react"; import { HStack } from "@astryxdesign/core/Stack"; import { Token, type TokenProps } from "@astryxdesign/core/Token"; @@ -25,6 +28,12 @@ export interface DoubleTokenValue { label: string; /** @default 'blue' */ color?: DoubleTokenColor; + /** + * Rendered in place of the visible label, which stays the accessible name. + * The caller renders the label inside it; `highlightKeyword` does not + * reach into it. + */ + endContent?: ReactNode; } export interface DoubleTokenProps { @@ -47,26 +56,27 @@ export function DoubleToken({ return ( <HStack gap={0} align="center" className="uic-double-token"> - {objectValues.map((value, idx) => - value.label ? ( - // Token.label is a string: a highlighted label is a hidden label - // plus endContent, so the plain string stays the accessible name. + {objectValues.map((value, idx) => { + if (!value.label) return null; + // Token.label is a string: anything richer is a hidden label plus + // endContent, so the plain string stays the accessible name. + const endContent = + value.endContent !== undefined ? ( + value.endContent + ) : isHighlighting ? ( + <TextHighlighter keyword={highlightKeyword}>{value.label}</TextHighlighter> + ) : undefined; + return ( <Token key={idx} className="uic-double-token__item" color={value.color ?? "blue"} label={value.label} - isLabelHidden={isHighlighting} - endContent={ - isHighlighting ? ( - <TextHighlighter keyword={highlightKeyword}> - {value.label} - </TextHighlighter> - ) : undefined - } + isLabelHidden={endContent !== undefined} + endContent={endContent} /> - ) : null, - )} + ); + })} </HStack> ); } From 7c5ae9f455dbd507794126a92f5c2b22df7ca338 Mon Sep 17 00:00:00 2001 From: Jongeun <jongeun@lablup.com> Date: Tue, 29 Sep 2026 10:09:25 +0000 Subject: [PATCH 66/87] feat(gen-exports): mirror lab name by name, and let a fork keep Astryx's name An exports.exclude.json entry can now hide single names of a subpath ("exports": [...]) instead of the whole of it; the mirror file is then written as explicit named re-exports minus those names, plus the custom that replaces them. lab is always written that way, since Astryx ships it as one namespace with no per-component subpath to exclude. The runtime cross-check that guards core's root now covers these mirrors too. A custom may keep an Astryx name only as a fork: its Astryx subpath (or names) excluded with replacedBy naming it, a "fork" field naming the Astryx module, and exactly the names Astryx's exports. Only a core fork may take the excluded Astryx subpath itself. --- scripts/gen-exports.d.mts | 5 + scripts/gen-exports.mjs | 336 +++++++++++++++++++++++++++++++------- src/astryx/lab.ts | 248 +++++++++++++++++++++++++++- src/exports.test.ts | 13 +- 4 files changed, 543 insertions(+), 59 deletions(-) diff --git a/scripts/gen-exports.d.mts b/scripts/gen-exports.d.mts index 3faf3a9..9b4d2fb 100644 --- a/scripts/gen-exports.d.mts +++ b/scripts/gen-exports.d.mts @@ -4,6 +4,9 @@ export declare const MIRROR_DIR: string; export declare const MIRRORED_PACKAGES: readonly { name: string; prefix: string }[]; +/** Mirrors written as explicit named re-exports even with nothing excluded. */ +export declare const NAMED_MIRRORS: readonly string[]; + export type ExportTarget = string | { types: string; import: string }; export declare const OWN_EXPORTS: Record<string, ExportTarget>; @@ -20,6 +23,8 @@ export interface GenerateResult { legacyCollisions: string[]; /** Excluded Astryx names a replacement custom re-exports unchanged. */ reinstated: string[]; + /** Astryx names exported from a ui-common fork, in the root or a mirror. */ + replaced: string[]; }; } diff --git a/scripts/gen-exports.mjs b/scripts/gen-exports.mjs index cc5b894..9ff90ad 100644 --- a/scripts/gen-exports.mjs +++ b/scripts/gen-exports.mjs @@ -4,13 +4,16 @@ * ui-common mirrors Astryx 1:1. Every subpath in the `exports` map of * `@astryxdesign/core` becomes the same subpath here, `@astryxdesign/lab` * becomes `lab`, and `@astryxdesign/theme-neutral` becomes `theme/neutral`. - * Nothing is curated by hand. The only way to hide a subpath is an entry in - * `exports.exclude.json`. + * Nothing is curated by hand. The only way to hide a subpath, or single names + * of one, is an entry in `exports.exclude.json`. * * Outputs, all committed: * * - `src/astryx/**`: one re-export file per mirrored subpath. A JS subpath is - * `export * from "<astryx specifier>"`. A CSS subpath is one `@import`. + * `export * from "<astryx specifier>"`, except `lab` and any subpath with + * excluded names: those are explicit named re-exports, minus the excluded + * names, plus the ui-common fork that replaces them. A CSS subpath is one + * `@import`. * - `src/index.ts`: the root barrel. Explicit named re-exports of core's root * (minus the names of excluded subpaths), plus the ui-common customs listed * in `exports.customs.json`. @@ -75,6 +78,13 @@ export const OWN_EXPORTS = { "./styles/themes/*.css": "./dist/styles/themes/*.css", }; +/** + * Mirrors written as explicit named re-exports even with nothing excluded, so + * a name can be excluded from them one at a time. Astryx ships `lab` as one + * namespace, with no per-component subpath to exclude. + */ +export const NAMED_MIRRORS = ["lab"]; + /** The locale catalogs are JSON, so they are merged at build time instead. */ const LOCALES_PATTERN = "./locales/*.json"; @@ -98,7 +108,11 @@ function posix(path) { return path.split(sep).join("/"); } -/** `{ name, replacedBy, reason }[]` from exports.exclude.json, validated. */ +/** + * `{ name, exports?, replacedBy, reason }[]` from exports.exclude.json, + * validated. Without `exports` the entry hides the whole subpath `name`; with + * it, only those names of that subpath. + */ async function readExclusions() { const list = await readJson(join(root, "exports.exclude.json")); if (!Array.isArray(list)) throw new Error("exports.exclude.json must be an array"); @@ -113,6 +127,16 @@ async function readExclusions() { `exports.exclude.json: "${entry.name}" needs "replacedBy" (use null when nothing replaces it)`, ); } + if ( + "exports" in entry && + (!Array.isArray(entry.exports) || + entry.exports.length === 0 || + entry.exports.some((n) => typeof n !== "string")) + ) { + throw new Error( + `exports.exclude.json: "${entry.name}" has "exports", which must be a non-empty list of names`, + ); + } } return list; } @@ -263,10 +287,42 @@ function cssFile(subpath) { return `${MIRROR_DIR}/${subpath}`; } -/** Relative import specifier from src/index.ts to a custom's source file. */ -function customSpecifier(source) { - const withoutExt = source.replace(/\.tsx?$/, "").replace(/\/index$/, ""); - return `./${withoutExt}`; +/** + * Relative import specifier to a custom's source file, from src/index.ts or + * from another file under src/. + */ +function customSpecifier(source, fromFile = "src/index.ts") { + const target = posix( + relative(dirname(join(root, fromFile)), join(root, "src", source)), + ); + const withoutExt = target.replace(/\.tsx?$/, "").replace(/\/index$/, ""); + return withoutExt.startsWith(".") ? withoutExt : `./${withoutExt}`; +} + +/** `a` and `b` hold the same names. */ +function sameNames(a, b) { + return ( + JSON.stringify([...new Set(a)].sort()) === JSON.stringify([...new Set(b)].sort()) + ); +} + +/** + * A module's runtime export names, for the value/type cross-check. A mismatch + * means a type-only name would be emitted as a value (a runtime SyntaxError + * for consumers) or the reverse. + */ +async function checkRuntimeValues(label, file, values) { + const runtime = Object.keys(await import(pathToFileURL(file).href)) + .filter((n) => n !== "default") + .sort(); + if (JSON.stringify(runtime) !== JSON.stringify(values)) { + const onlyRuntime = runtime.filter((n) => !values.includes(n)); + const onlyTypes = values.filter((n) => !runtime.includes(n)); + throw new Error( + `Value/type classification of ${label} disagrees with its runtime exports. ` + + `Runtime only: ${onlyRuntime.join(", ") || "-"}. Declared only: ${onlyTypes.join(", ") || "-"}.`, + ); + } } /** A list as ` * `-prefixed comment lines of at most 80 columns. */ @@ -303,11 +359,14 @@ async function format(content, filepath) { export async function generate() { const exclusions = await readExclusions(); const customs = await readCustoms(); - const excludedNames = new Set(exclusions.map((e) => e.name)); + // Whole subpaths hidden, and single names hidden from a subpath. + const subpathExclusions = exclusions.filter((e) => !e.exports); + const nameExclusions = exclusions.filter((e) => e.exports); + const excludedNames = new Set(subpathExclusions.map((e) => e.name)); const all = await collectMirroredSubpaths(); const known = new Set(all.map((s) => s.subpath)); - for (const name of excludedNames) { + for (const { name } of exclusions) { if (!known.has(name)) { throw new Error( `exports.exclude.json names "${name}", which no mirrored package exports any more. ` + @@ -335,6 +394,87 @@ export async function generate() { ); } } + for (const e of nameExclusions) { + const s = mirrored.find((m) => m.subpath === e.name); + if (!s || s.kind !== "js" || !s.types) { + throw new Error( + `exports.exclude.json: "${e.name}" excludes names, which needs a mirrored script subpath ` + + `with type declarations.`, + ); + } + } + + // A replacement has to exist: a custom, or a subpath that is still mirrored. + const customNames = new Set(customs.map((c) => c.name)); + const mirroredSubpaths = new Set(mirrored.map((s) => s.subpath)); + for (const e of exclusions) { + if (e.replacedBy === null) continue; + if (!customNames.has(e.replacedBy) && !mirroredSubpaths.has(e.replacedBy)) { + throw new Error( + `exports.exclude.json: "${e.name}" is replacedBy "${e.replacedBy}", which is ` + + `neither a custom in exports.customs.json nor a mirrored subpath.`, + ); + } + } + + // A custom that replaces names of a mirror is emitted into that mirror, not + // into the root barrel: `@lablup/ui-common/lab` keeps exporting `Drawer`, + // now ui-common's. One custom replaces the names of one entry. + /** custom name -> the name exclusion it replaces */ + const mirrorForks = new Map(); + for (const e of nameExclusions) { + if (e.replacedBy === null) continue; + const custom = customs.find((c) => c.name === e.replacedBy); + if (!custom) { + throw new Error( + `exports.exclude.json: names excluded from "${e.name}" are replacedBy ` + + `"${e.replacedBy}", which must be a custom in exports.customs.json.`, + ); + } + if (mirrorForks.has(custom.name)) { + throw new Error( + `exports.customs.json: "${custom.name}" replaces the names of more than one exclusion.`, + ); + } + if (custom.subpath) { + throw new Error( + `exports.customs.json: "${custom.name}" replaces names of "${e.name}" and ships ` + + `there; it cannot also have a subpath of its own.`, + ); + } + mirrorForks.set(custom.name, e); + } + + /** + * A same-name fork: the custom whose own subpath is an excluded Astryx + * subpath, excluded with replacedBy = that custom. It keeps the Astryx + * subpath and names (`@lablup/ui-common/ComplexSelector`), which is safe only + * because the exclusion removes Astryx's from the mirror. + */ + const subpathForkOf = (custom) => + custom.subpath + ? subpathExclusions.find( + (e) => e.name === custom.subpath && e.replacedBy === custom.name, + ) + : undefined; + + // `fork` names the Astryx module a fork stands in for (the CLI and the + // agent block read it). It is required on a fork and refused elsewhere. + for (const custom of customs) { + const isFork = mirrorForks.has(custom.name) || subpathForkOf(custom) !== undefined; + if (isFork && typeof custom.fork !== "string") { + throw new Error( + `exports.customs.json: "${custom.name}" replaces Astryx names with its own, so it ` + + `needs "fork": the Astryx module it stands in for.`, + ); + } + if (!isFork && custom.fork !== undefined) { + throw new Error( + `exports.customs.json: "${custom.name}" has "fork" but replaces no Astryx subpath ` + + `or names in exports.exclude.json.`, + ); + } + } // Type information: every mirrored JS module (to find default exports), // core's root, the excluded modules (their names leave the root barrel), @@ -358,6 +498,15 @@ export async function generate() { if (!file) throw new Error(`TypeScript could not load ${relative(root, path)}`); return file; }; + const customExportsOf = (custom) => + moduleExports(checker, sourceOf(join(root, "src", custom.source))); + + const report = { + droppedFromRoot: [], + legacyCollisions: [], + reinstated: [], + replaced: [], + }; const files = new Map(); @@ -367,7 +516,61 @@ export async function generate() { const path = jsFile(s.subpath); const types = typesPath(s); const hasDefault = types ? hasDefaultExport(checker, sourceOf(types)) : false; - let body = `// ${GENERATED_HEADER}\nexport * from "${s.specifier}";\n`; + const excludedHere = nameExclusions.filter((e) => e.name === s.subpath); + let body = `// ${GENERATED_HEADER}\n`; + if (types && (NAMED_MIRRORS.includes(s.subpath) || excludedHere.length > 0)) { + const names = moduleExports(checker, sourceOf(types)); + await checkRuntimeValues( + s.specifier, + join(s.packageDir, s.target), + names.values, + ); + const all = [...names.values, ...names.types]; + const dropped = new Set(); + for (const e of excludedHere) { + for (const n of e.exports) { + if (!all.includes(n)) { + throw new Error( + `exports.exclude.json: "${e.name}" excludes "${n}", which ${s.specifier} ` + + `does not export any more. Update the entry.`, + ); + } + dropped.add(n); + } + } + body += + exportList( + "export", + names.values.filter((n) => !dropped.has(n)), + s.specifier, + ) + + exportList( + "export type", + names.types.filter((n) => !dropped.has(n)), + s.specifier, + ); + for (const e of excludedHere) { + if (e.replacedBy === null) continue; + const custom = customs.find((c) => c.name === e.replacedBy); + const own = customExportsOf(custom); + const ownNames = [...own.values, ...own.types]; + if (!sameNames(ownNames, e.exports)) { + throw new Error( + `exports.customs.json: "${custom.name}" replaces ${e.exports.join(", ")} of ` + + `"${e.name}", so it must export exactly those names; it exports ` + + `${ownNames.join(", ") || "nothing"}.`, + ); + } + report.replaced.push(...ownNames); + const from = customSpecifier(custom.source, path); + body += + `\n// ${custom.name}: ui-common's own copy, in place of Astryx's.\n` + + exportList("export", own.values, from) + + exportList("export type", own.types, from); + } + } else { + body += `export * from "${s.specifier}";\n`; + } if (hasDefault) body += `export { default } from "${s.specifier}";\n`; files.set(path, await format(body, path)); } else if (s.kind === "css") { @@ -384,32 +587,26 @@ export async function generate() { const labRoot = moduleExports(checker, sourceOf(labRootTypes)); // Cross-check the classification against what core actually exports at - // runtime. A mismatch means a type-only name would be emitted as a value - // (a runtime SyntaxError for consumers) or the reverse. - const runtime = Object.keys( - await import(pathToFileURL(join(coreDir, "dist/index.js")).href), - ) - .filter((n) => n !== "default") - .sort(); - if (JSON.stringify(runtime) !== JSON.stringify(coreRoot.values)) { - const onlyRuntime = runtime.filter((n) => !coreRoot.values.includes(n)); - const onlyTypes = coreRoot.values.filter((n) => !runtime.includes(n)); - throw new Error( - `Value/type classification of @astryxdesign/core disagrees with its runtime exports. ` + - `Runtime only: ${onlyRuntime.join(", ") || "-"}. Declared only: ${onlyTypes.join(", ") || "-"}.`, - ); - } + // runtime. + await checkRuntimeValues( + "@astryxdesign/core", + join(coreDir, "dist/index.js"), + coreRoot.values, + ); const droppedFromRoot = new Set(); // name -> the exclusion that dropped it, and the declaration it names. const droppedBy = new Map(); + // excluded subpath -> every name it exports. + const excludedSubpathNames = new Map(); for (const s of excluded.filter( (e) => e.kind === "js" && e.package === "@astryxdesign/core", )) { const types = typesPath(s); if (!types) continue; - const exclusion = exclusions.find((e) => e.name === s.subpath); + const exclusion = subpathExclusions.find((e) => e.name === s.subpath); const names = moduleExports(checker, sourceOf(types)); + excludedSubpathNames.set(s.subpath, [...names.values, ...names.types]); for (const n of [...names.values, ...names.types]) { droppedFromRoot.add(n); droppedBy.set(n, { @@ -418,48 +615,55 @@ export async function generate() { }); } } - - // A replacement has to exist: a custom, or a subpath that is still mirrored. - const customNames = new Set(customs.map((c) => c.name)); - const mirroredSubpaths = new Set(mirrored.map((s) => s.subpath)); - for (const e of exclusions) { - if (e.replacedBy === null) continue; - if (!customNames.has(e.replacedBy) && !mirroredSubpaths.has(e.replacedBy)) { - throw new Error( - `exports.exclude.json: "${e.name}" is replacedBy "${e.replacedBy}", which is ` + - `neither a custom in exports.customs.json nor a mirrored subpath.`, - ); - } - } + report.droppedFromRoot = [...droppedFromRoot].sort(); /** * The replacement of an excluded subpath may re-export that subpath's own * names unchanged (Modal re-exports `DialogHeader`), so moving an import to * it is a rename of the specifier only. Only the identical declaration - * qualifies: a name that means something else still breaks the name rule. + * qualifies: a name that means something else still breaks the name rule, + * unless the custom is that subpath's same-name fork. */ - const isReinstated = (custom, n) => { + const droppedFor = (custom, n) => { const dropped = droppedBy.get(n); - if (!dropped || dropped.exclusion?.replacedBy !== custom.name) return false; + return dropped && dropped.exclusion?.replacedBy === custom.name + ? dropped + : undefined; + }; + const isReinstated = (custom, n) => { + const dropped = droppedFor(custom, n); + if (!dropped) return false; const own = exportTarget(checker, sourceOf(join(root, "src", custom.source)), n); return own !== undefined && own === dropped.target; }; + const isForkedName = (custom, n) => + subpathForkOf(custom) !== undefined && droppedFor(custom, n) !== undefined; const coreValues = coreRoot.values.filter((n) => !droppedFromRoot.has(n)); const coreTypes = coreRoot.types.filter((n) => !droppedFromRoot.has(n)); const coreNames = new Set([...coreRoot.values, ...coreRoot.types]); const labNames = new Set([...labRoot.values, ...labRoot.types]); - const report = { - droppedFromRoot: [...droppedFromRoot].sort(), - legacyCollisions: [], - reinstated: [], - }; const customBlocks = []; const rootNames = new Set([...coreValues, ...coreTypes]); + const forkedInRoot = []; for (const custom of customs) { - const names = moduleExports(checker, sourceOf(join(root, "src", custom.source))); + // Emitted into the mirror whose names it replaces, above. + if (mirrorForks.has(custom.name)) continue; + const names = customExportsOf(custom); + const fork = subpathForkOf(custom); + if (fork) { + const upstream = excludedSubpathNames.get(fork.name) ?? []; + const own = [...names.values, ...names.types]; + if (!sameNames(own, upstream)) { + throw new Error( + `exports.customs.json: "${custom.name}" takes the Astryx subpath "${fork.name}", so ` + + `it must export exactly the names Astryx's does (${upstream.join(", ")}); it ` + + `exports ${own.join(", ") || "nothing"}.`, + ); + } + } // A legacy entry deprecates the names it lists, or its whole module when // it lists none. Only a deprecated name may collide with Astryx. const deprecated = (n) => @@ -471,13 +675,15 @@ export async function generate() { ["types", names.types], ]) { for (const n of list) { - if (isReinstated(custom, n)) { + const reinstated = isReinstated(custom, n); + if (reinstated || isForkedName(custom, n)) { if (rootNames.has(n)) { throw new Error( `exports.customs.json: "${n}" is exported twice from the root barrel.`, ); } - report.reinstated.push(n); + if (reinstated) report.reinstated.push(n); + else forkedInRoot.push(n); rootNames.add(n); keep[bucket].push(n); continue; @@ -488,7 +694,9 @@ export async function generate() { throw new Error( `exports.customs.json: "${custom.name}" exports "${n}", which ${ clashCore ? "@astryxdesign/core" : "@astryxdesign/lab" - } also exports. ui-common customs never share a name with Astryx.`, + } also exports. ui-common customs never share a name with Astryx, except a ` + + `same-name fork whose Astryx subpath or names are excluded with ` + + `replacedBy = that custom.`, ); } if (clashCore) { @@ -507,6 +715,8 @@ export async function generate() { if (keep.values.length + keep.types.length === 0) continue; const from = customSpecifier(custom.source); let comment = `// ${custom.name}\n`; + if (fork) + comment = `// ${custom.name}: ui-common's own copy, in place of Astryx's.\n`; if (custom.legacy) { const what = custom.legacy.names ? custom.legacy.names.join(", ") : "this module"; comment += `// Deprecated, removed in 0.3: ${what}. Replaced by ${custom.legacy.replacedBy}.\n`; @@ -517,14 +727,18 @@ export async function generate() { exportList("export type", keep.types, from), ); } + report.replaced = [...forkedInRoot, ...report.replaced].sort(); + // Names forked in the root barrel are no longer "left out": they are back, + // as ui-common's. + const leftOut = report.droppedFromRoot.filter((n) => !forkedInRoot.includes(n)); const barrel = `/**\n * ${GENERATED_HEADER}\n *\n` + ` * The root barrel: Astryx core's root exports, minus the names of the\n` + ` * subpaths in exports.exclude.json, then the ui-common customs listed in\n` + ` * exports.customs.json. Edit those files and run \`pnpm run gen:exports\`.\n` + - (report.droppedFromRoot.length > 0 - ? ` *\n * Left out because their subpath is excluded:\n${wrapComment(report.droppedFromRoot)}` + (leftOut.length > 0 + ? ` *\n * Left out because their subpath is excluded:\n${wrapComment(leftOut)}` : "") + (report.legacyCollisions.length > 0 ? ` *\n * Deprecated customs whose names Astryx owns. The Astryx export wins\n` + @@ -535,6 +749,10 @@ export async function generate() { ? ` *\n * Excluded Astryx names re-exported unchanged by their replacement:\n` + wrapComment(report.reinstated) : "") + + (forkedInRoot.length > 0 + ? ` *\n * Astryx names exported from ui-common's own copy of the component:\n` + + wrapComment([...forkedInRoot].sort()) + : "") + ` */\n\n` + exportList("export", coreValues, "@astryxdesign/core") + exportList("export type", coreTypes, "@astryxdesign/core") + @@ -564,9 +782,10 @@ export async function generate() { `exports.customs.json: subpath "${custom.subpath}" is already taken.`, ); } - if (known.has(custom.subpath)) { + if (known.has(custom.subpath) && !subpathForkOf(custom)) { throw new Error( - `exports.customs.json: subpath "${custom.subpath}" is an Astryx subpath (even if excluded).`, + `exports.customs.json: subpath "${custom.subpath}" is an Astryx subpath. Only its ` + + `same-name fork may take it, with the subpath excluded and replacedBy "${custom.name}".`, ); } const base = custom.source.replace(/\.tsx?$/, ""); @@ -624,6 +843,11 @@ async function main() { `Excluded names re-exported unchanged by their replacement: ${report.reinstated.join(", ")}`, ); } + if (report.replaced.length > 0) { + console.log( + `Astryx names exported from ui-common's own copies: ${report.replaced.join(", ")}`, + ); + } } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { diff --git a/src/astryx/lab.ts b/src/astryx/lab.ts index a5a725c..65195d8 100644 --- a/src/astryx/lab.ts +++ b/src/astryx/lab.ts @@ -1,2 +1,248 @@ // @generated by scripts/gen-exports.mjs. Do not edit. -export * from "@astryxdesign/lab"; +export { + Chart, + ChartArea, + ChartAxis, + ChartBar, + ChartBrush, + ChartCandlestick, + ChartDot, + ChartDotGL, + ChartDotGLInteractive, + ChartErrorBar, + ChartGrid, + ChartHeatmapGL, + ChartLegend, + ChartLine, + ChartReferenceLine, + ChartSelect, + ChartStreamGL, + ChartTooltip, + ChartZoom, + ChatEmojiPicker, + ChatReactionBar, + ChatReasoning, + ChatTypingIndicator, + ChatUnreadDivider, + CircularProgress, + CodeEditor, + DEFAULT_CHAT_EMOJIS, + Drawer, + InfoTip, + ListInput, + LogStream, + RadialArea, + RadialAxis, + RadialChart, + RadialGrid, + RadialSlice, + RadialTooltip, + SVGIcon, + SYNC_TOKENIZE_THRESHOLD, + SankeyChart, + SankeyGrid, + SankeyLabel, + SankeyLink, + SankeyNode, + Schedule, + ScheduleContext, + Stat, + ThreeDAxis, + ThreeDBar, + ThreeDChart, + ThreeDGrid, + ThreeDScatter, + ThreeDScatterGL, + ThreeDSurface, + Tour, + TourStep, + TransferList, + TransferListSelector, + bellIcon, + calendarIcon, + checkIcon, + compactNumber, + createEventFromISO, + createScheduleDayView, + createScheduleListView, + createScheduleMonthlyView, + createScheduleWeeklyView, + currency, + defaultSchedulePlugins, + eyeIcon, + folderIcon, + getChartColors, + getChartColorsFromResolver, + heartIcon, + homeIcon, + iconVars, + lockIcon, + m4Reduce, + mailIcon, + menuIcon, + monthYear, + opticalSize, + percent, + searchIcon, + settingsIcon, + shieldIcon, + shortDate, + starIcon, + starterIcons, + tokenize, + tokenizeAsync, + transferListVars, + use3D, + useChart, + useChartColors, + useChartRange, + useRadial, + useSankey, + useScheduleContext, + useSchedulePaginationPlugin, + useScheduleViewSelectorPlugin, + useTour, + variations, + xIcon, +} from "@astryxdesign/lab"; +export type { + BrushMode, + BrushRange, + CalendarDayEvent, + CalendarEvent, + CalendarEventBase, + CalendarInstantEvent, + Camera, + ChartAreaProps, + ChartAxisProps, + ChartBarProps, + ChartBrushProps, + ChartCandlestickProps, + ChartColorsAPI, + ChartContext, + ChartCrosshairMode, + ChartDotGLInteractiveProps, + ChartDotGLProps, + ChartDotProps, + ChartErrorBarProps, + ChartGridProps, + ChartHeatmapGLProps, + ChartLegendItem, + ChartLegendProps, + ChartLineProps, + ChartMargin, + ChartProps, + ChartReferenceLineProps, + ChartScale, + ChartSelectProps, + ChartStreamGLHandle, + ChartStreamGLProps, + ChartTooltipProps, + ChartZoomProps, + ChatEmojiOption, + ChatEmojiPickerProps, + ChatReaction, + ChatReactionBarProps, + ChatReasoningProps, + ChatTypingIndicatorProps, + ChatUnreadDividerProps, + CircularProgressProps, + CircularProgressSize, + CircularProgressVariant, + CircularProgressVariantMap, + CodeEditorProps, + DrawerProps, + IconShape, + IconShapeRole, + InfoTipProps, + InfoTipSize, + Instant, + ListInputChange, + ListInputColumn, + ListInputProps, + ListInputRenderContext, + ListInputValueContext, + LogEntry, + LogStreamLevel, + LogStreamProps, + M4Point, + PlainDate, + Point3D, + ProjectedPoint, + RadialAreaProps, + RadialAxisProps, + RadialChartProps, + RadialContext, + RadialGridProps, + RadialMode, + RadialSliceProps, + RadialTooltipDatum, + RadialTooltipProps, + SVGIconColor, + SVGIconDef, + SVGIconProps, + SVGIconSize, + SVGIconVariation, + SankeyChartProps, + SankeyColumn, + SankeyColumnDef, + SankeyColumnLayout, + SankeyContext, + SankeyGridProps, + SankeyLabelProps, + SankeyLinkDatum, + SankeyLinkLayout, + SankeyLinkProps, + SankeyNodeDatum, + SankeyNodeLayout, + SankeyNodeProps, + ScheduleCategory, + ScheduleContextValue, + ScheduleDate, + ScheduleDayViewOptions, + ScheduleEventColor, + ScheduleEventSource, + ScheduleHeaderContent, + ScheduleListViewOptions, + ScheduleMonthlyViewOptions, + SchedulePaginationPluginOptions, + SchedulePlugin, + SchedulePluginPosition, + ScheduleProps, + ScheduleRange, + ScheduleView, + ScheduleViewBase, + ScheduleViewOptions, + ScheduleViewSelectorOption, + ScheduleViewSelectorPluginOptions, + ScheduleWeeklyViewOptions, + SequentialHue, + StatDelta, + StatDeltaDirection, + StatDeltaSentiment, + StatProps, + StatSize, + SyntaxToken, + ThreeDAxisProps, + ThreeDBarProps, + ThreeDChartProps, + ThreeDContext, + ThreeDGridProps, + ThreeDScatterGLProps, + ThreeDScatterProps, + ThreeDSurfaceProps, + TokenResolver, + TourDismissSource, + TourProps, + TourStepProps, + TransferListOption, + TransferListProps, + TransferListSelectorCommitBehavior, + TransferListSelectorProps, + UseChartRangeOptions, + UseChartRangeReturn, + UseTourReturn, + YBaseline, + ZonedDateTime, + ZonedDateTimeRange, +} from "@astryxdesign/lab"; diff --git a/src/exports.test.ts b/src/exports.test.ts index 8bc3846..4f8adcc 100644 --- a/src/exports.test.ts +++ b/src/exports.test.ts @@ -83,11 +83,20 @@ describe("export surface rules", () => { } }); + it("mirrors lab name by name, so single lab names can be excluded", () => { + const lab = result.files.get("src/astryx/lab.ts") ?? ""; + expect(lab).not.toMatch(/export \*/); + expect(lab).toMatch(/^\s+Chart,$/m); + expect(lab).toMatch(/^\s+ChartProps,$/m); + }); + it("hides every excluded subpath and keeps its names out of the root barrel", () => { const exclusions = JSON.parse( readFileSync(join(ROOT, "exports.exclude.json"), "utf8"), - ) as { name: string }[]; - for (const { name } of exclusions) { + ) as { name: string; exports?: string[]; replacedBy: string | null }[]; + for (const { name, exports, replacedBy } of exclusions) { + // Names hidden from a mirror, and a same-name fork, keep the subpath. + if (exports || replacedBy === name) continue; expect(Object.keys(result.exports)).not.toContain(`./${name}`); } const barrel = result.files.get("src/index.ts") ?? ""; From 99e7099f251e458b483f998c3dd999e7441fe4e3 Mon Sep 17 00:00:00 2001 From: Jongeun <jongeun@lablup.com> Date: Tue, 29 Sep 2026 10:10:11 +0000 Subject: [PATCH 67/87] feat: ship Astryx's ComplexSelector, Drawer and Tour as fixed forks A product carried these fixes as pnpm patches on @astryxdesign/core and @astryxdesign/lab. patchedDependencies never reach a dependency's consumers, so ui-common now owns fixed copies under Astryx's own names and import paths: - ComplexSelector (@lablup/ui-common/ComplexSelector, and the root): hasClear / onClear, a clear button as Selector has (facebook/astryx#6362). - Drawer (@lablup/ui-common/lab): Escape acts only when it happened in the drawer's own DOM subtree and did not end an IME composition, and a consumer's aria-modal passes through. - Tour, TourStep, useTour (@lablup/ui-common/lab): a step's highlight is promoted into the top layer once, never hidden and re-shown, so StrictMode no longer puts the spotlight dim over the callout. ui-common has no StyleX compile step, so a fork reuses the StyleX objects Astryx's own build compiled for the pinned version, copied from dist/ into a generated <Name>.styles.ts by scripts/sync-forks.mjs. The forks render upstream's atomic class names, whose rules already arrive with astryx.css and lab/lab.css in Astryx's layer: no CSS of their own. Markup-parity tests render each fork next to Astryx's component and compare them. src/forks/provenance.json records the version and a SHA-256 of every upstream file each fork uses; forks.test.ts fails on any Astryx bump until the fork is re-synced or deleted. Each fork also tests that Astryx's own component still lacks the fix, so the test that fails first once upstream ships it says to delete the fork. Upstream's tests run against the forks; two Drawer tests that read StyleX-injected CSS are skipped (they fail the same way against lab's Drawer here). The copied Astryx code is MIT; NOTICE carries its licence and check:pack requires it. The agent block and the CLI passthrough notes say these come from ui-common, same API, rather than telling the reader to use a different name. --- CHANGELOG.md | 21 +- CONTRIBUTING.md | 78 ++- NOTICE | 30 + astryx.integration.mjs | 1 + astryx/docs/ui-common.doc.mjs | 4 + cli/agents.mjs | 12 +- cli/paths.mjs | 37 +- cli/rewrite.mjs | 21 +- exports.customs.json | 16 + exports.exclude.json | 25 + package.json | 9 +- scripts/check-pack.mjs | 9 + scripts/sync-forks.d.mts | 27 + scripts/sync-forks.mjs | 172 +++++ src/astryx/ComplexSelector.ts | 2 - src/astryx/lab.ts | 22 +- src/exports.test.ts | 46 ++ .../ComplexSelector.fork.test.tsx | 142 ++++ .../ComplexSelector/ComplexSelector.styles.ts | 137 ++++ .../ComplexSelector/ComplexSelector.test.tsx | 590 ++++++++++++++++ src/forks/ComplexSelector/ComplexSelector.tsx | 513 ++++++++++++++ src/forks/ComplexSelector/index.ts | 14 + src/forks/Drawer/Drawer.fork.test.tsx | 177 +++++ src/forks/Drawer/Drawer.styles.ts | 114 ++++ src/forks/Drawer/Drawer.test.tsx | 639 ++++++++++++++++++ src/forks/Drawer/Drawer.tsx | 442 ++++++++++++ src/forks/Drawer/index.ts | 6 + src/forks/Drawer/useDrawerDialogPresence.ts | 225 ++++++ src/forks/Tour/Tour.fork.test.tsx | 129 ++++ src/forks/Tour/Tour.test.tsx | 266 ++++++++ src/forks/Tour/Tour.tsx | 173 +++++ src/forks/Tour/TourContext.ts | 62 ++ src/forks/Tour/TourStep.styles.ts | 33 + src/forks/Tour/TourStep.tsx | 360 ++++++++++ src/forks/Tour/index.ts | 14 + src/forks/Tour/useTour.ts | 62 ++ src/forks/compiled.ts | 22 + src/forks/forks.test.ts | 69 ++ src/forks/provenance.json | 64 ++ src/index.ts | 23 +- src/test/forkParity.ts | 19 + test/cli/cli.test.ts | 29 + vite.config.ts | 4 + 43 files changed, 4825 insertions(+), 35 deletions(-) create mode 100644 scripts/sync-forks.d.mts create mode 100644 scripts/sync-forks.mjs delete mode 100644 src/astryx/ComplexSelector.ts create mode 100644 src/forks/ComplexSelector/ComplexSelector.fork.test.tsx create mode 100644 src/forks/ComplexSelector/ComplexSelector.styles.ts create mode 100644 src/forks/ComplexSelector/ComplexSelector.test.tsx create mode 100644 src/forks/ComplexSelector/ComplexSelector.tsx create mode 100644 src/forks/ComplexSelector/index.ts create mode 100644 src/forks/Drawer/Drawer.fork.test.tsx create mode 100644 src/forks/Drawer/Drawer.styles.ts create mode 100644 src/forks/Drawer/Drawer.test.tsx create mode 100644 src/forks/Drawer/Drawer.tsx create mode 100644 src/forks/Drawer/index.ts create mode 100644 src/forks/Drawer/useDrawerDialogPresence.ts create mode 100644 src/forks/Tour/Tour.fork.test.tsx create mode 100644 src/forks/Tour/Tour.test.tsx create mode 100644 src/forks/Tour/Tour.tsx create mode 100644 src/forks/Tour/TourContext.ts create mode 100644 src/forks/Tour/TourStep.styles.ts create mode 100644 src/forks/Tour/TourStep.tsx create mode 100644 src/forks/Tour/index.ts create mode 100644 src/forks/Tour/useTour.ts create mode 100644 src/forks/compiled.ts create mode 100644 src/forks/forks.test.ts create mode 100644 src/forks/provenance.json create mode 100644 src/test/forkParity.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f76a1e6..039f0d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,18 +5,37 @@ Versioning follows the policy in [CONTRIBUTING.md](CONTRIBUTING.md#versioning). ## [Unreleased] +Astryx fixes a product used to carry as pnpm patches now ship in ui-common, +so its consumers get them without patching. + ### Added +- `ComplexSelector` (`@lablup/ui-common/ComplexSelector` and the root) is + ui-common's own copy of Astryx's, same API, adding `hasClear` and + `onClear`: a clear button between the spinner and the chevron while + `triggerLabel` is set, as `Selector` has. `onClear` runs, or + `onChange(undefined)` without it. Upstream: facebook/astryx#6362. - `DoubleToken`: a value's `endContent` renders in place of its visible label (a copy control around the text, say). The label stays the accessible name; `highlightKeyword` does not reach into it. +- `exports.exclude.json` can hide single names of a subpath (`exports`), and + `@lablup/ui-common/lab` is now written as named re-exports. Same names as + before. ### Fixed +- `Drawer` from `@lablup/ui-common/lab` is ui-common's own copy of lab's, + same API. An Escape from a layer opened inside the drawer (a modal + portalled out of it) no longer reaches the drawer, so the layer closes and + the drawer stays; an Escape that ends an IME composition no longer closes + it; and a consumer's `aria-modal` passes through on a scrimless drawer. +- `Tour`, `TourStep` and `useTour` from `@lablup/ui-common/lab` are + ui-common's own copies of lab's, same API. A step's highlight is promoted + into the top layer once and never hidden and re-shown, so under React + StrictMode the spotlight dim no longer paints over the callout. - `DoubleToken` squares inner end corners with `:not(:last-of-type)`, so an element trailing the tokens (a copy control's tooltip) no longer squares the last token's outer corners. - - `DataGrid`'s root (`.uic-data-grid`) is `min-width: 0; max-width: 100%`, so a wide grid inside a flex or grid parent scrolls itself instead of stretching the parent. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index abaf87c..72022a9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -41,6 +41,22 @@ subpath fails the generator, so stale entries get removed. So does a `replacedBy` that is neither a custom in `exports.customs.json` nor a mirrored subpath. +`exports` hides single names of a subpath instead of the whole of it: + +```json +{ + "name": "lab", + "exports": ["Drawer", "DrawerProps"], + "replacedBy": "Drawer", + "reason": "..." +} +``` + +The mirror file then lists the subpath's names one by one, minus these. `lab` +is always written that way, since Astryx ships it as one namespace with no +per-component subpath. A listed name the subpath no longer exports fails the +generator. + There is no other way to hide something. Do not curate the export map by hand. ### Adding a custom export @@ -67,12 +83,68 @@ exports. Two exceptions: - Entries marked `legacy` may collide, and Astryx's export wins in the root barrel. The mechanism is kept for a future deprecation; no entry uses it since the 0.1 look-alikes were removed. +- A fork (below) keeps Astryx's names, because its exclusion takes Astryx's + out of the mirror first. ## Name rule A ui-common component never shares a name with an Astryx core or lab export. Pick a different name, or use the Astryx component. +The one exception is a fork. + +## Forks of Astryx components + +A fork is a copy of an Astryx component with an upstream fix applied, shipped +under Astryx's own name and import path until Astryx ships the fix. It exists +because a product's pnpm `patchedDependencies` never reach that product's +consumers, and ui-common's consumers import Astryx through ui-common. + +| Fork | Where | Fix | Upstream | +| ----------------- | ----------------------------------- | ------------------------------------------------------------------------------------ | -------------------------------------------------------------------- | +| `ComplexSelector` | `@lablup/ui-common/ComplexSelector` | `hasClear` / `onClear` | [facebook/astryx#6362](https://github.com/facebook/astryx/pull/6362) | +| `Drawer` | `@lablup/ui-common/lab` | Escape stays inside (portalled layers, IME composition); `aria-modal` passes through | not filed | +| `Tour` | `@lablup/ui-common/lab` | a step's highlight is promoted once (StrictMode) | not filed | + +How one is put together: + +- **Source** is in `src/forks/<Name>/`, lab's too: upstream's + source (`astryx swizzle <Name>` rewrites its imports to public subpaths), + the fix, and a header saying what changed. An internal Astryx does not + export is inlined from the same version. +- **Styles** are not compiled here. `<Name>.styles.ts` holds the StyleX + objects Astryx's own build compiled for the pinned version, copied from its + `dist/` by `pnpm run sync:forks`, so the fork renders upstream's atomic class + names and their rules arrive with `astryx.css` / `lab/lab.css`. Where + upstream's compiler folded a `stylex.props` call into a class string, the + fork carries that string. No new CSS, no second copy of a rule, the same + cascade layer as upstream. +- **Exports**: an `exports.exclude.json` entry with `replacedBy` naming the + fork, and an `exports.customs.json` entry with `fork` naming the Astryx + module. A core fork takes the excluded subpath (`"subpath": "ComplexSelector"`) + and must export exactly the names Astryx's does; a lab fork excludes names of + `lab` and must export exactly those, and the generator writes them into the + `lab` mirror. Anything else keeps the name rule. +- **Tests**: upstream's tests, run against the fork (`<Name>.test.tsx`), and + ui-common's (`<Name>.fork.test.tsx`): the fix, markup parity with Astryx's + component where the fix does not apply, and a test that Astryx's component + still lacks the fix. +- **Provenance**: `src/forks/provenance.json` records the Astryx version and a + SHA-256 of every upstream file the fork uses. The copied code is MIT; its + licence is in `NOTICE`. + +`src/forks/forks.test.ts` fails as soon as the installed Astryx differs from +the recorded version or file. On an Astryx bump: + +1. If upstream now carries the fix (the "still lacks the fix" test fails, or + the issue is closed), delete the fork: its directory, its entries in + `exports.customs.json`, `exports.exclude.json` and `provenance.json`, and + its row above and in NOTICE's scope. Run `pnpm run gen:exports`. Astryx's + own component comes back under the same name. +2. Otherwise re-take upstream's new source, re-apply the fix, then run + `node scripts/sync-forks.mjs --accept` to regenerate the styles and record + the new version and hashes. + ## Components | Component | Source | Built on | @@ -334,8 +406,10 @@ Then, by hand: 1. Read the `gen:exports` diff. Update `exports.exclude.json` if the generator reports a stale entry or a new data export. -2. `pnpm run verify`. -3. Note new and removed subpaths in `CHANGELOG.md`. A removed subpath is a +2. Re-sync or delete each fork ("Forks of Astryx components"); + `src/forks/forks.test.ts` fails until you do. +3. `pnpm run verify`. +4. Note new and removed subpaths in `CHANGELOG.md`. A removed subpath is a breaking change. ## The upgrade tool diff --git a/NOTICE b/NOTICE index a8076c5..7b0c422 100644 --- a/NOTICE +++ b/NOTICE @@ -38,3 +38,33 @@ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- + +The forks of Astryx components under src/forks/ (shipped in dist/forks/), +including their generated *.styles files, are copies of Astryx source from +@astryxdesign/core and @astryxdesign/lab (https://github.com/facebook/astryx), +with fixes applied; src/forks/provenance.json records each one's origin. +Astryx is licensed under the MIT License: + +-------------------------------------------------------------------------------- +MIT License + +Copyright (c) 2026 Meta Platforms, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +-------------------------------------------------------------------------------- diff --git a/astryx.integration.mjs b/astryx.integration.mjs index 32e80a1..4647bdc 100644 --- a/astryx.integration.mjs +++ b/astryx.integration.mjs @@ -28,6 +28,7 @@ export default { "Use Modal, not Dialog. ui-common hides Astryx Dialog (exports.exclude.json) so every product has one dialog surface.", "Theme with <Theme theme={lablupTheme}> from @lablup/ui-common/theme/lablup. Declare @layer reset, theme, base, astryx-base, astryx-theme, ui-common, components, utilities; first.", "ui-common strings resolve through Astryx InternationalizationProvider: pass uiCommonMessages from @lablup/ui-common/i18n-catalog in its messages.", + "ComplexSelector, and lab's Drawer and Tour, come from ui-common: fixed copies of Astryx's, same API and import paths. ComplexSelector adds hasClear/onClear.", ], }, issuesUrl: "https://github.com/lablup/ui-common/issues", diff --git a/astryx/docs/ui-common.doc.mjs b/astryx/docs/ui-common.doc.mjs index 54d6236..4f8a673 100644 --- a/astryx/docs/ui-common.doc.mjs +++ b/astryx/docs/ui-common.doc.mjs @@ -44,6 +44,10 @@ export const docs = { type: "prose", text: "ui-common's own components are built on Astryx primitives and never share a name with an Astryx export. Each is exported from the package root; Modal, AlertModal and Form also have their own subpaths.", }, + { + type: "prose", + text: "The exception is a fixed copy of an Astryx component, carrying an upstream fix Astryx has not shipped yet. It keeps Astryx's name, props and import path, and replaces Astryx's in ui-common's exports: `ComplexSelector` (`@lablup/ui-common/ComplexSelector`) adds `hasClear` and `onClear`; lab's `Drawer` keeps an Escape from a layer opened inside it, or one ending an IME composition, from closing it and passes `aria-modal` through; lab's `Tour` keeps a step's spotlight under its callout in StrictMode (both at `@lablup/ui-common/lab`).", + }, { type: "list", style: "unordered", diff --git a/cli/agents.mjs b/cli/agents.mjs index 63f3af8..8986d47 100644 --- a/cli/agents.mjs +++ b/cli/agents.mjs @@ -15,8 +15,9 @@ import { customExports, dependencyDir, dependencyVersion, - excludedExports, findProjectDir, + forkedExports, + hiddenExports, importAstryxInternal, ownPackageJson, } from "./paths.mjs"; @@ -70,19 +71,24 @@ async function coreComponentCount() { /** ui-common's own lines, appended to the rewritten Astryx block. */ function uiCommonSection({ version, astryxVersion, invocation }) { const components = customExports() - .filter((c) => !c.legacy && /^[A-Z]/.test(c.name)) + .filter((c) => !c.legacy && !c.fork && /^[A-Z]/.test(c.name)) .map((c) => c.name); const lines = [ `UI-COMMON (@lablup/ui-common v${version} wraps Astryx v${astryxVersion}):`, "- Import only from @lablup/ui-common: the root, or the same subpath Astryx uses (@lablup/ui-common/Button, /theme/tokens.stylex, /lab). Never import @astryxdesign/* directly.", `- Layers: declare \`${LAYER_ORDER}\` once, first, in the entry stylesheet. ui-common's styles sit in \`ui-common\`; yours go in \`components\` / \`utilities\`.`, ]; - for (const entry of excludedExports()) { + for (const entry of hiddenExports()) { if (!entry.replacedBy) continue; lines.push( `- Use ${entry.replacedBy} (@lablup/ui-common/${entry.replacedBy}), not ${entry.name}: ui-common hides ${entry.name}.`, ); } + for (const fork of forkedExports()) { + lines.push( + `- ${fork.name} (${fork.from}) comes from ui-common: its own copy of Astryx's, same API and import path. ${fork.reason}`, + ); + } lines.push( "- Theme: <Theme theme={lablupTheme}> with lablupTheme from @lablup/ui-common/theme/lablup/built, plus @lablup/ui-common/theme/lablup/theme.css. A product palette is its own defineTheme over lablupTheme.", "- Strings: every built-in string is a prop; defaults come from ui-common's catalog. Pass uiCommonMessages from @lablup/ui-common/i18n-catalog to Astryx's InternationalizationProvider. Never a product i18n runtime.", diff --git a/cli/paths.mjs b/cli/paths.mjs index 19efa76..1554fb2 100644 --- a/cli/paths.mjs +++ b/cli/paths.mjs @@ -97,7 +97,7 @@ export function dependencyVersion(name) { export function excludedExports() { const file = join(PACKAGE_ROOT, "exports.exclude.json"); if (!existsSync(file)) return []; - return /** @type {Array<{name: string, replacedBy: string|null, reason: string}>} */ ( + return /** @type {Array<{name: string, exports?: string[], replacedBy: string|null, reason: string}>} */ ( readJson(file) ); } @@ -106,11 +106,44 @@ export function excludedExports() { export function customExports() { const file = join(PACKAGE_ROOT, "exports.customs.json"); if (!existsSync(file)) return []; - return /** @type {Array<{name: string, source: string, subpath?: string, legacy?: object}>} */ ( + return /** @type {Array<{name: string, source: string, subpath?: string, fork?: string, legacy?: object}>} */ ( readJson(file) ); } +/** + * ui-common's own copies of Astryx components (src/forks/): same names and + * import paths as Astryx's, which exports.exclude.json hides. `names` are the + * names a reader may meet it by; `from` is where a consumer imports it. + * + * @returns {Array<{name: string, from: string, names: string[], reason: string}>} + */ +export function forkedExports() { + const exclusions = excludedExports(); + return customExports() + .filter((c) => c.fork !== undefined) + .map((c) => { + const exclusion = exclusions.find((e) => e.replacedBy === c.name); + return { + name: c.name, + from: `@lablup/ui-common/${c.subpath ?? exclusion?.name ?? ""}`, + names: exclusion?.exports ?? [c.name], + reason: exclusion?.reason ?? "", + }; + }); +} + +/** + * The exclusions that hide an Astryx name behind a different one. A fork's + * exclusion is not one: the name stays, now ui-common's. + */ +export function hiddenExports() { + const forks = new Set(forkedExports().map((f) => f.name)); + return excludedExports().filter( + (e) => e.replacedBy === null || !forks.has(e.replacedBy), + ); +} + /** * The nearest directory at or above `start` holding a package.json. * diff --git a/cli/rewrite.mjs b/cli/rewrite.mjs index 6529fa4..c3d79fe 100644 --- a/cli/rewrite.mjs +++ b/cli/rewrite.mjs @@ -5,7 +5,7 @@ * no quote, backslash or control character, so it is safe inside JSON string * literals: `--json` output stays valid JSON. */ -import { excludedExports } from "./paths.mjs"; +import { forkedExports, hiddenExports } from "./paths.mjs"; /** Astryx CLI subcommands. Used to tell `astryx component` from `astryx-base`. */ export const ASTRYX_COMMANDS = [ @@ -88,7 +88,8 @@ export function rewriteCommands(text, invocation = "ui-common") { /** * Lines telling the reader that a name they are looking at is one ui-common - * hides, and what to use instead. + * hides, and what to use instead; or one ui-common exports from its own copy + * of the Astryx component, and what that copy changes. * * @param {string} text rewritten output * @param {string[]} args the command line, so `component Dialog` is caught @@ -96,18 +97,28 @@ export function rewriteCommands(text, invocation = "ui-common") { */ export function exclusionNotes(text, args = []) { const notes = []; - for (const entry of excludedExports()) { + const mentions = (name) => + new RegExp(String.raw`(?<![\w.-])${name}(?![\w.-])`).test(text) || + args.includes(name); + for (const entry of hiddenExports()) { // Asking about the replacement itself needs no pointer to it. if (!entry.replacedBy || args.includes(entry.replacedBy)) continue; const name = entry.name; - const mention = new RegExp(String.raw`(?<![\w.-])${name}(?![\w.-])`); - if (mention.test(text) || args.includes(name)) { + if (mentions(name)) { notes.push( `Note: @lablup/ui-common does not export ${name}. Use ${entry.replacedBy} ` + `(@lablup/ui-common/${entry.replacedBy}), not ${name}. ${entry.reason}`, ); } } + for (const fork of forkedExports()) { + if (fork.names.some(mentions)) { + notes.push( + `Note: ${fork.name} (${fork.from}) comes from ui-common: its own copy of ` + + `Astryx's, same API and import path. ${fork.reason}`, + ); + } + } return notes; } diff --git a/exports.customs.json b/exports.customs.json index 756e3c0..5628cb9 100644 --- a/exports.customs.json +++ b/exports.customs.json @@ -9,6 +9,12 @@ { "name": "BulkEditFormItem", "source": "components/BulkEditFormItem/index.ts" }, { "name": "BulkErrorModal", "source": "components/BulkErrorModal/index.ts" }, { "name": "ColorPicker", "source": "components/ColorPicker/index.ts" }, + { + "name": "ComplexSelector", + "source": "forks/ComplexSelector/index.ts", + "subpath": "ComplexSelector", + "fork": "@astryxdesign/core/ComplexSelector" + }, { "name": "ConfirmPopover", "source": "components/ConfirmPopover/index.ts" }, { "name": "CountBadge", "source": "components/CountBadge/index.ts" }, { "name": "CountdownBorder", "source": "components/CountdownBorder/index.ts" }, @@ -18,6 +24,11 @@ { "name": "DividedRow", "source": "components/DividedRow/index.ts" }, { "name": "DoubleBadge", "source": "components/DoubleBadge/index.ts" }, { "name": "DoubleToken", "source": "components/DoubleToken/index.ts" }, + { + "name": "Drawer", + "source": "forks/Drawer/index.ts", + "fork": "@astryxdesign/lab" + }, { "name": "ErrorState", "source": "components/ErrorState/index.ts" }, { "name": "Form", "source": "components/Form/index.ts", "subpath": "Form" }, { "name": "IconWithTooltip", "source": "components/IconWithTooltip/index.ts" }, @@ -39,6 +50,11 @@ { "name": "TextHighlighter", "source": "components/TextHighlighter/index.ts" }, { "name": "TokenList", "source": "components/TokenList/index.ts" }, { "name": "TokenRow", "source": "components/TokenRow/index.ts" }, + { + "name": "Tour", + "source": "forks/Tour/index.ts", + "fork": "@astryxdesign/lab" + }, { "name": "UncontrolledInput", "source": "components/UncontrolledInput/index.ts" }, { "name": "UnitGrid", "source": "components/UnitGrid/index.ts" }, { "name": "usePrefersReducedMotion", "source": "hooks/index.ts" } diff --git a/exports.exclude.json b/exports.exclude.json index d8c735b..0238a17 100644 --- a/exports.exclude.json +++ b/exports.exclude.json @@ -4,6 +4,31 @@ "replacedBy": "AlertModal", "reason": "Dialog-based surfaces go through Modal's level stack; a raw AlertDialog bypasses it." }, + { + "name": "ComplexSelector", + "replacedBy": "ComplexSelector", + "reason": "Adds hasClear and onClear, a clear button as Selector has (facebook/astryx#6362)." + }, + { + "name": "lab", + "exports": ["Drawer", "DrawerProps"], + "replacedBy": "Drawer", + "reason": "An Escape from a layer opened inside the drawer, or one that ends an IME composition, no longer closes it; aria-modal passes through." + }, + { + "name": "lab", + "exports": [ + "Tour", + "TourProps", + "TourStep", + "TourStepProps", + "useTour", + "UseTourReturn", + "TourDismissSource" + ], + "replacedBy": "Tour", + "reason": "A step's highlight is promoted into the top layer once, so under StrictMode the spotlight dim no longer covers the callout." + }, { "name": "Dialog", "replacedBy": "Modal", diff --git a/package.json b/package.json index df3733b..1e9c2d9 100644 --- a/package.json +++ b/package.json @@ -187,10 +187,6 @@ "types": "./dist/astryx/CommandPalette.d.ts", "import": "./dist/astryx/CommandPalette.js" }, - "./ComplexSelector": { - "types": "./dist/astryx/ComplexSelector.d.ts", - "import": "./dist/astryx/ComplexSelector.js" - }, "./ContextMenu": { "types": "./dist/astryx/ContextMenu.d.ts", "import": "./dist/astryx/ContextMenu.js" @@ -561,6 +557,10 @@ "types": "./dist/components/AlertModal/index.d.ts", "import": "./dist/components/AlertModal/index.js" }, + "./ComplexSelector": { + "types": "./dist/forks/ComplexSelector/index.d.ts", + "import": "./dist/forks/ComplexSelector/index.js" + }, "./Form": { "types": "./dist/components/Form/index.d.ts", "import": "./dist/components/Form/index.js" @@ -580,6 +580,7 @@ "test": "vitest run", "test:watch": "vitest", "gen:exports": "node scripts/gen-exports.mjs", + "sync:forks": "node scripts/sync-forks.mjs", "theme:build": "astryx theme build src/theme/lablup/lablupTheme.ts -o src/theme/lablup/built/theme.css --icons-specifier @astryxdesign/theme-neutral", "theme:check": "astryx theme build -c src/theme/lablup/lablupTheme.ts -o src/theme/lablup/built/theme.css --icons-specifier @astryxdesign/theme-neutral", "check:pack": "node scripts/check-pack.mjs", diff --git a/scripts/check-pack.mjs b/scripts/check-pack.mjs index cbe7339..da11f49 100644 --- a/scripts/check-pack.mjs +++ b/scripts/check-pack.mjs @@ -206,6 +206,15 @@ const THIRD_PARTY_NOTICES = [ "The above copyright notice and this permission notice shall be\nincluded in all copies or substantial portions of the Software.", ], }, + { + what: "Astryx source in the forks (src/forks/, dist/forks/)", + file: "NOTICE", + mustContain: [ + "Copyright (c) 2026 Meta Platforms, Inc.", + "Permission is hereby granted, free of charge, to any person obtaining a copy", + "The above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.", + ], + }, ]; for (const { what, file, mustContain } of THIRD_PARTY_NOTICES) { diff --git a/scripts/sync-forks.d.mts b/scripts/sync-forks.d.mts new file mode 100644 index 0000000..9a41dca --- /dev/null +++ b/scripts/sync-forks.d.mts @@ -0,0 +1,27 @@ +/** Types for the fork upkeep script, so tests can import it. */ + +export interface ForkProvenance { + /** The Astryx package the fork was taken from. */ + package: string; + /** Its exact version at the time. */ + version: string; + /** The upstream issue or pull request carrying the fix, if any. */ + upstream: string | null; + /** Package-relative path to SHA-256 of every upstream file the fork uses. */ + files: Record<string, string>; + /** The generated styles file and the compiled constants it copies. */ + styles?: { file: string; from: Array<{ file: string; consts: string[] }> }; +} + +export declare const PROVENANCE: string; + +export declare function readProvenance(): Record<string, ForkProvenance>; + +export declare function currentState(): Promise< + Record< + string, + { version: string; files: Record<string, string | null>; styles?: string } + > +>; + +export declare function extractConst(source: string, name: string): string; diff --git a/scripts/sync-forks.mjs b/scripts/sync-forks.mjs new file mode 100644 index 0000000..9b28ed4 --- /dev/null +++ b/scripts/sync-forks.mjs @@ -0,0 +1,172 @@ +/** + * Upkeep for the Astryx forks under src/forks/ (CONTRIBUTING, "Forks of + * Astryx components"). + * + * `src/forks/provenance.json` records, for each fork, the Astryx package and + * version it was taken from, a SHA-256 of every upstream file it copies or + * compiles from, and which compiled StyleX constants it reuses. From that: + * + * - `<fork>.styles.ts` is generated: the named constants, copied verbatim from + * the pinned package's compiled `dist/` file. The fork renders with + * upstream's own atomic class names, so it needs no StyleX compile step and + * no CSS of its own (src/forks/compiled.ts). + * - `src/forks/forks.test.ts` fails when the installed Astryx differs from the + * recorded version or any recorded file changed, and when a styles file is + * stale. An Astryx bump therefore stops CI until someone re-syncs the fork + * with upstream (or deletes it, once upstream carries the fix). + * + * Usage: + * node scripts/sync-forks.mjs rewrite the styles files + * node scripts/sync-forks.mjs --accept also record the installed versions + * and hashes, after re-syncing by hand + */ +import { createHash } from "node:crypto"; +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +import * as prettier from "prettier"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + +export const PROVENANCE = "src/forks/provenance.json"; + +const GENERATED_HEADER = "@generated by scripts/sync-forks.mjs. Do not edit."; + +/** @returns {Record<string, {package: string, version: string, upstream: string, files: Record<string,string>, styles?: {file: string, from: Array<{file: string, consts: string[]}>}}>} */ +export function readProvenance() { + return JSON.parse(readFileSync(join(root, PROVENANCE), "utf8")); +} + +export function packageDir(name) { + const dir = join(root, "node_modules", name); + if (!existsSync(join(dir, "package.json"))) { + throw new Error(`${name} is not installed. Run pnpm install first.`); + } + return dir; +} + +export function installedVersion(name) { + return JSON.parse(readFileSync(join(packageDir(name), "package.json"), "utf8")) + .version; +} + +export function sha256(path) { + return createHash("sha256").update(readFileSync(path)).digest("hex"); +} + +/** + * The source text of the top-level `const <name> = …;` (or `export const`) in + * a compiled Astryx module: from `const` to the `;` that closes it at depth 0. + * Strings and comments are skipped so a brace inside a class list or a + * comment never miscounts. + */ +export function extractConst(source, name) { + const match = new RegExp(`^(?:export )?const ${name} = `, "m").exec(source); + if (!match) throw new Error(`no top-level const "${name}"`); + let i = match.index + match[0].length; + let depth = 0; + for (; i < source.length; i++) { + const c = source[i]; + if (c === '"' || c === "'" || c === "`") { + for (i++; i < source.length && source[i] !== c; i++) { + if (source[i] === "\\") i++; + } + continue; + } + if (c === "/" && source[i + 1] === "/") { + i = source.indexOf("\n", i); + continue; + } + if (c === "/" && source[i + 1] === "*") { + i = source.indexOf("*/", i) + 1; + continue; + } + if (c === "{" || c === "[" || c === "(") depth++; + if (c === "}" || c === "]" || c === ")") depth--; + if (c === ";" && depth === 0) { + return source.slice(match.index + match[0].length, i).trim(); + } + } + throw new Error(`unterminated const "${name}"`); +} + +/** + * The compiled constants of one fork as a module, one `export const` per + * name, in the order provenance.json lists them (a later constant may refer + * to an earlier one, as `dynamicStyles` refers to `_temp`). + */ +export async function renderStyles(fork, entry) { + const dir = packageDir(entry.package); + let body = + `/* eslint-disable */\n// @ts-nocheck\n// ${GENERATED_HEADER}\n//\n` + + `// Compiled StyleX from ${entry.package} ${entry.version}, reused by the\n` + + `// ${fork} fork so it renders with upstream's own class names\n` + + `// (src/forks/compiled.ts). Copyright (c) Meta Platforms, Inc. and\n` + + `// affiliates; MIT, see NOTICE.\n`; + for (const { file, consts } of entry.styles.from) { + const source = readFileSync(join(dir, file), "utf8"); + body += `\n// ${file}\n`; + for (const name of consts) { + body += `export const ${name} = ${extractConst(source, name)};\n`; + } + } + const path = join(root, entry.styles.file); + const config = (await prettier.resolveConfig(path)) ?? {}; + return prettier.format(body, { ...config, filepath: path }); +} + +/** Everything the drift guard compares, computed from the installed packages. */ +export async function currentState() { + const provenance = readProvenance(); + const out = {}; + for (const [fork, entry] of Object.entries(provenance)) { + const dir = packageDir(entry.package); + const files = {}; + for (const file of Object.keys(entry.files)) { + const path = join(dir, file); + files[file] = existsSync(path) ? sha256(path) : null; + } + out[fork] = { + version: installedVersion(entry.package), + files, + styles: entry.styles ? await renderStyles(fork, entry) : undefined, + }; + } + return out; +} + +async function main() { + const accept = process.argv.includes("--accept"); + const provenance = readProvenance(); + const state = await currentState(); + for (const [fork, entry] of Object.entries(provenance)) { + const now = state[fork]; + if (entry.styles) writeFileSync(join(root, entry.styles.file), now.styles); + const changed = Object.keys(entry.files).filter( + (f) => entry.files[f] !== now.files[f], + ); + if (now.version !== entry.version || changed.length > 0) { + console.log( + `${fork}: ${entry.package} ${entry.version} -> ${now.version}` + + (changed.length > 0 ? `; changed: ${changed.join(", ")}` : ""), + ); + } + if (accept) { + entry.version = now.version; + entry.files = now.files; + } + } + if (accept) { + const path = join(root, PROVENANCE); + const config = (await prettier.resolveConfig(path)) ?? {}; + writeFileSync( + path, + await prettier.format(JSON.stringify(provenance), { ...config, filepath: path }), + ); + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + await main(); +} diff --git a/src/astryx/ComplexSelector.ts b/src/astryx/ComplexSelector.ts deleted file mode 100644 index 4f2471c..0000000 --- a/src/astryx/ComplexSelector.ts +++ /dev/null @@ -1,2 +0,0 @@ -// @generated by scripts/gen-exports.mjs. Do not edit. -export * from "@astryxdesign/core/ComplexSelector"; diff --git a/src/astryx/lab.ts b/src/astryx/lab.ts index 65195d8..5b8b9ba 100644 --- a/src/astryx/lab.ts +++ b/src/astryx/lab.ts @@ -27,7 +27,6 @@ export { CircularProgress, CodeEditor, DEFAULT_CHAT_EMOJIS, - Drawer, InfoTip, ListInput, LogStream, @@ -54,8 +53,6 @@ export { ThreeDScatter, ThreeDScatterGL, ThreeDSurface, - Tour, - TourStep, TransferList, TransferListSelector, bellIcon, @@ -101,7 +98,6 @@ export { useScheduleContext, useSchedulePaginationPlugin, useScheduleViewSelectorPlugin, - useTour, variations, xIcon, } from "@astryxdesign/lab"; @@ -151,7 +147,6 @@ export type { CircularProgressVariant, CircularProgressVariantMap, CodeEditorProps, - DrawerProps, IconShape, IconShapeRole, InfoTipProps, @@ -232,17 +227,26 @@ export type { ThreeDScatterProps, ThreeDSurfaceProps, TokenResolver, - TourDismissSource, - TourProps, - TourStepProps, TransferListOption, TransferListProps, TransferListSelectorCommitBehavior, TransferListSelectorProps, UseChartRangeOptions, UseChartRangeReturn, - UseTourReturn, YBaseline, ZonedDateTime, ZonedDateTimeRange, } from "@astryxdesign/lab"; + +// Drawer: ui-common's own copy, in place of Astryx's. +export { Drawer } from "../forks/Drawer"; +export type { DrawerProps } from "../forks/Drawer"; + +// Tour: ui-common's own copy, in place of Astryx's. +export { Tour, TourStep, useTour } from "../forks/Tour"; +export type { + TourDismissSource, + TourProps, + TourStepProps, + UseTourReturn, +} from "../forks/Tour"; diff --git a/src/exports.test.ts b/src/exports.test.ts index 4f8adcc..1f55034 100644 --- a/src/exports.test.ts +++ b/src/exports.test.ts @@ -180,3 +180,49 @@ describe("export surface rules", () => { } }); }); + +describe("forks of Astryx components", () => { + const COMPLEX_SELECTOR_NAMES = [ + "ComplexSelector", + "ComplexSelectorHandle", + "ComplexSelectorProps", + "ComplexSelectorRenderState", + "ComplexSelectorSize", + "ComplexSelectorStatus", + "ComplexSelectorVariant", + ]; + + it("serves ComplexSelector's subpath from ui-common's copy", () => { + expect(result.exports["./ComplexSelector"]).toEqual({ + types: "./dist/forks/ComplexSelector/index.d.ts", + import: "./dist/forks/ComplexSelector/index.js", + }); + expect(result.files.has("src/astryx/ComplexSelector.ts")).toBe(false); + }); + + it("exports every ComplexSelector name from the root, all from ui-common's copy", () => { + const barrel = result.files.get("src/index.ts") ?? ""; + const fromFork = /export (?:type )?\{([^}]*)\} from "\.\/forks\/ComplexSelector"/g; + const names = [...barrel.matchAll(fromFork)].flatMap((m) => + m[1]! + .split(",") + .map((n) => n.trim()) + .filter(Boolean), + ); + expect(names.sort()).toEqual(COMPLEX_SELECTOR_NAMES); + for (const name of COMPLEX_SELECTOR_NAMES) { + expect(result.report.replaced).toContain(name); + } + }); + + it("exports lab's Drawer and Tour from ui-common's copies, not lab", () => { + const lab = result.files.get("src/astryx/lab.ts") ?? ""; + const [fromLab, ...own] = lab.split(/\n\/\/ (?=Drawer|Tour)/); + for (const name of ["Drawer", "DrawerProps", "Tour", "TourStep", "useTour"]) { + expect(fromLab).not.toMatch(new RegExp(`^\\s+${name},$`, "m")); + expect(result.report.replaced).toContain(name); + } + expect(own.join("\n")).toContain('from "../forks/Drawer";'); + expect(own.join("\n")).toContain('from "../forks/Tour";'); + }); +}); diff --git a/src/forks/ComplexSelector/ComplexSelector.fork.test.tsx b/src/forks/ComplexSelector/ComplexSelector.fork.test.tsx new file mode 100644 index 0000000..9a1efe5 --- /dev/null +++ b/src/forks/ComplexSelector/ComplexSelector.fork.test.tsx @@ -0,0 +1,142 @@ +/** + * ui-common's tests for its ComplexSelector fork: the fix it carries + * (`hasClear` / `onClear`, facebook/astryx#6362), and that everything else + * renders exactly as Astryx's does. + */ +import { describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { ComplexSelector as UpstreamComplexSelector } from "@astryxdesign/core/ComplexSelector"; + +import { comparableMarkup } from "../../test/forkParity"; +import { ComplexSelector, type ComplexSelectorProps } from "./ComplexSelector"; + +type Props = Omit<ComplexSelectorProps<string | undefined>, "children">; + +function renderSelector(props: Partial<Props> = {}) { + return render( + <ComplexSelector<string | undefined> + label="Fruit" + value="apple" + triggerLabel="Apple" + {...props} + > + {() => <button type="button">Banana</button>} + </ComplexSelector>, + ); +} + +const clearButton = () => screen.queryByRole("button", { name: /clear/i }); + +describe("ComplexSelector fork: hasClear", () => { + it("renders no clear button unless asked", () => { + renderSelector(); + expect(clearButton()).not.toBeInTheDocument(); + }); + + it("renders a clear button, named after the field, while a value shows", () => { + renderSelector({ hasClear: true }); + const button = clearButton(); + expect(button).toBeInTheDocument(); + expect(button).toHaveAccessibleName(/Fruit/); + }); + + it("puts the clear button between the spinner and the chevron", () => { + const { container } = renderSelector({ hasClear: true, isLoading: true }); + const trigger = container.querySelector(".astryx-complex-selector")!; + const order = Array.from(trigger.children).map((el) => + el.matches('[role="status"], .astryx-spinner') + ? "spinner" + : el.contains(clearButton()) + ? "clear" + : el.matches(".astryx-complex-selector-indicator-icon") + ? "chevron" + : "other", + ); + expect(order.slice(-3)).toEqual(["spinner", "clear", "chevron"]); + }); + + it("hides the clear button with no value to clear, and while disabled", () => { + const { unmount } = renderSelector({ hasClear: true, triggerLabel: undefined }); + expect(clearButton()).not.toBeInTheDocument(); + unmount(); + renderSelector({ hasClear: true, isDisabled: true }); + expect(clearButton()).not.toBeInTheDocument(); + }); + + it("calls onClear, and does not open the popover", async () => { + const onClear = vi.fn(); + const onChange = vi.fn(); + const onOpenChange = vi.fn(); + renderSelector({ hasClear: true, onClear, onChange, onOpenChange }); + await userEvent.click(clearButton()!); + expect(onClear).toHaveBeenCalledTimes(1); + expect(onChange).not.toHaveBeenCalled(); + expect(onOpenChange).not.toHaveBeenCalled(); + expect(screen.getByRole("button", { name: "Fruit" })).toHaveAttribute( + "aria-expanded", + "false", + ); + }); + + it("falls back to onChange(undefined) without onClear", async () => { + const onChange = vi.fn(); + renderSelector({ hasClear: true, onChange }); + await userEvent.click(clearButton()!); + expect(onChange).toHaveBeenCalledExactlyOnceWith(undefined); + }); +}); + +describe("ComplexSelector fork: parity with Astryx", () => { + const cases: Array<[string, Partial<Props>]> = [ + ["a value", {}], + ["the placeholder", { triggerLabel: undefined }], + ["ghost, small, loading", { variant: "ghost", size: "sm", isLoading: true }], + ["disabled ghost", { variant: "ghost", isDisabled: true }], + [ + "a status and a description", + { status: { type: "error", message: "Pick one" }, description: "Help" }, + ], + ["a start icon, required", { startIcon: "search", isRequired: true }], + ]; + + it.each(cases)("renders Astryx's markup with %s", (_, props) => { + const fork = render( + <ComplexSelector<string | undefined> + label="Fruit" + value="apple" + triggerLabel="Apple" + {...props} + > + {() => null} + </ComplexSelector>, + ); + const upstream = render( + <UpstreamComplexSelector<string | undefined> + label="Fruit" + value="apple" + triggerLabel="Apple" + {...props} + > + {() => null} + </UpstreamComplexSelector>, + ); + expect(comparableMarkup(fork.container)).toBe(comparableMarkup(upstream.container)); + }); + + // When this fails, Astryx has shipped the fix: delete the fork and its + // exports.exclude.json entry (CONTRIBUTING, "Forks of Astryx components"). + it("still differs from Astryx's, which has no clear button", () => { + render( + <UpstreamComplexSelector + label="Fruit" + value="apple" + triggerLabel="Apple" + {...({ hasClear: true } as object)} + > + {() => null} + </UpstreamComplexSelector>, + ); + expect(clearButton()).not.toBeInTheDocument(); + }); +}); diff --git a/src/forks/ComplexSelector/ComplexSelector.styles.ts b/src/forks/ComplexSelector/ComplexSelector.styles.ts new file mode 100644 index 0000000..52438e4 --- /dev/null +++ b/src/forks/ComplexSelector/ComplexSelector.styles.ts @@ -0,0 +1,137 @@ +/* eslint-disable */ +// @ts-nocheck +// @generated by scripts/sync-forks.mjs. Do not edit. +// +// Compiled StyleX from @astryxdesign/core 0.6.2, reused by the +// ComplexSelector fork so it renders with upstream's own class names +// (src/forks/compiled.ts). Copyright (c) Meta Platforms, Inc. and +// affiliates; MIT, see NOTICE. + +// dist/ComplexSelector/ComplexSelector.js +export const styles = { + triggerContainer: { + kVAEAm: "x1n2onr6", + k1xSpc: "x78zum5", + kGNEyG: "x6s0dn4", + kjj79g: "x1qughib", + kOIVth: "x1txdalj", + kzqmXN: "xh8yej3", + k8WAf4: "xce4md1", + kg3NbH: "xrrkdod", + kMv6JI: "x9ynric", + kGuDYH: "xcr08ib xdk3u5w", + kLWn49: "x1kq96og", + kMwMTN: "x1tgivj0", + kkrTdU: "x1ypdohk x16khyan", + $$css: true, + }, + trigger: { + k1xSpc: "x78zum5", + kGNEyG: "x6s0dn4", + kjj79g: "x1qughib", + kOIVth: "x1txdalj", + kzQI83: "x1iyjqo2", + kmuXW: "xs83m0k", + kCS8Yb: "x1r8uery", + k7Eaqz: "xeuugli", + kmVPX3: "x1717udv", + kogj98: "x1ghz6dp", + kMzoRj: "xc342km", + ksu8eU: "xng3xce", + kWkggS: "xjbqb8w", + kMv6JI: "xjb2p0i", + kGuDYH: "x1qlqyl8", + kLWn49: "x15bjb6t", + kMwMTN: "x1heor9g", + kkrTdU: "x1ypdohk x16khyan", + kI3sdo: "x1a2a7pz", + kaIpWk: "xh6dtrn", + $$css: true, + }, + triggerText: { + kzQI83: "x1iyjqo2", + k7Eaqz: "xeuugli", + kVQacm: "xb3r6kr", + kg5iWk: "xlyipyv", + khDVqt: "xuxw1ft", + k9WMMc: "x1yc453h", + $$css: true, + }, + placeholder: { + kMwMTN: "xv1l7n4", + $$css: true, + }, + triggerGhost: { + kzqmXN: "x14atkfc", + kMzoRj: "xc342km", + kWkggS: "xjbqb8w", + kGVxlE: "x1gnnqk1 x1s17dad x15ysqaf", + k63SB2: "x1e4wzip", + k1ekBW: "xrafxwg", + k3aq6I: "x3oybdh xk4oym4", + $$css: true, + }, + triggerGhostDisabled: { + kKwaWg: "x18o3ruo", + k3aq6I: "x1c071of x1pdlv7q", + $$css: true, + }, + triggerIcon: { + kmuXW: "x2lah0s", + $$css: true, + }, + triggerIconRotation: { + k1ekBW: "x11xpdln", + kIyJzY: "xuedmi6", + kAMwcw: "xlr8y92", + k3nNDw: "x1g0ag68", + $$css: true, + }, + triggerIconOpen: { + k3aq6I: "x19jd1h0", + $$css: true, + }, + popover: { + k7Eaqz: "xrzjruh", + $$css: true, + }, + content: { + kB7OPa: "x9f619", + kskxy: "xpnbb6b", + kVQacm: "xysyzu8", + kmVPX3: "x1b2ylru", + $$css: true, + }, + sm: { + kZKoxP: "x6k0iem", + $$css: true, + }, + md: { + kZKoxP: "x1ueg155", + $$css: true, + }, + lg: { + kZKoxP: "xssyfek", + $$css: true, + }, + disabled: { + kkrTdU: "xt0e3qv", + $$css: true, + }, +}; + +// dist/utils/interactionOverlay.stylex.js +export const interactionOverlayStyles = { + backgroundColor: { + kWkggS: "xjbqb8w x1anq1lc xoevpu5 xprvw0a", + $$css: true, + }, + backgroundImage: { + kKwaWg: "x7uyq82 xmvprkv xetgvay", + $$css: true, + }, + backgroundImageOnNeutral: { + kKwaWg: "x14bno8m xzmimnh x1otsd3y xo3fi6e", + $$css: true, + }, +}; diff --git a/src/forks/ComplexSelector/ComplexSelector.test.tsx b/src/forks/ComplexSelector/ComplexSelector.test.tsx new file mode 100644 index 0000000..1b3910f --- /dev/null +++ b/src/forks/ComplexSelector/ComplexSelector.test.tsx @@ -0,0 +1,590 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. +// Modifications copyright (c) Lablup Inc. +// +// Forked from @astryxdesign/core 0.6.2, src/ComplexSelector/ComplexSelector.test.tsx +// (MIT; see NOTICE). Upstream's tests, run against the fork; ui-common's own tests are +// in the *.fork.test.tsx beside it. +// Provenance and the drift guard: src/forks/provenance.json. + +/** + * @file ComplexSelector.test.tsx + * @input Uses vitest, Testing Library, user-event, and ComplexSelector + * @output Unit tests for selection, trigger variants, positioning, and the imperative handle + * @position Tests; validates the ComplexSelector public interaction contract + * + * SYNC: When ComplexSelector.tsx API changes, update these tests. + */ + +import React from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { ComplexSelector, type ComplexSelectorHandle } from "./ComplexSelector"; + +const originalMatches = HTMLElement.prototype.matches; + +// Mock the Popover API, which jsdom does not implement. +beforeEach(() => { + HTMLElement.prototype.showPopover = vi.fn(function (this: HTMLElement) { + this.setAttribute("popover-open", ""); + const event = new Event("toggle"); + Object.defineProperty(event, "newState", { value: "open" }); + this.dispatchEvent(event); + }); + HTMLElement.prototype.hidePopover = vi.fn(function (this: HTMLElement) { + this.removeAttribute("popover-open"); + const event = new Event("toggle"); + Object.defineProperty(event, "newState", { value: "closed" }); + this.dispatchEvent(event); + }); + Object.defineProperty(HTMLElement.prototype, "matches", { + configurable: true, + value: function (this: HTMLElement, selector: string): boolean { + if (selector === ":popover-open") { + return this.hasAttribute("popover-open"); + } + return originalMatches.call(this, selector); + }, + }); +}); + +type FruitValue = { + fruit: "Apple" | "Banana"; + ripeness: "Crisp" | "Ripe" | "Juicy"; +}; + +const FRUITS = ["Apple", "Banana"] as const; +const RIPENESS = ["Crisp", "Ripe", "Juicy"] as const; +const h = { hidden: true } as const; + +function FruitGrid({ + value, + onChange, +}: { + value: FruitValue; + onChange: (value: FruitValue) => void; +}) { + return ( + <div role="grid" aria-label="Fruit blend choices"> + {FRUITS.flatMap((fruit) => + RIPENESS.map((ripeness) => { + const isSelected = value.fruit === fruit && value.ripeness === ripeness; + return ( + <button + key={`${fruit}-${ripeness}`} + type="button" + role="gridcell" + aria-label={`${fruit} ${ripeness}`} + aria-selected={isSelected || undefined} + onClick={() => onChange({ fruit, ripeness })} + > + {fruit} {ripeness} + </button> + ); + }), + )} + </div> + ); +} + +function FruitComplexSelector({ + value, + onChange, + changeAction, +}: { + value: FruitValue; + onChange: (value: FruitValue) => void; + changeAction?: (value: FruitValue) => void | Promise<void>; +}) { + return ( + <ComplexSelector + label="Fruit blend" + value={value} + onChange={onChange} + changeAction={changeAction} + triggerLabel={`${value.fruit} ${value.ripeness}`} + > + {(value, onChange, close) => ( + <FruitGrid + value={value} + onChange={(nextValue) => { + onChange(nextValue); + close(); + }} + /> + )} + </ComplexSelector> + ); +} + +describe("ComplexSelector", () => { + it("defaults to md and reflects explicit trigger sizes", () => { + const { container, rerender } = render( + <ComplexSelector label="Fruit blend" value="Apple"> + {() => <div>Options</div>} + </ComplexSelector>, + ); + + const getSelector = () => container.querySelector(".astryx-complex-selector"); + + expect(getSelector()).toHaveAttribute("data-size", "md"); + + rerender( + <ComplexSelector label="Fruit blend" value="Apple" size="sm"> + {() => <div>Options</div>} + </ComplexSelector>, + ); + + expect(getSelector()).toHaveAttribute("data-size", "sm"); + + rerender( + <ComplexSelector label="Fruit blend" value="Apple" size="lg"> + {() => <div>Options</div>} + </ComplexSelector>, + ); + + expect(getSelector()).toHaveAttribute("data-size", "lg"); + }); + + it("renders custom content with value and commits through onChange", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + + render( + <FruitComplexSelector + value={{ fruit: "Apple", ripeness: "Ripe" }} + onChange={onChange} + />, + ); + + await user.click(screen.getByRole("button", { name: "Fruit blend" })); + await user.click(screen.getByRole("gridcell", { name: "Banana Juicy", ...h })); + + expect(onChange).toHaveBeenCalledWith({ fruit: "Banana", ripeness: "Juicy" }); + expect(screen.getByRole("button", { name: "Fruit blend" })).toHaveAttribute( + "aria-expanded", + "false", + ); + }); + + it("gives the popup clearance on both block edges, not just the leading one (#4803)", async () => { + const user = userEvent.setup(); + render( + <FruitComplexSelector + value={{ fruit: "Apple", ripeness: "Ripe" }} + onChange={() => {}} + />, + ); + await user.click(screen.getByRole("button", { name: "Fruit blend" })); + const popup = document.querySelector("[popover]") as HTMLElement; + expect(popup).not.toBeNull(); + // Both edges, not just the leading one: the trailing edge is what faces + // the trigger when the same popup opens upward (placement="above") or is + // flipped by position-try-fallbacks. useLayer's `offset` sets both from + // the resolved placement; jsdom resolves neither the var indirection nor + // logical margins, so read the debug-mode declarations it emits. + const blockStart = popup.style.getPropertyValue("--x-marginBlockStart"); + expect(blockStart).not.toBe(""); + expect(popup.style.getPropertyValue("--x-marginBlockEnd")).toBe(blockStart); + }); + + it("runs changeAction through the provided onChange helper", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + const changeAction = vi.fn(); + + render( + <FruitComplexSelector + value={{ fruit: "Apple", ripeness: "Ripe" }} + onChange={onChange} + changeAction={changeAction} + />, + ); + + await user.click(screen.getByRole("button", { name: "Fruit blend" })); + await user.click(screen.getByRole("gridcell", { name: "Banana Crisp", ...h })); + + expect(onChange).toHaveBeenCalledWith({ fruit: "Banana", ripeness: "Crisp" }); + await waitFor(() => { + expect(changeAction).toHaveBeenCalledWith({ + fruit: "Banana", + ripeness: "Crisp", + }); + }); + }); + + it("passes a close helper to composed content", async () => { + const user = userEvent.setup(); + + render( + <ComplexSelector label="Fruit blend" value="Apple" triggerLabel="Apple"> + {(_value, _onChange, close) => ( + <button type="button" onClick={close}> + Done + </button> + )} + </ComplexSelector>, + ); + + const trigger = screen.getByRole("button", { name: "Fruit blend" }); + await user.click(trigger); + expect(trigger).toHaveAttribute("aria-expanded", "true"); + + await user.click(screen.getByRole("button", { name: "Done", ...h })); + expect(trigger).toHaveAttribute("aria-expanded", "false"); + }); + + it("renders a ghost toolbar trigger with a start icon", () => { + const { container } = render( + <ComplexSelector + label="View options" + value={["name"]} + variant="ghost" + startIcon="viewColumns" + status={{ type: "warning", message: "Unsaved changes" }} + data-testid="view-options" + > + {() => <div>Columns</div>} + </ComplexSelector>, + ); + + expect(container.querySelector(".astryx-complex-selector")).toHaveAttribute( + "data-variant", + "ghost", + ); + expect(container.querySelector(".astryx-field-status")).toHaveAttribute( + "data-variant", + "detached", + ); + expect(screen.getByTestId("view-options").querySelectorAll("svg")).toHaveLength(2); + }); + + it("supports end-aligned popup positioning", () => { + render( + <ComplexSelector label="View options" value={[]} alignment="end"> + {() => <div>Columns</div>} + </ComplexSelector>, + ); + + const popover = screen.getByRole("dialog", { hidden: true }).closest("[popover]"); + expect(popover?.getAttribute("style")).toContain( + "position-area: self-block-end span-self-inline-start", + ); + }); + + it("exposes imperative open, close, toggle, and isOpen via handleRef", async () => { + const handleRef = React.createRef<ComplexSelectorHandle>(); + render( + <ComplexSelector label="View options" value={[]} handleRef={handleRef}> + {() => <button type="button">Apply</button>} + </ComplexSelector>, + ); + const trigger = screen.getByRole("button", { name: "View options" }); + expect(trigger).toHaveAttribute("aria-expanded", "false"); + expect(handleRef.current?.isOpen()).toBe(false); + + act(() => { + handleRef.current?.open(); + }); + await waitFor(() => { + expect(trigger).toHaveAttribute("aria-expanded", "true"); + }); + expect(handleRef.current?.isOpen()).toBe(true); + + act(() => { + handleRef.current?.close(); + }); + await waitFor(() => { + expect(trigger).toHaveAttribute("aria-expanded", "false"); + }); + expect(handleRef.current?.isOpen()).toBe(false); + + act(() => { + handleRef.current?.toggle(); + }); + await waitFor(() => { + expect(trigger).toHaveAttribute("aria-expanded", "true"); + }); + + act(() => { + handleRef.current?.toggle(); + }); + await waitFor(() => { + expect(trigger).toHaveAttribute("aria-expanded", "false"); + }); + }); + + it("does not open via the imperative handle when disabled", async () => { + const handleRef = React.createRef<ComplexSelectorHandle>(); + render( + <ComplexSelector label="View options" value={[]} isDisabled handleRef={handleRef}> + {() => <button type="button">Apply</button>} + </ComplexSelector>, + ); + const trigger = screen.getByRole("button", { name: "View options" }); + + act(() => { + handleRef.current?.open(); + }); + act(() => { + handleRef.current?.toggle(); + }); + expect(trigger).toHaveAttribute("aria-expanded", "false"); + expect(handleRef.current?.isOpen()).toBe(false); + }); + + it("does not reopen from the trigger click that follows light dismiss", async () => { + const user = userEvent.setup(); + render( + <ComplexSelector label="View options" value={[]}> + {() => <button type="button">Apply</button>} + </ComplexSelector>, + ); + const trigger = screen.getByRole("button", { name: "View options" }); + + await user.click(trigger); + const popover = screen.getByRole("dialog", { hidden: true }).closest("[popover]"); + expect(popover).not.toBeNull(); + + fireEvent.pointerDown(trigger); + const closeEvent = new Event("toggle"); + Object.defineProperty(closeEvent, "newState", { value: "closed" }); + fireEvent(popover as HTMLElement, closeEvent); + expect(trigger).toHaveAttribute("aria-expanded", "false"); + + const showCallCount = vi.mocked(HTMLElement.prototype.showPopover).mock.calls + .length; + fireEvent.click(trigger); + expect(HTMLElement.prototype.showPopover).toHaveBeenCalledTimes(showCallCount); + }); +}); + +describe("ComplexSelector popup theme target", () => { + it("puts astryx-complex-selector-popup on the surface that paints, not the content box", async () => { + const user = userEvent.setup(); + render( + <ComplexSelector label="Fruit blend" value="Apple" triggerLabel="Apple"> + {() => <button type="button">Done</button>} + </ComplexSelector>, + ); + await user.click(screen.getByRole("button", { name: "Fruit blend" })); + + const popup = document.querySelector( + ".astryx-complex-selector-popup", + ) as HTMLElement; + expect(popup).not.toBeNull(); + + // The surface is the element usePopover renders: it carries the dialog + // role and the shared surface class, and the component's content box — + // the one with the padding and the scroll — sits INSIDE it. A target on + // that inner box cannot paint the popup's background or radius, which is + // what a theme reaches for this class to do. + expect(popup).toHaveAttribute("role", "dialog"); + expect(popup).toHaveClass("astryx-popover-surface"); + expect(popup.querySelector("[id]")).not.toBeNull(); + expect(popup).toContainElement(screen.getByRole("button", { name: "Done", ...h })); + + // And it is not the bare positioning layer either. + const layer = document.querySelector("[popover]") as HTMLElement; + expect(popup).not.toBe(layer); + expect(layer.contains(popup)).toBe(true); + }); + + it("keeps the target when the consumer also passes contentXstyle", async () => { + const user = userEvent.setup(); + render( + <ComplexSelector + label="Fruit blend" + value="Apple" + triggerLabel="Apple" + contentXstyle={{}} + > + {() => <button type="button">Done</button>} + </ComplexSelector>, + ); + await user.click(screen.getByRole("button", { name: "Fruit blend" })); + + expect(document.querySelector(".astryx-complex-selector-popup")).not.toBeNull(); + }); + + it("stays closed when the trigger click follows its own light dismiss (#5004)", async () => { + const user = userEvent.setup(); + render( + <ComplexSelector label="Fruit blend" value="Apple" triggerLabel="Apple"> + {() => <button type="button">Done</button>} + </ComplexSelector>, + ); + const trigger = screen.getByRole("button", { name: "Fruit blend" }); + await user.click(trigger); + expect(trigger).toHaveAttribute("aria-expanded", "true"); + + // The browser dismissed the popup on pointerup and queued the toggle. When + // that event lands before the click — WebKit, or any engine under load — + // the click used to read a closed popup and reopen it. + fireEvent.pointerDown(trigger); + const popover = document.querySelector("[popover]") as HTMLElement; + act(() => { + popover.dispatchEvent( + Object.assign(new Event("toggle"), { + oldState: "open", + newState: "closed", + }), + ); + }); + // Synchronously: the click falls inside the one gesture the guard covers, + // as it does in a browser a few milliseconds behind the dismissal. + fireEvent.click(trigger); + + expect(trigger).toHaveAttribute("aria-expanded", "false"); + }); +}); + +describe("ComplexSelector onOpenChange", () => { + function renderSelector(onOpenChange: (isOpen: boolean) => void) { + render( + <ComplexSelector label="View options" value={[]} onOpenChange={onOpenChange}> + {(_value, _onChange, close) => ( + <button type="button" onClick={close}> + Apply + </button> + )} + </ComplexSelector>, + ); + return screen.getByRole("button", { name: "View options" }); + } + + it("reports the open and the close of a trigger toggle", async () => { + const user = userEvent.setup(); + const onOpenChange = vi.fn(); + const trigger = renderSelector(onOpenChange); + + await user.click(trigger); + expect(onOpenChange.mock.calls).toEqual([[true]]); + + await user.click(trigger); + expect(onOpenChange.mock.calls).toEqual([[true], [false]]); + }); + + it("reports an open from ArrowDown", async () => { + const user = userEvent.setup(); + const onOpenChange = vi.fn(); + const trigger = renderSelector(onOpenChange); + + trigger.focus(); + await user.keyboard("{ArrowDown}"); + + expect(onOpenChange.mock.calls).toEqual([[true]]); + }); + + it("reports a close from Escape", async () => { + const user = userEvent.setup(); + const onOpenChange = vi.fn(); + const trigger = renderSelector(onOpenChange); + + await user.click(trigger); + onOpenChange.mockClear(); + await user.keyboard("{Escape}"); + + expect(onOpenChange.mock.calls).toEqual([[false]]); + }); + + it("reports a close the browser performed (light dismiss)", async () => { + const user = userEvent.setup(); + const onOpenChange = vi.fn(); + const trigger = renderSelector(onOpenChange); + + await user.click(trigger); + onOpenChange.mockClear(); + + const popover = screen + .getByRole("dialog", { hidden: true }) + .closest("[popover]") as HTMLElement; + const closeEvent = new Event("toggle"); + Object.defineProperty(closeEvent, "newState", { value: "closed" }); + fireEvent(popover, closeEvent); + + expect(onOpenChange.mock.calls).toEqual([[false]]); + }); + + it("reports a gesture light dismiss once without reopening from its click", async () => { + const user = userEvent.setup(); + const onOpenChange = vi.fn(); + const trigger = renderSelector(onOpenChange); + + await user.click(trigger); + onOpenChange.mockClear(); + + const popover = screen + .getByRole("dialog", { hidden: true }) + .closest("[popover]") as HTMLElement; + fireEvent.pointerDown(trigger); + fireEvent( + popover, + Object.assign(new Event("toggle"), { + oldState: "open", + newState: "closed", + }), + ); + fireEvent.click(trigger); + + expect(onOpenChange.mock.calls).toEqual([[false]]); + expect(trigger).toHaveAttribute("aria-expanded", "false"); + }); + + it("reports a close from content calling close()", async () => { + const user = userEvent.setup(); + const onOpenChange = vi.fn(); + const trigger = renderSelector(onOpenChange); + + await user.click(trigger); + onOpenChange.mockClear(); + await user.click(screen.getByRole("button", { name: "Apply", ...h })); + + expect(onOpenChange.mock.calls).toEqual([[false]]); + }); + + it("reports opens and closes driven through the imperative handle", () => { + const onOpenChange = vi.fn(); + const handleRef = React.createRef<ComplexSelectorHandle>(); + render( + <ComplexSelector + label="View options" + value={[]} + handleRef={handleRef} + onOpenChange={onOpenChange} + > + {() => <button type="button">Apply</button>} + </ComplexSelector>, + ); + + act(() => handleRef.current?.open()); + act(() => handleRef.current?.close()); + act(() => handleRef.current?.toggle()); + + expect(onOpenChange.mock.calls).toEqual([[true], [false], [true]]); + }); + + it("does not report a state it is already in", async () => { + const user = userEvent.setup(); + const onOpenChange = vi.fn(); + const handleRef = React.createRef<ComplexSelectorHandle>(); + render( + <ComplexSelector + label="View options" + value={[]} + handleRef={handleRef} + onOpenChange={onOpenChange} + > + {() => <button type="button">Apply</button>} + </ComplexSelector>, + ); + const trigger = screen.getByRole("button", { name: "View options" }); + + act(() => handleRef.current?.close()); + expect(onOpenChange).not.toHaveBeenCalled(); + + await user.click(trigger); + act(() => handleRef.current?.open()); + expect(onOpenChange.mock.calls).toEqual([[true]]); + }); +}); diff --git a/src/forks/ComplexSelector/ComplexSelector.tsx b/src/forks/ComplexSelector/ComplexSelector.tsx new file mode 100644 index 0000000..0a1768f --- /dev/null +++ b/src/forks/ComplexSelector/ComplexSelector.tsx @@ -0,0 +1,513 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. +// Modifications copyright (c) Lablup Inc. +// +// Forked from @astryxdesign/core 0.6.2, src/ComplexSelector/ComplexSelector.tsx +// (MIT; see NOTICE). Provenance and the drift guard: src/forks/provenance.json. + +"use client"; + +/** + * ComplexSelector, ui-common's copy of Astryx's, with one upstream fix + * applied: `hasClear` / `onClear`, a clear button between the loading spinner + * and the chevron, as `Selector`'s `hasClear` has (facebook/astryx#6362, open). + * + * Everything else is upstream's, line for line. Two things differ in form + * only: the style namespaces are Astryx's compiled output (src/forks/compiled.ts), + * and the two internals Astryx does not export are inlined — + * `useResolvedRequired` (read through the public `FormLayoutContext`) and + * `interactionOverlayStyles` (compiled, like the rest). + * + * Delete this fork, and its exports.exclude.json entry, once Astryx ships + * the fix (CONTRIBUTING, "Forks of Astryx components"). + */ + +import React, { + use, + useCallback, + useId, + useImperativeHandle, + useOptimistic, + useRef, + useTransition, + type ReactNode, +} from "react"; +import * as stylex from "@stylexjs/stylex"; +import type { StyleXStyles } from "@stylexjs/stylex"; +import type { BaseProps } from "@astryxdesign/core/BaseProps"; +import { + Field, + InputClearButton, + inputWrapperStyles, + type FieldStatusVariant, +} from "@astryxdesign/core/Field"; +import { FormLayoutContext } from "@astryxdesign/core/FormLayout"; +import { Icon, renderIconSlot, type IconType } from "@astryxdesign/core/Icon"; +import { Spinner } from "@astryxdesign/core/Spinner"; +import { useTranslator } from "@astryxdesign/core/i18n"; +import { + layerAnimations, + type LayerAlignment, + type LayerPlacement, +} from "@astryxdesign/core/Layer"; +import { usePopover } from "@astryxdesign/core/Popover"; +import { spacingVars } from "@astryxdesign/core/theme/tokens.stylex"; +import { + composeEventHandlers, + focusOutlineStyles, + isRenderable, + mergeProps, + themeProps, + type SizeValue, +} from "@astryxdesign/core/utils"; + +import { compiledStyles } from "../compiled"; +import * as compiled from "./ComplexSelector.styles"; + +// dist/ComplexSelector/ComplexSelector.js `styles`, and +// dist/utils/interactionOverlay.stylex.js, which core does not export. +const styles = compiledStyles(compiled.styles); +const interactionOverlayStyles = compiledStyles(compiled.interactionOverlayStyles); + +// @astryxdesign/core 0.6.2 src/hooks/useResolvedRequired.ts, which core does +// not export: a field under `FormLayout defaultOptionality="required"` is +// required unless it opts out. +function useResolvedRequired({ + isRequired = false, + isOptional = false, +}: { + isRequired?: boolean; + isOptional?: boolean; +}): boolean { + const { defaultOptionality } = use(FormLayoutContext); + return !isOptional && (isRequired || defaultOptionality === "required"); +} + +export type ComplexSelectorVariant = "input" | "ghost"; + +export type ComplexSelectorSize = "sm" | "md" | "lg"; + +export interface ComplexSelectorRenderState { + /** Whether the selector surface is open. */ + isOpen: boolean; + /** Whether changeAction/isLoading is pending. */ + isBusy: boolean; + /** ID of the trigger button. */ + triggerId: string; + /** ID of the popup content container. */ + contentId: string; +} + +/** + * Imperative control surface for ComplexSelector, accessed via the `handleRef` + * prop. Methods drive the same popover machinery as the built-in trigger, so + * they respect focus restoration, light dismiss, and Escape. Prefer these + * callbacks over mirroring open state in the parent — the selector owns its + * visibility, and imperative calls avoid the focus-management pitfalls of + * syncing an external `isOpen` prop. Pair with `onOpenChange` to observe every + * open and close, including the ones the selector performs itself. + */ +export interface ComplexSelectorHandle { + /** Open the selector surface. No-op when disabled or already open. */ + open(): void; + /** Close the selector surface. Restores focus to the trigger. */ + close(): void; + /** Toggle the selector surface open or closed. */ + toggle(): void; + /** Whether the selector surface is currently open. Reads live state. */ + isOpen(): boolean; +} + +export interface ComplexSelectorStatus { + type: "warning" | "error" | "success"; + message?: string; +} + +export interface ComplexSelectorProps<Value> extends Omit< + BaseProps<HTMLDivElement>, + "children" | "onChange" +> { + /** Label text for accessibility and the field label. */ + label: string; + /** Current controlled value. */ + value: Value; + /** Called when custom content commits a new value. */ + onChange?: (value: Value) => void; + /** Optional async action after onChange; drives optimistic UI. */ + changeAction?: (value: Value) => void | Promise<void>; + /** Custom selector surface content rendered inside a dialog popover. */ + children: ( + value: Value, + onChange: (value: Value) => void, + close: () => void, + state: ComplexSelectorRenderState, + ) => ReactNode; + /** Label/content shown in the closed trigger. */ + triggerLabel?: ReactNode; + /** Placeholder shown when triggerLabel is omitted. */ + placeholder?: ReactNode; + /** Whether to visually hide the field label. */ + isLabelHidden?: boolean; + /** Helper text displayed below the label. */ + description?: string; + /** Marks the field optional. */ + isOptional?: boolean; + /** Marks the field required. */ + isRequired?: boolean; + /** Disables the selector. */ + isDisabled?: boolean; + /** Shows loading state on the trigger. */ + isLoading?: boolean; + /** + * Shows a clear button between the loading spinner and the chevron while + * `triggerLabel` is set, as `Selector`'s `hasClear` does. Activating it + * calls `onClear`, or `onChange(undefined)` when `onClear` is not given. + */ + hasClear?: boolean; + /** Called when the clear button is activated. */ + onClear?: () => void; + /** Validation status. */ + status?: ComplexSelectorStatus; + /** Status placement. */ + statusVariant?: FieldStatusVariant; + /** Tooltip text displayed next to the label. */ + labelTooltip?: string; + /** Trigger and field size. */ + size?: ComplexSelectorSize; + /** Visual trigger style. Ghost matches toolbar buttons. */ + variant?: ComplexSelectorVariant; + /** Icon displayed at the start of the trigger. */ + startIcon?: ReactNode | IconType; + /** Width of the field. */ + width?: SizeValue; + /** Popup placement. */ + placement?: LayerPlacement; + /** Popup alignment along the placement axis. */ + alignment?: LayerAlignment; + /** + * Imperative handle for programmatic open/close control. Exposes open, + * close, toggle, and the isOpen query. Use this instead of mirroring open + * state in the parent — the selector owns its visibility. + */ + handleRef?: React.Ref<ComplexSelectorHandle>; + /** + * Called whenever the selector surface opens or closes, however it happened + * — the trigger, the keyboard, a light dismiss, Escape, content that calls + * `close()`, or the imperative handle. Pair it with `handleRef` to drive the + * surface from outside without mirroring its state. + */ + onOpenChange?: (isOpen: boolean) => void; + /** StyleX styles for the popup content container. */ + contentXstyle?: StyleXStyles; + /** Test ID for the trigger container. */ + "data-testid"?: string; +} + +/** + * A selector shell for rich, custom selection surfaces. + * + * ComplexSelector owns the field, trigger, popover, focus restore, and async + * change action flow. Consumers provide the dialog content as a render function, + * using the supplied `value`, `onChange`, and `close` helpers to compose the + * right accessible structure for the custom selector. + * + * @example + * ``` + * <ComplexSelector + * label="Fruit" + * value={value} + * onChange={setValue} + * triggerLabel={`${value.fruit} ${value.ripeness}`}> + * {(value, onChange, close) => ( + * <FruitGrid + * value={value} + * onChange={nextValue => { + * onChange(nextValue); + * close(); + * }} + * /> + * )} + * </ComplexSelector> + * ``` + */ +export function ComplexSelector<Value>({ + label, + value, + onChange, + changeAction, + children, + triggerLabel, + placeholder: placeholderFromProps, + isLabelHidden = false, + description, + isOptional = false, + isRequired = false, + isDisabled = false, + isLoading = false, + hasClear = false, + onClear, + status, + statusVariant = "attached", + labelTooltip, + size = "md", + variant = "input", + startIcon, + width, + placement = "below", + alignment = "start", + handleRef, + onOpenChange, + contentXstyle, + xstyle, + className, + style, + "data-testid": testId, + onClick: onClickProp, + ...props +}: ComplexSelectorProps<Value>) { + const t = useTranslator(); + const isEffectivelyRequired = useResolvedRequired({ isRequired, isOptional }); + const placeholder = placeholderFromProps ?? t("@astryx.selector.placeholder"); + const effectiveStatusVariant = + variant === "ghost" && statusVariant === "attached" ? "detached" : statusVariant; + + const triggerId = useId(); + const labelId = useId(); + const contentId = useId(); + const descriptionId = useId(); + const statusMessageId = useId(); + const ariaDescribedBy = + [description ? descriptionId : null, status?.message ? statusMessageId : null] + .filter((id): id is string => id != null) + .join(" ") || undefined; + + const triggerRef = useRef<HTMLButtonElement>(null); + + const [isPending, startTransition] = useTransition(); + const [optimisticValue, setOptimisticValue] = useOptimistic(value); + const isBusy = isLoading || isPending; + + const handlePopoverShow = useCallback(() => { + onOpenChange?.(true); + }, [onOpenChange]); + + const handlePopoverHide = useCallback(() => { + // Focus is restored first so a consumer that moves focus elsewhere from + // the callback wins, instead of being overwritten a line later. + triggerRef.current?.focus(); + onOpenChange?.(false); + }, [onOpenChange]); + + const popover = usePopover({ + dialogLabel: label, + hasCloseButton: false, + hasAutoFocus: true, + surfaceTarget: "complex-selector-popup", + onShow: handlePopoverShow, + onHide: handlePopoverHide, + }); + + const isOpen = popover.isOpen; + + const handleTriggerClick = useCallback(() => { + if (isDisabled) { + return; + } + if (popover.isOpen) { + popover.hide(); + } else { + popover.show(); + } + }, [isDisabled, popover]); + + const close = useCallback(() => { + popover.hide(); + }, [popover]); + + useImperativeHandle( + handleRef, + () => ({ + open: () => { + if (!isDisabled) { + popover.show(); + } + }, + close: () => popover.hide(), + toggle: () => { + if (isDisabled) { + return; + } + if (popover.isOpen) { + popover.hide(); + } else { + popover.show(); + } + }, + isOpen: () => popover.isOpen, + }), + [isDisabled, popover], + ); + + const commitValue = useCallback( + (nextValue: Value) => { + onChange?.(nextValue); + if (changeAction) { + startTransition(async () => { + setOptimisticValue(nextValue); + await changeAction(nextValue); + }); + } + }, + [changeAction, onChange, setOptimisticValue, startTransition], + ); + + const triggerContent = triggerLabel ?? placeholder; + + const startIconSlot = renderIconSlot(startIcon, { + size: "sm", + color: "secondary", + }); + + const content = ( + <div id={contentId} {...stylex.props(styles.content, contentXstyle)}> + {children(optimisticValue, commitValue, close, { + isOpen, + isBusy, + triggerId, + contentId, + })} + </div> + ); + + const selectorContent = ( + <> + <div + ref={popover.triggerRef} + data-testid={testId} + {...props} + onClick={composeEventHandlers(onClickProp, handleTriggerClick)} + {...mergeProps( + themeProps("complex-selector", { + variant, + size, + status: status?.type ?? null, + }), + stylex.props( + inputWrapperStyles.base, + styles.triggerContainer, + styles[size], + // The ring belongs to the wrapper (the focusable `<button>` sits + // inside it), but it must still be a KEYBOARD ring: `:focus-within` + // matched a mouse click on the trigger and drew the outline for + // pointer users too. `focusWithin` here is `:has(:focus-visible)`. + focusOutlineStyles.focusWithin, + variant === "ghost" && styles.triggerGhost, + variant === "ghost" && interactionOverlayStyles.backgroundImage, + isDisabled && inputWrapperStyles.disabled, + variant === "ghost" && isDisabled && styles.triggerGhostDisabled, + isDisabled && styles.disabled, + triggerLabel == null && styles.placeholder, + xstyle, + ), + className, + style, + )} + > + {isRenderable(startIconSlot) && startIconSlot} + <button + ref={triggerRef} + id={triggerId} + type="button" + aria-haspopup="dialog" + aria-expanded={isOpen} + aria-controls={contentId} + aria-describedby={ariaDescribedBy} + aria-labelledby={labelId} + aria-required={isEffectivelyRequired ? "true" : undefined} + aria-invalid={status?.type === "error" ? "true" : undefined} + aria-busy={isBusy || undefined} + disabled={isDisabled} + onKeyDown={(event) => { + if (event.key === "ArrowDown" && !isOpen && !isDisabled) { + event.preventDefault(); + popover.show(); + } + }} + {...stylex.props(styles.trigger)} + > + <span {...stylex.props(styles.triggerText)}>{triggerContent}</span> + </button> + {isBusy && <Spinner size="sm" />} + {/* ui-common: the clear button (facebook/astryx#6362). */} + {hasClear && triggerLabel != null && !isDisabled && ( + <InputClearButton + label={t("@astryx.selector.clearLabel", { label })} + // The container's own click opens the popover; a clear must not. + onClick={(event) => { + event.stopPropagation(); + if (onClear) { + onClear(); + } else { + // Clearing hands back "no value", which `Value` may not name. + onChange?.(undefined as Value); + } + }} + /> + )} + <Icon + icon="chevronDown" + size="sm" + color="secondary" + // No wrapper: Icon's own span already provides the 16px box (`sm`) + // and the secondary icon color the wrapper used to set, so the glyph + // IS the trigger's icon element — one node carrying the box, the + // color, the rotation, and the theme target. + xstyle={[ + styles.triggerIcon, + styles.triggerIconRotation, + isOpen && styles.triggerIconOpen, + ]} + {...themeProps("complex-selector-indicator-icon", { + state: isOpen ? "expanded" : "collapsed", + })} + /> + </div> + + {popover.render(content, { + placement, + alignment, + offset: spacingVars["--spacing-1"], + xstyle: [styles.popover, layerAnimations[placement]], + })} + </> + ); + + return ( + <Field + label={label} + isLabelHidden={isLabelHidden} + description={description} + inputID={triggerId} + descriptionID={description ? descriptionId : undefined} + labelID={labelId} + isOptional={isOptional} + isRequired={isRequired} + isDisabled={isDisabled} + status={ + status + ? { + type: status.type, + message: status.message, + messageID: status.message ? statusMessageId : undefined, + } + : undefined + } + statusVariant={effectiveStatusVariant} + labelTooltip={labelTooltip} + width={width} + > + {selectorContent} + </Field> + ); +} + +ComplexSelector.displayName = "ComplexSelector"; diff --git a/src/forks/ComplexSelector/index.ts b/src/forks/ComplexSelector/index.ts new file mode 100644 index 0000000..76eb667 --- /dev/null +++ b/src/forks/ComplexSelector/index.ts @@ -0,0 +1,14 @@ +/** + * `@lablup/ui-common/ComplexSelector`: ui-common's copy of Astryx's + * ComplexSelector, same names and types, plus `hasClear` / `onClear` + * (facebook/astryx#6362). Astryx's is excluded (exports.exclude.json). + */ +export { ComplexSelector } from "./ComplexSelector"; +export type { + ComplexSelectorHandle, + ComplexSelectorProps, + ComplexSelectorRenderState, + ComplexSelectorSize, + ComplexSelectorStatus, + ComplexSelectorVariant, +} from "./ComplexSelector"; diff --git a/src/forks/Drawer/Drawer.fork.test.tsx b/src/forks/Drawer/Drawer.fork.test.tsx new file mode 100644 index 0000000..fcb87f5 --- /dev/null +++ b/src/forks/Drawer/Drawer.fork.test.tsx @@ -0,0 +1,177 @@ +/** + * ui-common's tests for its Drawer fork: the fixes it carries (Escape + * containment, `aria-modal` passthrough), and that everything else renders + * exactly as lab's does. + */ +import { useState } from "react"; +import { createPortal } from "react-dom"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { Drawer as UpstreamDrawer } from "@astryxdesign/lab"; + +import { comparableMarkup } from "../../test/forkParity"; +import { Drawer } from "./Drawer"; + +beforeEach(() => { + HTMLDialogElement.prototype.showModal = vi.fn(function (this: HTMLDialogElement) { + this.setAttribute("open", ""); + }); + HTMLDialogElement.prototype.show = vi.fn(function (this: HTMLDialogElement) { + this.setAttribute("open", ""); + }); + HTMLDialogElement.prototype.close = vi.fn(function (this: HTMLDialogElement) { + this.removeAttribute("open"); + }); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +/** A layer opened from inside the drawer, portalled out of its DOM subtree. */ +function PortalledLayer() { + return createPortal( + <input aria-label="Layer field" data-testid="layer-input" />, + document.body, + ); +} + +function renderDrawer(DrawerImpl: typeof Drawer, onOpenChange = vi.fn()) { + render( + <DrawerImpl isOpen onOpenChange={onOpenChange} label="Details"> + <input aria-label="Drawer field" /> + <PortalledLayer /> + </DrawerImpl>, + ); + return onOpenChange; +} + +describe("Drawer fork: Escape containment", () => { + it("still closes on an Escape from inside the drawer", () => { + const onOpenChange = renderDrawer(Drawer); + fireEvent.keyDown(screen.getByLabelText("Drawer field"), { key: "Escape" }); + expect(onOpenChange).toHaveBeenCalledExactlyOnceWith(false); + }); + + it("ignores an Escape from a layer portalled out of its children", () => { + const onOpenChange = renderDrawer(Drawer); + const layerInput = screen.getByTestId("layer-input"); + expect(screen.getByRole("dialog").contains(layerInput)).toBe(false); + const event = new KeyboardEvent("keydown", { + key: "Escape", + bubbles: true, + cancelable: true, + }); + layerInput.dispatchEvent(event); + expect(onOpenChange).not.toHaveBeenCalled(); + // Left for the layer's own dismissal to act on. + expect(event.defaultPrevented).toBe(false); + }); + + it("ignores an Escape that ends an IME composition", () => { + const onOpenChange = renderDrawer(Drawer); + fireEvent.keyDown(screen.getByLabelText("Drawer field"), { + key: "Escape", + isComposing: true, + }); + expect(onOpenChange).not.toHaveBeenCalled(); + }); + + // When these fail, lab has shipped the fix: delete the fork and its + // exports.exclude.json entry (CONTRIBUTING, "Forks of Astryx components"). + it("still differs from lab's, which acts on the portalled Escape", () => { + const onOpenChange = renderDrawer(UpstreamDrawer); + fireEvent.keyDown(screen.getByTestId("layer-input"), { key: "Escape" }); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); +}); + +describe("Drawer fork: aria-modal", () => { + it("keeps upstream's default: modal with a scrim, unset without", () => { + const { rerender } = render( + <Drawer isOpen onOpenChange={() => {}} label="Details"> + Content + </Drawer>, + ); + expect(screen.getByRole("dialog")).toHaveAttribute("aria-modal", "true"); + rerender( + <Drawer isOpen onOpenChange={() => {}} label="Details" hasScrim={false}> + Content + </Drawer>, + ); + expect(screen.getByRole("dialog")).not.toHaveAttribute("aria-modal"); + }); + + it("passes a consumer's aria-modal through on a scrimless drawer", () => { + render( + <Drawer + isOpen + onOpenChange={() => {}} + label="Details" + hasScrim={false} + aria-modal="true" + > + Content + </Drawer>, + ); + expect(screen.getByRole("dialog")).toHaveAttribute("aria-modal", "true"); + }); + + it("still differs from lab's, which overrides it", () => { + render( + <UpstreamDrawer + isOpen + onOpenChange={() => {}} + label="Details" + hasScrim={false} + aria-modal="true" + > + Content + </UpstreamDrawer>, + ); + expect(screen.getByRole("dialog")).not.toHaveAttribute("aria-modal"); + }); +}); + +describe("Drawer fork: parity with lab", () => { + function Toggle({ impl: Impl, ...props }: { impl: typeof Drawer } & object) { + const [isOpen] = useState(true); + return ( + <Impl isOpen={isOpen} onOpenChange={() => {}} label="Details" {...props}> + Content + </Impl> + ); + } + + const cases: Array<[string, object]> = [ + ["the defaults", {}], + [ + "start side, no scrim, no close button", + { side: "start", hasScrim: false, hasCloseButton: false }, + ], + [ + "a string width, full width on mobile", + { width: "32rem", isFullWidthOnMobile: true }, + ], + ]; + + it.each(cases)("renders lab's markup with %s", (_, props) => { + const fork = render(<Toggle impl={Drawer} {...props} />); + const upstream = render(<Toggle impl={UpstreamDrawer} {...props} />); + expect(comparableMarkup(fork.container)).toBe(comparableMarkup(upstream.container)); + }); + + it("renders lab's markup while closed", () => { + const fork = render( + <Drawer isOpen={false} onOpenChange={() => {}} label="Details"> + Content + </Drawer>, + ); + const upstream = render( + <UpstreamDrawer isOpen={false} onOpenChange={() => {}} label="Details"> + Content + </UpstreamDrawer>, + ); + expect(comparableMarkup(fork.container)).toBe(comparableMarkup(upstream.container)); + }); +}); diff --git a/src/forks/Drawer/Drawer.styles.ts b/src/forks/Drawer/Drawer.styles.ts new file mode 100644 index 0000000..7946e0d --- /dev/null +++ b/src/forks/Drawer/Drawer.styles.ts @@ -0,0 +1,114 @@ +/* eslint-disable */ +// @ts-nocheck +// @generated by scripts/sync-forks.mjs. Do not edit. +// +// Compiled StyleX from @astryxdesign/lab 0.6.2-canary.c9fb1ad, reused by the +// Drawer fork so it renders with upstream's own class names +// (src/forks/compiled.ts). Copyright (c) Meta Platforms, Inc. and +// affiliates; MIT, see NOTICE. + +// dist/Drawer/Drawer.js +export const styles = { + dialog: { + kVAEAm: "xixxii4", + kogj98: "x1ghz6dp", + kmVPX3: "x1717udv", + kaIpWk: "x2u8bby", + ks0D6T: "x1x1rfll", + kskxy: "x7ab17h", + kB7OPa: "x9f619", + kXwgrk: "xdt5ytf", + kWkggS: "x10xzikg", + kGVxlE: "x1kcpxr7", + kVQacm: "xb3r6kr", + kZeWKH: "xish69e", + kI3sdo: "x1a2a7pz", + k87sOh: "x13vifvy", + krVfgx: "x1ey2m1c", + kZKoxP: "xtdtrs8", + k1xSpc: "x1s85apg", + k1ekBW: "x11xpdln", + kIyJzY: "x80gvsz", + kAMwcw: "xlr8y92", + k6CgDc: "xzg1mie", + $$css: true, + }, + rendered: { + k1xSpc: "x78zum5", + $$css: true, + }, + end: { + kt4wiu: "xtijo5x", + kLqNvP: "xhi6v0a", + k2ei4v: "xgbv0en", + kVhnKS: "x1t7ytsu", + kGJrpR: "x1j92z86", + k3aq6I: "xumwmo6 x1df3fe5", + $$css: true, + }, + endOpen: { + k3aq6I: "xbryuvx x1yqmsfc x1lymnkk", + $$css: true, + }, + start: { + kLqNvP: "x1o0tod", + kt4wiu: "x1woyocn", + ke9TFa: "xw8tdv1", + k8ry5P: "x18b5jzi", + kBCPoo: "x1gejf6u", + k3aq6I: "x5i6ehr xttggg", + $$css: true, + }, + startOpen: { + k3aq6I: "xbryuvx x6mt36l x14gflnl", + $$css: true, + }, + scrim: { + kGyWv1: "xnixb3f", + kba3nw: "x1abwkk1", + k5sjJv: "xph5o2a", + kND0Po: "x167zut7", + k9an0g: "xft5bk6", + kb4ib: "x15h3t91", + kA5Tbj: "x1viac0w", + $$css: true, + }, + scrimOpen: { + k5sjJv: "xb3n6bw xxiuuzi", + $$css: true, + }, +}; +export const _temp = { + ks0D6T: "xlbgzzq", + $$css: true, +}; +export const dynamicStyles = { + inlineSize: (desktopWidth, mobileWidth) => [ + _temp, + { + kzqmXN: + (desktopWidth != null ? "x11ymkkh " : desktopWidth) + + (mobileWidth != null ? "xci7e5a" : mobileWidth), + $$css: true, + }, + { + "--x-1xmrurk": ((val) => + typeof val === "number" ? val + "px" : val != null ? val : undefined)( + desktopWidth, + ), + "--x-fqzwsx": ((val) => + typeof val === "number" ? val + "px" : val != null ? val : undefined)( + mobileWidth, + ), + }, + ], + stackZ: (z) => [ + { + kY2c9j: z != null ? "xr3buco" : z, + $$css: true, + }, + { + "--x-zIndex": z != null ? z : undefined, + }, + ], +}; diff --git a/src/forks/Drawer/Drawer.test.tsx b/src/forks/Drawer/Drawer.test.tsx new file mode 100644 index 0000000..502ac7d --- /dev/null +++ b/src/forks/Drawer/Drawer.test.tsx @@ -0,0 +1,639 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. +// Modifications copyright (c) Lablup Inc. +// +// Forked from @astryxdesign/lab 0.6.2-canary.c9fb1ad, src/Drawer/Drawer.test.tsx +// (MIT; see NOTICE). Upstream's tests, run against the fork; ui-common's own tests are +// in the *.fork.test.tsx beside it. +// Provenance and the drift guard: src/forks/provenance.json. + +/** + * @file Drawer.test.tsx + * @input Uses vitest, @testing-library/react, Drawer component + * @output Unit tests for Drawer component behavior + * @position Lab testing; validates Drawer.tsx implementation + * + * SYNC: When Drawer.tsx changes, update tests to match new behavior + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent, act } from "@testing-library/react"; +import { useState } from "react"; +import { Drawer } from "./Drawer"; + +// Mock dialog methods since they're not fully implemented in jsdom +beforeEach(() => { + HTMLDialogElement.prototype.showModal = vi.fn(function (this: HTMLDialogElement) { + this.setAttribute("open", ""); + }); + HTMLDialogElement.prototype.show = vi.fn(function (this: HTMLDialogElement) { + this.setAttribute("open", ""); + }); + HTMLDialogElement.prototype.close = vi.fn(function (this: HTMLDialogElement) { + this.removeAttribute("open"); + }); + + vi.stubGlobal( + "matchMedia", + vi.fn().mockReturnValue({ + matches: false, + media: "", + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + }), + ); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("Drawer", () => { + it("renders children when open", () => { + render( + <Drawer isOpen onOpenChange={() => {}} label="Host details"> + Drawer content + </Drawer>, + ); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + expect(screen.getByText("Drawer content")).toBeInTheDocument(); + }); + + it("does not show when isOpen is false", () => { + render( + <Drawer isOpen={false} onOpenChange={() => {}} label="Host details"> + Hidden content + </Drawer>, + ); + const dialog = screen.getByRole("dialog", { hidden: true }); + expect(dialog).not.toHaveAttribute("open"); + expect(HTMLDialogElement.prototype.showModal).not.toHaveBeenCalled(); + }); + + it("applies the accessible label", () => { + render( + <Drawer isOpen onOpenChange={() => {}} label="Host details"> + Content + </Drawer>, + ); + expect(screen.getByRole("dialog")).toHaveAccessibleName("Host details"); + }); + + describe("modal vs non-modal", () => { + it("opens with showModal() and aria-modal by default (hasScrim)", () => { + render( + <Drawer isOpen onOpenChange={() => {}} label="Details"> + Content + </Drawer>, + ); + expect(HTMLDialogElement.prototype.showModal).toHaveBeenCalled(); + expect(HTMLDialogElement.prototype.show).not.toHaveBeenCalled(); + expect(screen.getByRole("dialog")).toHaveAttribute("aria-modal", "true"); + }); + + it("opens with show() and no aria-modal when hasScrim is false", () => { + render( + <Drawer isOpen onOpenChange={() => {}} label="Details" hasScrim={false}> + Content + </Drawer>, + ); + expect(HTMLDialogElement.prototype.show).toHaveBeenCalled(); + expect(HTMLDialogElement.prototype.showModal).not.toHaveBeenCalled(); + expect(screen.getByRole("dialog")).not.toHaveAttribute("aria-modal"); + }); + }); + + describe("Escape key", () => { + it("calls onOpenChange(false) on Escape keydown", () => { + const handleOpenChange = vi.fn(); + render( + <Drawer isOpen onOpenChange={handleOpenChange} label="Details"> + Content + </Drawer>, + ); + fireEvent.keyDown(screen.getByRole("dialog"), { key: "Escape" }); + expect(handleOpenChange).toHaveBeenCalledWith(false); + }); + + it("calls onOpenChange(false) on Escape in non-modal mode (no native cancel)", () => { + const handleOpenChange = vi.fn(); + render( + <Drawer isOpen onOpenChange={handleOpenChange} label="Details" hasScrim={false}> + Content + </Drawer>, + ); + fireEvent.keyDown(screen.getByRole("dialog"), { key: "Escape" }); + expect(handleOpenChange).toHaveBeenCalledWith(false); + }); + + it("prevents the native cancel event and routes through onOpenChange(false)", () => { + const handleOpenChange = vi.fn(); + render( + <Drawer isOpen onOpenChange={handleOpenChange} label="Details"> + Content + </Drawer>, + ); + const cancelEvent = new Event("cancel", { cancelable: true }); + fireEvent(screen.getByRole("dialog"), cancelEvent); + expect(handleOpenChange).toHaveBeenCalledWith(false); + expect(cancelEvent.defaultPrevented).toBe(true); + }); + + it("ignores other keys", () => { + const handleOpenChange = vi.fn(); + render( + <Drawer isOpen onOpenChange={handleOpenChange} label="Details"> + Content + </Drawer>, + ); + fireEvent.keyDown(screen.getByRole("dialog"), { key: "Enter" }); + expect(handleOpenChange).not.toHaveBeenCalled(); + }); + }); + + describe("consumer event handlers", () => { + it("composes a consumer onKeyDown with built-in Escape handling", () => { + const handleKeyDown = vi.fn(); + const handleOpenChange = vi.fn(); + render( + <Drawer + isOpen + onOpenChange={handleOpenChange} + label="Details" + onKeyDown={handleKeyDown} + > + Content + </Drawer>, + ); + fireEvent.keyDown(screen.getByRole("dialog"), { key: "Escape" }); + expect(handleKeyDown).toHaveBeenCalledTimes(1); + expect(handleOpenChange).toHaveBeenCalledWith(false); + }); + + it("lets a consumer preventDefault opt out of built-in Escape handling", () => { + const handleOpenChange = vi.fn(); + render( + <Drawer + isOpen + onOpenChange={handleOpenChange} + label="Details" + onKeyDown={(event) => event.preventDefault()} + > + Content + </Drawer>, + ); + fireEvent.keyDown(screen.getByRole("dialog"), { key: "Escape" }); + expect(handleOpenChange).not.toHaveBeenCalled(); + }); + }); + + describe("scrim click", () => { + it("calls onOpenChange(false) when the ::backdrop (dialog element itself) is clicked", () => { + const handleOpenChange = vi.fn(); + render( + <Drawer isOpen onOpenChange={handleOpenChange} label="Details"> + Content + </Drawer>, + ); + fireEvent.click(screen.getByRole("dialog")); + expect(handleOpenChange).toHaveBeenCalledWith(false); + }); + + it("does not close when drawer content is clicked", () => { + const handleOpenChange = vi.fn(); + render( + <Drawer isOpen onOpenChange={handleOpenChange} label="Details"> + <button type="button">Inside</button> + </Drawer>, + ); + fireEvent.click(screen.getByRole("button", { name: "Inside" })); + expect(handleOpenChange).not.toHaveBeenCalled(); + }); + + it("does not close on self-click when non-modal (no scrim to click)", () => { + const handleOpenChange = vi.fn(); + render( + <Drawer isOpen onOpenChange={handleOpenChange} label="Details" hasScrim={false}> + Content + </Drawer>, + ); + fireEvent.click(screen.getByRole("dialog")); + expect(handleOpenChange).not.toHaveBeenCalled(); + }); + }); + + describe("close and focus restore", () => { + function Harness() { + const [isOpen, setIsOpen] = useState(false); + return ( + <> + <button type="button" onClick={() => setIsOpen(true)}> + Open inspector + </button> + <Drawer isOpen={isOpen} onOpenChange={setIsOpen} label="Inspector"> + <button type="button" onClick={() => setIsOpen(false)}> + Close inspector + </button> + </Drawer> + </> + ); + } + + // ui-common: skipped, as is the padding test below. Both read the + // stylesheet StyleX generates, which Astryx's own test run injects and + // this one does not load; they fail the same way against lab's Drawer here. + it.skip("delays dialog.close() so the exit transition can play", () => { + vi.useFakeTimers(); + try { + render(<Harness />); + fireEvent.click(screen.getByRole("button", { name: "Open inspector" })); + const dialog = screen.getByRole("dialog", { hidden: true }); + expect(dialog).toHaveAttribute("open"); + + fireEvent.click(screen.getByRole("button", { name: "Close inspector" })); + // Still open while the slide-out transition plays + expect(dialog).toHaveAttribute("open"); + act(() => { + vi.advanceTimersByTime(250); + }); + expect(dialog).toHaveAttribute("open"); + act(() => { + vi.advanceTimersByTime(300); + }); + expect(dialog).not.toHaveAttribute("open"); + } finally { + vi.useRealTimers(); + } + }); + + it("closes as soon as the slide-out transition ends", () => { + vi.useFakeTimers(); + try { + render(<Harness />); + fireEvent.click(screen.getByRole("button", { name: "Open inspector" })); + const dialog = screen.getByRole("dialog", { hidden: true }); + + fireEvent.click(screen.getByRole("button", { name: "Close inspector" })); + expect(dialog).toHaveAttribute("open"); + + // The transition is authoritative — no need to wait out the backstop. + act(() => { + fireEvent.transitionEnd(dialog, { propertyName: "transform" }); + }); + expect(dialog).not.toHaveAttribute("open"); + } finally { + vi.useRealTimers(); + } + }); + + it("keeps sliding while a transitionend for another property arrives", () => { + vi.useFakeTimers(); + try { + render(<Harness />); + fireEvent.click(screen.getByRole("button", { name: "Open inspector" })); + const dialog = screen.getByRole("dialog", { hidden: true }); + + fireEvent.click(screen.getByRole("button", { name: "Close inspector" })); + act(() => { + fireEvent.transitionEnd(dialog, { propertyName: "opacity" }); + }); + expect(dialog).toHaveAttribute("open"); + } finally { + vi.useRealTimers(); + } + }); + + it("restores focus to the trigger element on close", () => { + vi.useFakeTimers(); + try { + render(<Harness />); + const trigger = screen.getByRole("button", { name: "Open inspector" }); + trigger.focus(); + fireEvent.click(trigger); + + fireEvent.click(screen.getByRole("button", { name: "Close inspector" })); + act(() => { + vi.advanceTimersByTime(300); + }); + expect(trigger).toHaveFocus(); + } finally { + vi.useRealTimers(); + } + }); + + it("can be re-opened after closing", () => { + vi.useFakeTimers(); + try { + render(<Harness />); + const dialog = screen.getByRole("dialog", { hidden: true }); + + fireEvent.click(screen.getByRole("button", { name: "Open inspector" })); + expect(dialog).toHaveAttribute("open"); + + fireEvent.click(screen.getByRole("button", { name: "Close inspector" })); + act(() => { + vi.advanceTimersByTime(300); + }); + expect(dialog).not.toHaveAttribute("open"); + + fireEvent.click(screen.getByRole("button", { name: "Open inspector" })); + act(() => { + vi.advanceTimersByTime(300); + }); + expect(dialog).toHaveAttribute("open"); + } finally { + vi.useRealTimers(); + } + }); + }); + + it("focuses the element with data-autofocus on open", () => { + render( + <Drawer isOpen onOpenChange={() => {}} label="Details"> + <button type="button">First</button> + <button type="button" data-autofocus> + Second + </button> + </Drawer>, + ); + expect(screen.getByRole("button", { name: "Second" })).toHaveFocus(); + }); + + it("renders the side as a data attribute for theming", () => { + render( + <Drawer isOpen onOpenChange={() => {}} label="Details" side="start"> + Content + </Drawer>, + ); + expect(screen.getByRole("dialog")).toHaveAttribute("data-side", "start"); + }); + + describe("sides", () => { + it.each(["start", "end"] as const)( + 'renders side="%s" with the matching data attribute', + (side) => { + render( + <Drawer isOpen onOpenChange={() => {}} label="Details" side={side}> + Content + </Drawer>, + ); + expect(screen.getByRole("dialog")).toHaveAttribute("data-side", side); + }, + ); + }); + + describe("width", () => { + it("applies the default 400px inline budget", () => { + render( + <Drawer isOpen onOpenChange={() => {}} label="Details"> + Content + </Drawer>, + ); + expect(screen.getByRole("dialog").getAttribute("style")).toContain("400px"); + }); + + it("accepts a number of pixels", () => { + render( + <Drawer isOpen onOpenChange={() => {}} label="Details" width={320}> + Content + </Drawer>, + ); + expect(screen.getByRole("dialog").getAttribute("style")).toContain("320px"); + }); + + it("accepts any CSS length string", () => { + render( + <Drawer isOpen onOpenChange={() => {}} label="Details" width="50%"> + Content + </Drawer>, + ); + expect(screen.getByRole("dialog").getAttribute("style")).toContain("50%"); + }); + + it("preserves a 56px page reveal without exceeding the width budget on mobile", () => { + render( + <Drawer isOpen onOpenChange={() => {}} label="Details"> + Content + </Drawer>, + ); + // Desktop budget and mobile cap are both emitted as custom properties; + // the media query itself is compiled CSS that jsdom cannot evaluate, + // so assert both values reach the element. + const style = screen.getByRole("dialog").getAttribute("style") ?? ""; + expect(style).toContain("400px"); + expect(style).toContain("min(400px, calc(100dvw - 56px))"); + }); + + it("covers the full viewport on mobile with isFullWidthOnMobile", () => { + render( + <Drawer isOpen onOpenChange={() => {}} label="Details" isFullWidthOnMobile> + Content + </Drawer>, + ); + const style = screen.getByRole("dialog").getAttribute("style") ?? ""; + expect(style).toContain("100dvw"); + expect(style).not.toContain("100dvw - 56px"); + }); + }); + + describe("close button", () => { + it("renders a close button by default when modal", () => { + const handleOpenChange = vi.fn(); + render( + <Drawer isOpen onOpenChange={handleOpenChange} label="Details"> + Content + </Drawer>, + ); + const closeButton = screen.getByRole("button", { name: "Close" }); + fireEvent.click(closeButton); + expect(handleOpenChange).toHaveBeenCalledWith(false); + }); + + it("renders a close button by default when non-modal", () => { + const handleOpenChange = vi.fn(); + render( + <Drawer isOpen onOpenChange={handleOpenChange} label="Details" hasScrim={false}> + Content + </Drawer>, + ); + fireEvent.click(screen.getByRole("button", { name: "Close" })); + expect(handleOpenChange).toHaveBeenCalledWith(false); + }); + + it("hides the close button with hasCloseButton={false}", () => { + render( + <Drawer isOpen onOpenChange={() => {}} label="Details" hasCloseButton={false}> + Content + </Drawer>, + ); + expect(screen.queryByRole("button", { name: "Close" })).not.toBeInTheDocument(); + }); + + it("keeps the close button when hasCloseButton is explicitly true in non-modal mode", () => { + render( + <Drawer + isOpen + onOpenChange={() => {}} + label="Details" + hasScrim={false} + hasCloseButton + > + Content + </Drawer>, + ); + expect(screen.getByRole("button", { name: "Close" })).toBeInTheDocument(); + }); + }); + + describe("LIFO stacking", () => { + it("Escape only closes the last-opened drawer", () => { + const closeFirst = vi.fn(); + const closeSecond = vi.fn(); + render( + <> + <Drawer isOpen onOpenChange={closeFirst} label="First" hasScrim={false}> + First content + </Drawer> + <Drawer isOpen onOpenChange={closeSecond} label="Second" hasScrim={false}> + Second content + </Drawer> + </>, + ); + + // Escape inside the first (bottom-of-stack) drawer is ignored. + fireEvent.keyDown(screen.getByRole("dialog", { name: "First" }), { + key: "Escape", + }); + expect(closeFirst).not.toHaveBeenCalled(); + expect(closeSecond).not.toHaveBeenCalled(); + + // Escape inside the last-opened drawer closes it. + fireEvent.keyDown(screen.getByRole("dialog", { name: "Second" }), { + key: "Escape", + }); + expect(closeSecond).toHaveBeenCalledWith(false); + expect(closeFirst).not.toHaveBeenCalled(); + }); + + function StackHarness() { + const [outerOpen, setOuterOpen] = useState(true); + const [innerOpen, setInnerOpen] = useState(true); + return ( + <> + <Drawer + isOpen={outerOpen} + onOpenChange={setOuterOpen} + label="Outer" + hasScrim={false} + > + Outer content + </Drawer> + <Drawer + isOpen={innerOpen} + onOpenChange={setInnerOpen} + label="Inner" + hasScrim={false} + > + Inner content + </Drawer> + </> + ); + } + + it("closes stacked drawers innermost-first", () => { + vi.useFakeTimers(); + try { + render(<StackHarness />); + const outer = screen.getByRole("dialog", { name: "Outer" }); + const inner = screen.getByRole("dialog", { name: "Inner" }); + + fireEvent.keyDown(inner, { key: "Escape" }); + // Inner unregistered when isOpen flipped — outer is now the top. + fireEvent.keyDown(outer, { key: "Escape" }); + act(() => { + vi.advanceTimersByTime(300); + }); + expect(inner).not.toHaveAttribute("open"); + expect(outer).not.toHaveAttribute("open"); + } finally { + vi.useRealTimers(); + } + }); + + it("unregisters unmounted drawers so the remaining one becomes top", () => { + const closeFirst = vi.fn(); + const { rerender } = render( + <> + <Drawer isOpen onOpenChange={closeFirst} label="First" hasScrim={false}> + First content + </Drawer> + <Drawer isOpen onOpenChange={() => {}} label="Second" hasScrim={false}> + Second content + </Drawer> + </>, + ); + rerender( + <Drawer isOpen onOpenChange={closeFirst} label="First" hasScrim={false}> + First content + </Drawer>, + ); + fireEvent.keyDown(screen.getByRole("dialog", { name: "First" }), { + key: "Escape", + }); + expect(closeFirst).toHaveBeenCalledWith(false); + }); + }); + + describe("exit anchoring", () => { + it("slides out to the side it opened from, even if the prop flips", () => { + // The common consumer shape: `side` is derived from the same state that + // drives isOpen, so it reverts to the default the moment the drawer + // closes. The panel must still leave by the edge it came in from. + function Harness() { + const [side, setSide] = useState<"start" | "end" | null>(null); + return ( + <> + <button type="button" onClick={() => setSide("start")}> + Open from start + </button> + <Drawer + isOpen={side != null} + onOpenChange={(isOpen) => !isOpen && setSide(null)} + label="Filters" + side={side ?? "end"} + > + Content + </Drawer> + </> + ); + } + render(<Harness />); + fireEvent.click(screen.getByRole("button", { name: "Open from start" })); + const dialog = screen.getByRole("dialog", { hidden: true }); + expect(dialog).toHaveAttribute("data-side", "start"); + + fireEvent.keyDown(dialog, { key: "Escape" }); + // Mid-exit: the live prop is now 'end', the anchor must still be 'start'. + expect(dialog).toHaveAttribute("data-side", "start"); + }); + }); + + describe("container padding isolation", () => { + it.skip("resets container padding custom properties on the root dialog element", () => { + render( + <Drawer isOpen onOpenChange={() => {}} label="Details"> + Content + </Drawer>, + ); + const dialog = screen.getByRole("dialog"); + const computed = window.getComputedStyle(dialog); + expect(computed.getPropertyValue("--container-padding-inline-start")).toBe("0px"); + expect(computed.getPropertyValue("--container-padding-inline-end")).toBe("0px"); + expect(computed.getPropertyValue("--container-padding-block-start")).toBe("0px"); + expect(computed.getPropertyValue("--container-padding-block-end")).toBe("0px"); + }); + }); +}); diff --git a/src/forks/Drawer/Drawer.tsx b/src/forks/Drawer/Drawer.tsx new file mode 100644 index 0000000..162010e --- /dev/null +++ b/src/forks/Drawer/Drawer.tsx @@ -0,0 +1,442 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. +// Modifications copyright (c) Lablup Inc. +// +// Forked from @astryxdesign/lab 0.6.2-canary.c9fb1ad, src/Drawer/Drawer.tsx +// (MIT; see NOTICE). Provenance and the drift guard: src/forks/provenance.json. + +"use client"; + +/** + * Drawer, ui-common's copy of Astryx lab's, with two fixes applied (not yet + * upstream): + * + * - **Escape stays inside.** The drawer's Escape handler acts only on an + * Escape that happened in its own DOM subtree and did not end an IME + * composition. React events follow the React tree, so a layer portalled out + * of the drawer's children (a modal opened from inside it) bubbled its + * Escape through here; acting on it, and preventing its default, made the + * shared layer-dismissal stack stand down, so neither the layer nor the + * drawer closed. An Escape that ends a composition cancels the composition, + * as the layer stack already treats it. + * - **`aria-modal` passes through.** A scrimless drawer is non-modal, but a + * consumer that restores modality by hand (its own mask and focus trap) can + * now say so; the default is unchanged. + * + * Everything else is upstream's. Its style namespaces are Astryx's compiled + * output (src/forks/compiled.ts). The LIFO drawer registry is module-level, + * so this copy stacks with other drawers from this copy only; lab's own + * `Drawer` is not exported by ui-common. + * + * Delete this fork, and its exports.exclude.json entry, once lab ships both + * fixes (CONTRIBUTING, "Forks of Astryx components"). + */ + +/* + * Upstream's file notes, kept: + * + * Overlay panel for inspectors and detail views — the "click a table row, + * see its details" pattern. Slides in from the inline start or end edge and + * floats above the page content: unlike a docked panel it never reflows the + * layout underneath, it overlays it (with or without a scrim). + * + * Inline axis only (start/end). Block-axis sheets are BottomSheet's job; + * a drawer is always a full-height side panel. + * + * Sizing is viewport-aware: `width` is the desktop budget, and below + * the mobile breakpoint it preserves a 56px reveal of the page behind, capped + * by the requested width (or fills the viewport with `isFullWidthOnMobile`). + * + * Uses the native `<dialog>` element (same precedent as Dialog/MobileNav): + * - `showModal()` when `hasScrim` (default) — top-layer rendering, focus + * trapping, `::backdrop`, no z-index management. + * - `show()` when `hasScrim={false}` — non-modal overlay; the page behind + * stays interactive (e.g. master-detail inspectors). + * + * Entry animation uses `@starting-style`; exit slides out before + * `dialog.close()` releases the top layer and restores focus to the element + * that opened the drawer. React owns `display` for both legs rather than a + * discrete `display` transition, so the panel stops painting in the same + * commit as `close()`. + * + * Sibling drawers coordinate through a module-level LIFO registry: Escape + * closes only the top (last-opened) drawer, and non-modal drawers stack + * last-opened-on-top via registry-assigned z-indexes. + */ + +import { useCallback, useEffect, useId, useRef, useState, type ReactNode } from "react"; +import * as stylex from "@stylexjs/stylex"; +import type { StyleXStyles } from "@stylexjs/stylex"; +import type { BaseProps } from "@astryxdesign/core"; +import { Icon } from "@astryxdesign/core/Icon"; +import { IconButton } from "@astryxdesign/core/IconButton"; +import { useScrollLock } from "@astryxdesign/core/hooks"; +import { + composeEventHandlers, + mergeProps, + mergeRefs, + themeProps, +} from "@astryxdesign/core/utils"; +import { overlayPaddingReset } from "@astryxdesign/core/Layout"; + +import { compiledStyles } from "../compiled"; +import * as compiled from "./Drawer.styles"; +import { useDrawerDialogPresence } from "./useDrawerDialogPresence"; + +// ============================================================================= +// LIFO stacking registry (internal) +// ============================================================================= + +// Module-level registry of currently open drawers, in open order (last entry +// is the top of the stack). SSR-safe: only mutated inside effects. Escape +// handling consults isTopDrawer() so sibling drawers close innermost-first, +// and non-modal (show()) drawers get incrementing z-indexes so the +// last-opened one paints on top; modal drawers rely on the native top +// layer's chronological stacking instead. +type DrawerRegistryEntry = { id: string; close: () => void }; + +// Without the top layer (hasScrim={false} uses show(), not showModal()) +// the panel needs explicit stacking. No z-index token exists in the theme; +// 1000 matches the app-level drawer convention. +const NON_MODAL_BASE_Z = 1000; + +const openDrawerStack: DrawerRegistryEntry[] = []; +let registrationCounter = 0; + +function registerDrawer(id: string, close: () => void): number { + openDrawerStack.push({ id, close }); + registrationCounter += 1; + return NON_MODAL_BASE_Z + registrationCounter - 1; +} + +function unregisterDrawer(id: string): void { + const index = openDrawerStack.findIndex((entry) => entry.id === id); + if (index !== -1) { + openDrawerStack.splice(index, 1); + } + if (openDrawerStack.length === 0) { + registrationCounter = 0; + } +} + +function isTopDrawer(id: string): boolean { + return openDrawerStack[openDrawerStack.length - 1]?.id === id; +} + +// ============================================================================= +// Styles +// ============================================================================= + +// Material's established mobile drawer pattern leaves a 56dp reveal. Using +// the same value in CSS pixels gives the overlay a stable visual relationship +// to the page behind while the requested width remains an upper bound. +const MOBILE_PAGE_REVEAL = 56; +const MOBILE_WIDTH_FULL = "100dvw"; + +// @astryxdesign/lab dist/Drawer/Drawer.js `styles` and `dynamicStyles`. +const styles = compiledStyles(compiled.styles); +const dynamicStyles = compiled.dynamicStyles as { + inlineSize: (desktopWidth: string, mobileWidth: string) => StyleXStyles; + stackZ: (z: number) => StyleXStyles; +}; + +// Upstream's `content` and `controls` styles, which its compiler folded into +// these class lists (dist/Drawer/Drawer.js). +const CONTENT_CLASS_NAME = + "x1iyjqo2 x2lwn1j xh8yej3 x1odjw0f x6ikm8r xish69e xx69xxh x1a148e8 x1a2a7pz"; +const CONTROLS_CLASS_NAME = "x10l6tqk xctzyg x72tfeb x78zum5 xzye2dw x1vjfegm"; + +// ============================================================================= +// Types +// ============================================================================= + +export interface DrawerProps extends BaseProps<HTMLDialogElement> { + /** Ref forwarded to the root <dialog> element */ + ref?: React.Ref<HTMLDialogElement>; + + /** + * Whether the drawer is open. Fully controlled — pair with `onOpenChange`. + */ + isOpen: boolean; + + /** + * Called when the drawer requests an open-state change. Escape, scrim + * click, and the built-in close button call it with `false`. The caller owns + * the open state. When sibling drawers are open, Escape only closes the top + * (last-opened) drawer. + */ + onOpenChange: (isOpen: boolean) => void; + + /** + * Which edge the drawer slides from. + * - `'end'` — inline-end edge (right in LTR) — the inspector convention + * - `'start'` — inline-start edge (left in LTR) + * @default 'end' + */ + side?: "start" | "end"; + + /** + * Desktop width budget. A number is pixels; a string is any CSS length + * (`'50%'`, `'32rem'`). Below the mobile breakpoint (640px), this + * remains the maximum while the drawer preserves a 56px reveal of the page + * behind — see `isFullWidthOnMobile`. + * @default 400 + */ + width?: number | string; + + /** + * Whether the drawer covers the full viewport width on mobile + * (below 640px) instead of preserving the default 56px reveal of the page + * behind. The reveal makes the drawer read as an overlay rather than a + * navigation. + * @default false + */ + isFullWidthOnMobile?: boolean; + + /** + * Accessible label for the drawer (required — the drawer has no + * built-in heading to derive a name from). + */ + label: string; + + /** + * Whether to render a modal scrim behind the drawer. + * - `true` (default) — `showModal()`: top layer, focus trap, body scroll + * lock, click-outside-to-close. + * - `false` — `show()`: non-modal overlay; the page behind stays + * interactive. Escape still closes while focus is inside the drawer. + * @default true + */ + hasScrim?: boolean; + + /** + * Whether to render the built-in close button in the top-trailing + * corner. Enabled by default for both modal and non-modal drawers so every + * overlay has an obvious dismissal affordance. + * @default true + */ + hasCloseButton?: boolean; + + /** + * Drawer content. Rendered inside a full-height scrollable area. + * Focus the element with `data-autofocus` on open, if present. + */ + children: ReactNode; + + /** + * Test ID for the root element. + */ + "data-testid"?: string; +} + +// ============================================================================= +// Component +// ============================================================================= + +/** + * An overlay panel for inspectors and detail views. + * + * Slides in from the logical start or end edge and floats above the page + * using the native `<dialog>` element: modal with a scrim by default, or a + * non-modal overlay with `hasScrim={false}` that leaves the page behind + * interactive. `width` is the desktop budget; below 640px the panel preserves + * a 56px page reveal without exceeding that budget (or fills the viewport + * with `isFullWidthOnMobile`). Escape + * closes the top-most open drawer; focus returns to the element that + * opened it. + * + * @example + * ``` + * const [selected, setSelected] = useState(null); + * <Drawer + * isOpen={selected != null} + * onOpenChange={isOpen => !isOpen && setSelected(null)} + * label={`Details: ${selected?.name}`}> + * <HostDetails host={selected} /> + * </Drawer> + * ``` + */ +export function Drawer({ + isOpen, + onOpenChange, + side = "end", + width = 400, + isFullWidthOnMobile = false, + label, + hasScrim = true, + hasCloseButton = true, + children, + xstyle, + className, + style, + onClick: onClickProp, + onKeyDown: onKeyDownProp, + ref, + ...props +}: DrawerProps) { + const dialogRef = useRef<HTMLDialogElement>(null); + // Registry identity + latest onOpenChange (stable across re-renders so the + // registration effect doesn't churn on every onOpenChange identity change). + const drawerId = useId(); + const onOpenChangeRef = useRef(onOpenChange); + useEffect(() => { + onOpenChangeRef.current = onOpenChange; + }, [onOpenChange]); + // z-index assigned by the registry on open (non-modal stacking only). + const [stackZ, setStackZ] = useState(NON_MODAL_BASE_Z); + // Whether the panel paints: true while open and for the whole slide-out. + const [isRendered, setIsRendered] = useState(isOpen); + + // Adjusted during render, not in an effect: the panel has to be rendered in + // the same commit that targets the open transform, or @starting-style has + // nothing to animate from. + if (isOpen && !isRendered) { + setIsRendered(true); + } + + useDrawerDialogPresence({ + dialogRef, + isOpen, + isModal: hasScrim, + setIsRendered, + }); + + // LIFO registry membership: register on open, unregister on close or + // unmount. The returned z-index stacks non-modal siblings in open order. + useEffect(() => { + if (!isOpen) { + return; + } + const z = registerDrawer(drawerId, () => onOpenChangeRef.current(false)); + setStackZ(z); + return () => unregisterDrawer(drawerId); + }, [isOpen, drawerId]); + + // Lock body scroll while a modal drawer is open (iOS Safari workaround). + useScrollLock(isOpen && hasScrim); + + // Escape closes. The native `cancel` event only fires for showModal(); + // this React keydown handler covers the non-modal show() path too. Only the + // top of the drawer stack closes, so stacked siblings peel off + // innermost-first. + const handleKeyDown = useCallback( + (event: React.KeyboardEvent<HTMLDialogElement>) => { + // ui-common: React events follow the REACT tree, so a layer portalled + // out of this drawer's children bubbles its Escape through here although + // it is not in this dialog's DOM subtree. Acting on it (and preventing + // its default) makes the shared layer-dismissal stack stand down, so + // neither the layer nor the drawer closes. An Escape that ends an IME + // composition cancels the composition, not the drawer. + if ( + event.key === "Escape" && + !event.nativeEvent.isComposing && + event.currentTarget.contains(event.target as Node) + ) { + event.preventDefault(); + if (isTopDrawer(drawerId)) { + onOpenChange(false); + } + } + }, + [onOpenChange, drawerId], + ); + + // Native cancel event (browser Escape handling) — prevent the browser + // from closing the dialog directly and route through onOpenChange so the + // caller's state stays the source of truth. Same top-of-stack rule as + // the keydown path. + const handleCancel = useCallback( + (event: React.SyntheticEvent<HTMLDialogElement>) => { + event.preventDefault(); + if (isTopDrawer(drawerId)) { + onOpenChange(false); + } + }, + [onOpenChange, drawerId], + ); + + // Clicks on the ::backdrop target the <dialog> element itself; clicks on + // drawer content always target a child (the content area fills the panel). + const handleClick = useCallback( + (event: React.MouseEvent<HTMLDialogElement>) => { + if (event.target === event.currentTarget && hasScrim) { + onOpenChange(false); + } + }, + [hasScrim, onOpenChange], + ); + + const widthValue = typeof width === "number" ? `${width}px` : width; + const mobileWidth = isFullWidthOnMobile + ? MOBILE_WIDTH_FULL + : `min(${widthValue}, calc(100dvw - ${MOBILE_PAGE_REVEAL}px))`; + + // The side the panel is ANCHORED to, which is the side it must slide back + // out to. Latched at open, because a consumer commonly derives `side` from + // the same state that drives `isOpen` (`side={selected?.side ?? 'end'}`): + // that state clears on close, so the live prop flips mid-exit and the panel + // teleports to the other edge and slides out the wrong way. Children stay + // mounted for the exit for the same reason; so does the anchor. + const exitSideRef = useRef(side); + if (isOpen) { + exitSideRef.current = side; + } + const anchoredSide = isOpen ? side : exitSideRef.current; + + const sideStyle = anchoredSide === "start" ? styles.start : styles.end; + const sideOpenStyle = anchoredSide === "start" ? styles.startOpen : styles.endOpen; + + // Filter out native `open` to prevent InvalidStateError when passed + const { open: _open, ...safeProps } = props as Record<string, unknown>; + + return ( + <dialog + ref={mergeRefs(ref, dialogRef)} + {...mergeProps( + themeProps("drawer", { side: anchoredSide }), + stylex.props( + styles.dialog, + overlayPaddingReset.reset, + sideStyle, + dynamicStyles.inlineSize(widthValue, mobileWidth), + isRendered && styles.rendered, + isOpen && sideOpenStyle, + hasScrim ? styles.scrim : dynamicStyles.stackZ(stackZ), + hasScrim && isOpen && styles.scrimOpen, + xstyle, + ), + className, + style, + )} + {...safeProps} + aria-label={label} + // ui-common: scrimless by itself is non-modal, but a consumer that + // restores the modality by hand (a portal supplying its own mask and + // focus trap) must be able to say so. + aria-modal={ + (safeProps["aria-modal"] as React.AriaAttributes["aria-modal"]) ?? + (hasScrim ? "true" : undefined) + } + onClick={composeEventHandlers(onClickProp, handleClick)} + onKeyDown={composeEventHandlers(onKeyDownProp, handleKeyDown)} + onCancel={handleCancel} + > + {/* Scrollable content area — tabIndex so the dialog's focusing steps + land on the panel body rather than the first button inside. */} + <div tabIndex={-1} className={CONTENT_CLASS_NAME}> + {children} + </div> + {hasCloseButton && ( + <div className={CONTROLS_CLASS_NAME}> + <IconButton + icon={<Icon icon="close" size="sm" color="inherit" />} + label="Close" + variant="ghost" + onClick={() => onOpenChange(false)} + /> + </div> + )} + </dialog> + ); +} + +Drawer.displayName = "Drawer"; diff --git a/src/forks/Drawer/index.ts b/src/forks/Drawer/index.ts new file mode 100644 index 0000000..1eea960 --- /dev/null +++ b/src/forks/Drawer/index.ts @@ -0,0 +1,6 @@ +/** + * ui-common's copy of Astryx lab's Drawer, exported from + * `@lablup/ui-common/lab` in place of lab's (exports.exclude.json). + */ +export { Drawer } from "./Drawer"; +export type { DrawerProps } from "./Drawer"; diff --git a/src/forks/Drawer/useDrawerDialogPresence.ts b/src/forks/Drawer/useDrawerDialogPresence.ts new file mode 100644 index 0000000..68f4424 --- /dev/null +++ b/src/forks/Drawer/useDrawerDialogPresence.ts @@ -0,0 +1,225 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. +// +// Forked from @astryxdesign/lab 0.6.2-canary.c9fb1ad, src/Drawer/useDrawerDialogPresence.ts +// (MIT; see NOTICE). Unchanged: forked with Drawer, which uses it. +// Provenance and the drift guard: src/forks/provenance.json. + +"use client"; + +/** + * @file useDrawerDialogPresence.ts + * @input Controlled open state, modal mode, dialog ref, and rendered-state setter + * @output Coordinates native dialog presence, exit timing, focus restoration, and unmount cleanup + * @position Drawer-internal hook; consumed only by Drawer.tsx + * + * The drawer has two independent notions of presence: + * - React's rendered state keeps the panel visible for its CSS exit. + * - The native <dialog> `open` state keeps it in the top layer. + * + * Their close ordering is a browser-visible invariant: the panel must finish + * its exit, then `dialog.close()` and the React hide must happen in the same + * task. If the hide lands a frame later, a transformed ancestor becomes the + * containing block for the now-non-top-layer `position: fixed` panel and it + * paints back inside the page for one frame. + * + * SYNC: When modified, update: + * - /packages/lab/src/Drawer/Drawer.test.tsx + * - /.github/scripts/modal-close-visibility.js + */ + +import { + useEffect, + useRef, + type Dispatch, + type RefObject, + type SetStateAction, +} from "react"; +import { flushSync } from "react-dom"; + +/** Slack past the computed transition before the backstop gives up waiting. */ +const EXIT_BACKSTOP_BUFFER_MS = 50; + +/** + * Hold used when the transition duration cannot be read — an unresolved + * `var()` outside a real browser. Picking a fixed number would otherwise make + * an assumption about the consumer's theme. + */ +const EXIT_FALLBACK_MS = 250; + +type UseDrawerDialogPresenceOptions = { + dialogRef: RefObject<HTMLDialogElement | null>; + isOpen: boolean; + isModal: boolean; + setIsRendered: Dispatch<SetStateAction<boolean>>; +}; + +/** + * Coordinates the native dialog and React-rendered presence for Drawer. + * + * Opening captures the trigger, opens the native dialog, and honours the + * component's `data-autofocus` contract. Closing waits for the actual + * transform transition (with a computed-duration backstop), then closes the + * native dialog and synchronously hides the panel before the browser can paint + * it outside the top layer. Unmount cleanup closes a dialog left open by React + * Activity or a removed subtree. + */ +export function useDrawerDialogPresence({ + dialogRef, + isOpen, + isModal, + setIsRendered, +}: UseDrawerDialogPresenceOptions): void { + // Element focused when the drawer opened — restored on close. + const triggerElementRef = useRef<HTMLElement | null>(null); + + useEffect(() => { + const dialog = dialogRef.current; + if (!dialog) { + return; + } + + if (isOpen) { + if (!dialog.open) { + triggerElementRef.current = document.activeElement as HTMLElement | null; + if (isModal) { + dialog.showModal(); + } else { + dialog.show(); + } + // React's autoFocus calls .focus() during commit, before the dialog is + // shown, so it silently fails — honour data-autofocus instead (same + // contract as Dialog). + dialog.querySelector<HTMLElement>("[data-autofocus]")?.focus(); + } + return; + } + + if (!dialog.open) { + return; + } + + return waitForDrawerExit(dialog, () => { + dialog.close(); + // flushSync, not a plain setState: React's default scheduling can land + // the commit after the next paint, and that one frame is exactly the + // bug — the panel paints outside the top layer. Both happen in this + // task, so the browser never gets to paint between them. + flushSync(() => { + setIsRendered(false); + }); + // Return focus after close(): a modal dialog makes the rest of the + // document inert, so focusing the trigger before close() silently fails. + triggerElementRef.current?.focus(); + triggerElementRef.current = null; + }); + }, [dialogRef, isModal, isOpen, setIsRendered]); + + // Close the native dialog on unmount if it is still open. When the drawer is + // mounted inside an <Activity> that flips to mode="hidden", React runs effect + // cleanups (with stale isOpen) instead of re-running the effect with + // isOpen=false. Leaving `open` set would skip showModal() on the next open. + // This is deliberately separate from the open/close effect: putting it in + // that cleanup would cut off every delayed slide-out. + useEffect(() => { + const dialog = dialogRef.current; + return () => { + if (dialog?.open) { + dialog.close(); + } + }; + }, [dialogRef]); +} + +/** + * Wait for the panel's transform transition, with a computed-duration + * backstop. Mirrors BottomSheetPanel.waitForTransition: the native event is + * authoritative, and the timeout only prevents a lost event from stranding an + * open dialog. + */ +function waitForDrawerExit( + element: HTMLDialogElement, + complete: () => void, +): () => void { + let done = false; + let timer: ReturnType<typeof setTimeout> | null = null; + + const finish = () => { + if (done) { + return; + } + done = true; + if (timer != null) { + clearTimeout(timer); + } + element.removeEventListener("transitionend", handleTransitionEnd); + element.removeEventListener("transitioncancel", handleTransitionEnd); + complete(); + }; + + const handleTransitionEnd = (event: TransitionEvent) => { + if (event.target === element && event.propertyName === "transform") { + finish(); + } + }; + + element.addEventListener("transitionend", handleTransitionEnd); + element.addEventListener("transitioncancel", handleTransitionEnd); + + const transitionMs = readTransformTransitionMs(element); + timer = setTimeout( + finish, + (transitionMs ?? EXIT_FALLBACK_MS) + EXIT_BACKSTOP_BUFFER_MS, + ); + + return () => { + done = true; + if (timer != null) { + clearTimeout(timer); + } + element.removeEventListener("transitionend", handleTransitionEnd); + element.removeEventListener("transitioncancel", handleTransitionEnd); + }; +} + +/** Read the transform transition's duration + delay from computed CSS. */ +function readTransformTransitionMs(element: HTMLElement): number | null { + const computed = window.getComputedStyle(element); + const properties = computed.transitionProperty + .split(",") + .map((value) => value.trim()); + const durations = parseTimes(computed.transitionDuration); + const delays = parseTimes(computed.transitionDelay); + + if ( + properties.length === 0 || + durations.length === 0 || + delays.length === 0 || + durations.includes(null) || + delays.includes(null) + ) { + return null; + } + + return properties.reduce((longest, property, index) => { + if (property !== "transform" && property !== "all") { + return longest; + } + const duration = durations[index % durations.length]; + const delay = delays[index % delays.length]; + return Math.max(longest, (duration ?? 0) + (delay ?? 0)); + }, 0); +} + +function parseTimes(value: string): Array<number | null> { + return value.split(",").map((part) => { + const trimmed = part.trim(); + const time = Number.parseFloat(trimmed); + if (!Number.isFinite(time)) { + return null; + } + if (trimmed.endsWith("ms")) { + return time; + } + return trimmed.endsWith("s") ? time * 1000 : null; + }); +} diff --git a/src/forks/Tour/Tour.fork.test.tsx b/src/forks/Tour/Tour.fork.test.tsx new file mode 100644 index 0000000..b1ff979 --- /dev/null +++ b/src/forks/Tour/Tour.fork.test.tsx @@ -0,0 +1,129 @@ +/** + * ui-common's tests for its Tour fork: the fix it carries (a step's highlight + * is promoted into the top layer once and never hidden and re-shown), and that + * everything else renders exactly as lab's does. + */ +import { StrictMode, useRef } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { Tour as UpstreamTour, TourStep as UpstreamTourStep } from "@astryxdesign/lab"; + +import { comparableMarkup } from "../../test/forkParity"; +import { Tour } from "./Tour"; +import { TourStep } from "./TourStep"; + +const originalMatches = Element.prototype.matches; +let showPopover: ReturnType<typeof vi.fn>; +let hidePopover: ReturnType<typeof vi.fn>; + +// jsdom has neither the popover API nor top-layer state; model both. +beforeEach(() => { + HTMLDialogElement.prototype.showModal = vi.fn(function (this: HTMLDialogElement) { + this.setAttribute("open", ""); + }); + HTMLDialogElement.prototype.close = vi.fn(function (this: HTMLDialogElement) { + this.removeAttribute("open"); + }); + showPopover = vi.fn(function (this: HTMLElement) { + this.setAttribute("data-popover-open", ""); + }); + hidePopover = vi.fn(function (this: HTMLElement) { + this.removeAttribute("data-popover-open"); + }); + HTMLElement.prototype.showPopover = showPopover as () => void; + HTMLElement.prototype.hidePopover = hidePopover as () => void; + Element.prototype.matches = function (this: Element, selector: string) { + if (selector === ":popover-open") return this.hasAttribute("data-popover-open"); + return originalMatches.call(this, selector); + }; +}); + +afterEach(() => { + Element.prototype.matches = originalMatches; + // @ts-expect-error: restore jsdom's absence of the popover API + delete HTMLElement.prototype.showPopover; + // @ts-expect-error: as above + delete HTMLElement.prototype.hidePopover; +}); + +function OneStepTour({ + TourImpl, + StepImpl, + hasBackdrop = false, +}: { + TourImpl: typeof Tour; + StepImpl: typeof TourStep; + hasBackdrop?: boolean; +}) { + const ref = useRef<HTMLButtonElement>(null); + return ( + <> + <button type="button" ref={ref}> + Target + </button> + <TourImpl isActive hasBackdrop={hasBackdrop} onDismiss={() => {}}> + <StepImpl targetRef={ref} heading="Save your work"> + Changes save automatically. + </StepImpl> + </TourImpl> + </> + ); +} + +const highlightOverlay = () => screen.getByTestId("tour-highlight").parentElement!; +const overlayPromotions = () => + showPopover.mock.contexts.filter((el) => el === highlightOverlay()).length; +const overlayDemotions = () => + hidePopover.mock.contexts.filter((el) => el === highlightOverlay()).length; + +describe("Tour fork: highlight promotion", () => { + it("promotes the highlight once under StrictMode, and never hides it", () => { + render( + <StrictMode> + <OneStepTour TourImpl={Tour} StepImpl={TourStep} hasBackdrop /> + </StrictMode>, + ); + expect(highlightOverlay()).toHaveAttribute("data-popover-open"); + expect(overlayPromotions()).toBe(1); + expect(overlayDemotions()).toBe(0); + }); + + // When this fails, lab has shipped the fix: delete the fork and its + // exports.exclude.json entry (CONTRIBUTING, "Forks of Astryx components"). + it("still differs from lab's, which hides and re-promotes it", () => { + render( + <StrictMode> + <OneStepTour + TourImpl={UpstreamTour} + StepImpl={UpstreamTourStep as typeof TourStep} + hasBackdrop + /> + </StrictMode>, + ); + expect(overlayDemotions()).toBeGreaterThan(0); + expect(overlayPromotions()).toBeGreaterThan(1); + }); +}); + +describe("Tour fork: parity with lab", () => { + it.each([false, true])("renders lab's markup (backdrop: %s)", (hasBackdrop) => { + const fork = render( + <OneStepTour TourImpl={Tour} StepImpl={TourStep} hasBackdrop={hasBackdrop} />, + ); + const upstream = render( + <OneStepTour + TourImpl={UpstreamTour} + StepImpl={UpstreamTourStep as typeof TourStep} + hasBackdrop={hasBackdrop} + />, + ); + expect(comparableMarkup(fork.container)).toBe(comparableMarkup(upstream.container)); + // The callout renders in a portal, outside the render containers. + const callouts = screen.getAllByText("Changes save automatically."); + expect(callouts).toHaveLength(2); + const [forkCallout, upstreamCallout] = callouts.map( + (el) => el.closest('[role="dialog"]') ?? el.parentElement!, + ); + expect(comparableMarkup(forkCallout!)).toBe(comparableMarkup(upstreamCallout!)); + }); +}); diff --git a/src/forks/Tour/Tour.test.tsx b/src/forks/Tour/Tour.test.tsx new file mode 100644 index 0000000..2b8e56a --- /dev/null +++ b/src/forks/Tour/Tour.test.tsx @@ -0,0 +1,266 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. +// Modifications copyright (c) Lablup Inc. +// +// Forked from @astryxdesign/lab 0.6.2-canary.c9fb1ad, src/Tour/Tour.test.tsx +// (MIT; see NOTICE). Upstream's tests, run against the fork; ui-common's own tests are +// in the *.fork.test.tsx beside it. +// Provenance and the drift guard: src/forks/provenance.json. + +/** + * @file Tour.test.tsx + * @input Uses vitest, @testing-library/react, @testing-library/user-event, Tour + TourStep + * @output Unit tests for the Tour controller: step registration/order, advance, + * dismiss reasons, reset, backdrop, and useTour. + * @position Testing; validates Tour.tsx / TourStep.tsx behavior + * + * SYNC: When Tour.tsx / TourStep.tsx change, update tests to match new behavior + */ + +import { describe, it, expect, vi, beforeAll } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { useRef, useState } from "react"; +import { Tour } from "./Tour"; +import { TourStep } from "./TourStep"; +import { useTour } from "./useTour"; + +// jsdom does not implement <dialog> showModal/close, which the Popover the +// steps render on relies on. +beforeAll(() => { + HTMLDialogElement.prototype.showModal = vi.fn(function (this: HTMLDialogElement) { + this.setAttribute("open", ""); + }); + HTMLDialogElement.prototype.close = vi.fn(function (this: HTMLDialogElement) { + this.removeAttribute("open"); + }); +}); + +// A small harness: two targets + a two-step tour, isActive controlled locally +// so onDismiss can turn it off like a real consumer. +function TwoStepTour({ + hasBackdrop = false, + isStepCountShown = false, + onDismiss, +}: { + hasBackdrop?: boolean; + isStepCountShown?: boolean; + onDismiss?: (source: string) => void; +}) { + const [isActive, setIsActive] = useState(true); + const aRef = useRef<HTMLButtonElement>(null); + const bRef = useRef<HTMLButtonElement>(null); + return ( + <> + <button type="button" ref={aRef}> + Target A + </button> + <button type="button" ref={bRef}> + Target B + </button> + <Tour + isActive={isActive} + hasBackdrop={hasBackdrop} + isStepCountShown={isStepCountShown} + onDismiss={(source) => { + onDismiss?.(source); + if (source !== "skip") { + setIsActive(false); + } + }} + > + <TourStep targetRef={aRef} heading="First"> + First body + </TourStep> + <TourStep targetRef={bRef} heading="Second"> + Second body + </TourStep> + </Tour> + </> + ); +} + +describe("Tour", () => { + it("shows only the first step initially", () => { + render(<TwoStepTour />); + expect(screen.getByText("First")).toBeInTheDocument(); + expect(screen.queryByText("Second")).not.toBeInTheDocument(); + }); + + it("advances to the next step on Next", () => { + render(<TwoStepTour />); + fireEvent.click(screen.getByText("Next")); + expect(screen.queryByText("First")).not.toBeInTheDocument(); + expect(screen.getByText("Second")).toBeInTheDocument(); + }); + + it("goes back on Back", () => { + render(<TwoStepTour />); + fireEvent.click(screen.getByText("Next")); + fireEvent.click(screen.getByText("Back")); + expect(screen.getByText("First")).toBeInTheDocument(); + }); + + it("hides Back on the first step", () => { + render(<TwoStepTour />); + expect(screen.queryByText("Back")).not.toBeInTheDocument(); + }); + + it('labels the final step action "Done" and earlier steps "Next"', () => { + render(<TwoStepTour />); + expect(screen.getByText("Next")).toBeInTheDocument(); + fireEvent.click(screen.getByText("Next")); + expect(screen.getByText("Done")).toBeInTheDocument(); + }); + + it('dismisses with "complete" when advancing past the last step', () => { + const onDismiss = vi.fn(); + render(<TwoStepTour onDismiss={onDismiss} />); + fireEvent.click(screen.getByText("Next")); // → step 2 + fireEvent.click(screen.getByText("Done")); // → complete + expect(onDismiss).toHaveBeenCalledWith("complete"); + // Tour turned off — no step content remains. + expect(screen.queryByText("First")).not.toBeInTheDocument(); + expect(screen.queryByText("Second")).not.toBeInTheDocument(); + }); + + it("shows step count when isStepCountShown is set", () => { + render(<TwoStepTour isStepCountShown />); + expect(screen.getByText("1 of 2")).toBeInTheDocument(); + fireEvent.click(screen.getByText("Next")); + expect(screen.getByText("2 of 2")).toBeInTheDocument(); + }); + + it("does not show step count by default", () => { + render(<TwoStepTour />); + expect(screen.queryByText("1 of 2")).not.toBeInTheDocument(); + }); + + it("renders a highlight overlay for the active step; backdrop only when set", () => { + const { rerender } = render(<TwoStepTour />); + // Highlight always present for the active step. + expect(screen.getByTestId("tour-highlight")).toBeInTheDocument(); + // No dim without hasBackdrop — a plain coachmark rings the target only. + expect(screen.queryByTestId("tour-backdrop")).not.toBeInTheDocument(); + rerender(<TwoStepTour hasBackdrop />); + expect(screen.getByTestId("tour-highlight")).toBeInTheDocument(); + expect(screen.getByTestId("tour-backdrop")).toBeInTheDocument(); + }); + + it('dismisses with "backdrop" when the backdrop is clicked', () => { + const onDismiss = vi.fn(); + render(<TwoStepTour hasBackdrop onDismiss={onDismiss} />); + fireEvent.click(screen.getByTestId("tour-backdrop")); + expect(onDismiss).toHaveBeenCalledWith("backdrop"); + }); + + it("does not restyle the target element (highlight is a separate overlay)", () => { + render(<TwoStepTour hasBackdrop />); + // The consumer's targets keep their own classes untouched — the ring is + // drawn by the overlay, not by mutating the target (which loses the + // cascade to the target's own styles). + expect(screen.getByText("Target A").className).toBe(""); + expect(screen.getByText("Target B").className).toBe(""); + }); + + it("moves the highlight across steps and clears it when the tour ends", () => { + render(<TwoStepTour hasBackdrop />); + expect(screen.getByTestId("tour-highlight")).toBeInTheDocument(); + fireEvent.click(screen.getByText("Next")); // → last step + expect(screen.getByTestId("tour-highlight")).toBeInTheDocument(); + fireEvent.click(screen.getByText("Done")); // → complete, tour off + expect(screen.queryByTestId("tour-highlight")).not.toBeInTheDocument(); + expect(screen.queryByTestId("tour-backdrop")).not.toBeInTheDocument(); + }); + + it("renders nothing when isActive is false", () => { + function Inactive() { + const aRef = useRef<HTMLButtonElement>(null); + return ( + <> + <button type="button" ref={aRef}> + T + </button> + <Tour isActive={false} onDismiss={() => {}}> + <TourStep targetRef={aRef} heading="Hidden"> + Body + </TourStep> + </Tour> + </> + ); + } + render(<Inactive />); + expect(screen.queryByText("Hidden")).not.toBeInTheDocument(); + }); + + it("a TourStep outside a Tour renders nothing", () => { + function Orphan() { + const ref = useRef<HTMLButtonElement>(null); + return ( + <> + <button type="button" ref={ref}> + T + </button> + <TourStep targetRef={ref} heading="Orphan"> + Body + </TourStep> + </> + ); + } + render(<Orphan />); + expect(screen.queryByText("Orphan")).not.toBeInTheDocument(); + }); +}); + +describe("useTour", () => { + it("returns null outside a Tour", () => { + let value: ReturnType<typeof useTour> = {} as ReturnType<typeof useTour>; + function Probe() { + value = useTour(); + return null; + } + render(<Probe />); + expect(value).toBeNull(); + }); + + it("reports step position and first/last inside a Tour", () => { + const seen: Array<{ index: number; first: boolean; last: boolean }> = []; + function Probe() { + const t = useTour(); + if (t) { + seen.push({ + index: t.activeStepIndex, + first: t.isFirstStep, + last: t.isLastStep, + }); + } + return null; + } + function Harness() { + const aRef = useRef<HTMLButtonElement>(null); + const bRef = useRef<HTMLButtonElement>(null); + return ( + <> + <button type="button" ref={aRef}> + A + </button> + <button type="button" ref={bRef}> + B + </button> + <Tour isActive onDismiss={() => {}}> + <Probe /> + <TourStep targetRef={aRef} heading="A"> + a + </TourStep> + <TourStep targetRef={bRef} heading="B"> + b + </TourStep> + </Tour> + </> + ); + } + render(<Harness />); + const last = seen[seen.length - 1]!; + expect(last.index).toBe(0); + expect(last.first).toBe(true); + expect(last.last).toBe(false); + }); +}); diff --git a/src/forks/Tour/Tour.tsx b/src/forks/Tour/Tour.tsx new file mode 100644 index 0000000..a77f5ec --- /dev/null +++ b/src/forks/Tour/Tour.tsx @@ -0,0 +1,173 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. +// +// Forked from @astryxdesign/lab 0.6.2-canary.c9fb1ad, src/Tour/Tour.tsx +// (MIT; see NOTICE). Unchanged: forked with TourStep, whose context it provides. +// Provenance and the drift guard: src/forks/provenance.json. + +"use client"; + +/** + * @file Tour.tsx + * @input Uses React state/refs, TourContext + * @output Exports Tour controller component and TourProps + * @position Lab experiment (facebook/astryx#4239); controller consumed by index.ts + * + * Tour is the controller for a product-tour / NUX walkthrough. It renders no + * visible chrome of its own — it owns the tour state (active step, advance / + * retreat / complete / dismiss) and shares it with declaratively-nested + * `<TourStep>` children through context. Steps register on mount, so the step + * ORDER is taken from the children in document order (no step array to keep in + * sync with the markup). + * + * The behavior (a controller plus spotlight feature steps, an `isActive` + * switch, dismiss-with-reason, and step progress) is composed from Astryx + * primitives. Each step anchors its callout via Popover and draws its own + * highlight/backdrop (see TourStep) — the controller owns no chrome. + * + * SYNC: When modified, update these files to stay in sync: + * - /packages/lab/src/Tour/TourStep.tsx + * - /packages/lab/src/Tour/TourContext.ts + * - /packages/lab/src/Tour/useTour.ts + * - /packages/lab/src/Tour/Tour.doc.mjs (props table, features) + * - /packages/lab/src/Tour/Tour.test.tsx (tests for new/changed behavior) + * - /packages/lab/src/Tour/index.ts (exports if types change) + */ + +import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; +import { + TourContext, + type TourDismissSource, + type TourContextValue, +} from "./TourContext"; + +export interface TourProps { + /** + * Whether the tour is running. When false, nothing renders and step state + * resets — the tour restarts from the first step next time it becomes active. + * (Controlled: the consumer owns "has this user seen the tour?".) + */ + isActive: boolean; + /** + * The tour's steps — `<TourStep>` elements. Step order is taken from their + * order here; only the active step renders its callout. + */ + children?: ReactNode; + /** + * Called when the tour is dismissed, with the reason. Fires for every exit, + * including completing the last step (`source === 'complete'`). The consumer + * flips `isActive` to false in response, and can branch on the source to tell + * a successful finish apart from an early skip/backdrop/escape. + */ + onDismiss: (source: TourDismissSource) => void; + /** + * Dim the page around the active step's target (a spotlight cutout — the + * target stays lit, everything else darkens). Use for modal-style steps that + * demand focus; leave off for a lightweight coachmark that only rings the + * target. + * @default false + */ + hasBackdrop?: boolean; + /** + * Show the step count ("2 of 5") in each step. + * @default false + */ + isStepCountShown?: boolean; +} + +/** + * Controller for a guided product tour. Renders no chrome; coordinates the + * active step among its `<TourStep>` children. + * + * @example + * ``` + * const [isActive, setIsActive] = useState(true); + * const saveRef = useRef(null); + * const shareRef = useRef(null); + * <Tour isActive={isActive} hasBackdrop isStepCountShown onDismiss={() => setIsActive(false)}> + * <TourStep targetRef={saveRef} heading="Save your work"> + * Changes save automatically to the cloud. + * </TourStep> + * <TourStep targetRef={shareRef} heading="Share it"> + * Invite teammates from here. + * </TourStep> + * </Tour> + * ``` + */ +export function Tour({ + isActive, + children, + onDismiss, + hasBackdrop = false, + isStepCountShown = false, +}: TourProps) { + // Steps register on mount; insertion order (document order) defines the + // sequence. A plain array keeps registration order deterministic. + const [stepIds, setStepIds] = useState<string[]>([]); + const [activeStepIndex, setActiveStepIndex] = useState(0); + + const registerStep = useCallback((id: string) => { + setStepIds((prev) => (prev.includes(id) ? prev : [...prev, id])); + return () => { + setStepIds((prev) => prev.filter((stepId) => stepId !== id)); + }; + }, []); + + // Reset to the first step whenever the tour is (re)started, so a dismissed + // tour begins from the top next time isActive flips back on. + useEffect(() => { + if (!isActive) { + setActiveStepIndex(0); + } + }, [isActive]); + + const stepCount = stepIds.length; + + const onNext = useCallback(() => { + // Runs from a click/imperative call (never during render), so branch on the + // current index directly rather than inside a state updater — calling + // onDismiss from within setState triggers a "setState while rendering" + // warning. + if (activeStepIndex < stepCount - 1) { + setActiveStepIndex(activeStepIndex + 1); + return; + } + // Past the last step → complete. onDismiss carries the reason so a + // consumer can branch on 'complete' vs an early exit. + onDismiss("complete"); + }, [activeStepIndex, stepCount, onDismiss]); + + const onPrevious = useCallback(() => { + setActiveStepIndex((prev) => (prev > 0 ? prev - 1 : prev)); + }, []); + + const activeStepId = isActive ? (stepIds[activeStepIndex] ?? null) : null; + + const contextValue = useMemo<TourContextValue>( + () => ({ + registerStep, + activeStepId, + activeStepIndex, + stepCount, + isStepCountShown, + hasBackdrop, + onNext, + onPrevious, + onDismiss, + }), + [ + registerStep, + activeStepId, + activeStepIndex, + stepCount, + isStepCountShown, + hasBackdrop, + onNext, + onPrevious, + onDismiss, + ], + ); + + return <TourContext.Provider value={contextValue}>{children}</TourContext.Provider>; +} + +Tour.displayName = "Tour"; diff --git a/src/forks/Tour/TourContext.ts b/src/forks/Tour/TourContext.ts new file mode 100644 index 0000000..967a708 --- /dev/null +++ b/src/forks/Tour/TourContext.ts @@ -0,0 +1,62 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. +// +// Forked from @astryxdesign/lab 0.6.2-canary.c9fb1ad, src/Tour/TourContext.ts +// (MIT; see NOTICE). Unchanged: forked with TourStep, which reads it. +// Provenance and the drift guard: src/forks/provenance.json. + +"use client"; + +/** + * @file TourContext.ts + * @input Uses React createContext + * @output Exports TourContext, TourContextValue, TourDismissSource + * @position Internal context; provided by Tour, consumed by TourStep + useTour + * + * SYNC: When modified, update these files to stay in sync: + * - /packages/lab/src/Tour/Tour.tsx + * - /packages/lab/src/Tour/TourStep.tsx + * - /packages/lab/src/Tour/useTour.ts + * - /packages/lab/src/Tour/Tour.doc.mjs + */ + +import { createContext } from "react"; + +/** + * Why a tour was dismissed. Lets a consumer distinguish "finished the whole + * tour" from the various early exits, so it can persist "has seen this tour" + * appropriately. + * - `backdrop`: clicked the dimmed background + * - `escape`: pressed Escape at any step + * - `close`: pressed the step's close (X) control + * - `skip`: chose "skip"/"maybe later" on the first step + * - `complete`: advanced past the final step + */ +export type TourDismissSource = "backdrop" | "escape" | "close" | "skip" | "complete"; + +/** + * Value shared from the Tour controller to its steps. Steps register + * themselves on mount (so the controller learns the step order from the + * children, in document order) and read whether they are the active step. + */ +export interface TourContextValue { + /** Register a step by its stable id; returns an unregister cleanup. */ + registerStep: (id: string) => () => void; + /** The id of the step that is currently active, or null when none. */ + activeStepId: string | null; + /** Zero-based index of the active step among registered steps. */ + activeStepIndex: number; + /** Total number of registered steps. */ + stepCount: number; + /** Whether the tour's step count should be shown in each step. */ + isStepCountShown: boolean; + /** Whether a dimmed background is shown behind the active step. */ + hasBackdrop: boolean; + /** Advance to the next step (or complete on the last step). */ + onNext: () => void; + /** Return to the previous step (no-op on the first step). */ + onPrevious: () => void; + /** Dismiss the tour with a reason. */ + onDismiss: (source: TourDismissSource) => void; +} + +export const TourContext = createContext<TourContextValue | null>(null); diff --git a/src/forks/Tour/TourStep.styles.ts b/src/forks/Tour/TourStep.styles.ts new file mode 100644 index 0000000..92dd112 --- /dev/null +++ b/src/forks/Tour/TourStep.styles.ts @@ -0,0 +1,33 @@ +/* eslint-disable */ +// @ts-nocheck +// @generated by scripts/sync-forks.mjs. Do not edit. +// +// Compiled StyleX from @astryxdesign/lab 0.6.2-canary.c9fb1ad, reused by the +// Tour fork so it renders with upstream's own class names +// (src/forks/compiled.ts). Copyright (c) Meta Platforms, Inc. and +// affiliates; MIT, see NOTICE. + +// dist/Tour/TourStep.js +export const styles = { + footer: { + kGNEyG: "x6s0dn4", + kjj79g: "x1qughib", + $$css: true, + }, + calloutGapBelow: { + keoZOQ: "xtbrsbv", + $$css: true, + }, + calloutGapAbove: { + k1K539: "x1p37lm5", + $$css: true, + }, + calloutGapStart: { + k71WvV: "x1pezmd8", + $$css: true, + }, + calloutGapEnd: { + keTefX: "x1cmpsy9", + $$css: true, + }, +}; diff --git a/src/forks/Tour/TourStep.tsx b/src/forks/Tour/TourStep.tsx new file mode 100644 index 0000000..ee1dfd6 --- /dev/null +++ b/src/forks/Tour/TourStep.tsx @@ -0,0 +1,360 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. +// Modifications copyright (c) Lablup Inc. +// +// Forked from @astryxdesign/lab 0.6.2-canary.c9fb1ad, src/Tour/TourStep.tsx +// (MIT; see NOTICE). Provenance and the drift guard: src/forks/provenance.json. + +"use client"; + +/** + * TourStep, ui-common's copy of Astryx lab's, with one fix applied (not yet + * upstream): the highlight overlay is promoted into the top layer exactly + * once and never hidden and re-shown. Top-layer order is promotion order, so + * the re-promotion React StrictMode's double-invoked effect caused moved the + * overlay ABOVE the callout and painted the spotlight dim over it. Detaching + * the node takes it out of the top layer, so no cleanup is needed. + * + * `Tour`, `TourContext` and `useTour` are forked unchanged with it, since the + * step reads the controller's context. Everything else is upstream's. Its + * style namespace is Astryx's compiled output (src/forks/compiled.ts). + * + * Delete this fork, and its exports.exclude.json entry, once lab ships the + * fix (CONTRIBUTING, "Forks of Astryx components"). + */ + +/* + * Upstream's file notes, kept: + * + * A TourStep highlights a target element and renders a callout anchored to it, + * with a heading, body, optional step progress ("2 of 5"), and back / next / + * close controls. It registers with the parent `<Tour>` on mount (so step + * order follows the children) and only renders its callout while it is the + * active step. Anchoring + the callout surface reuse the core `Popover` + * (`anchorRef` → the step's target), so positioning, top-layer rendering, and + * dismiss semantics come from the existing layer system rather than a bespoke + * implementation. + * + * The highlight is drawn as a separate overlay tracking the target's box — the + * consumer's element is never restyled, so there is no cascade fight with the + * target's own styles. That overlay is promoted into the browser TOP LAYER + * (via the popover API, like the rest of the layer system) so it sits above + * page content without any hardcoded z-index; the callout is promoted after + * it, so the callout stays above the highlight. Because it is promoted in + * place (not portaled out of the tree), it stays inside the consumer's Theme + * subtree and inherits theme tokens — including a scoped/nested theme's accent + * for the ring. Dimming is an opt-in spotlight cutout (dims around the target, + * not over it) rather than a flat scrim. + */ + +import { + useContext, + useEffect, + useId, + useLayoutEffect, + useRef, + useState, + type ReactNode, +} from "react"; +import { Popover } from "@astryxdesign/core/Popover"; +import type { LayerAlignment, LayerPlacement } from "@astryxdesign/core/Layer"; +import { Button } from "@astryxdesign/core/Button"; +import { Text } from "@astryxdesign/core/Text"; +import { Heading } from "@astryxdesign/core/Heading"; +import { VStack, HStack } from "@astryxdesign/core/Layout"; + +import { compiledStyles } from "../compiled"; +import * as compiled from "./TourStep.styles"; +import { TourContext } from "./TourContext"; + +// Client-only layout effect (SSR renders no overlay, so this only runs on the +// client). Kept local since core does not export its isomorphic variant. +const useClientLayoutEffect = + typeof window !== "undefined" ? useLayoutEffect : useEffect; + +// Gap between the target's edge and the highlight ring, in px. +const HIGHLIGHT_PADDING = 4; + +interface TargetRect { + top: number; + left: number; + width: number; + height: number; + radius: string; +} + +// @astryxdesign/lab dist/Tour/TourStep.js `styles`. +const styles = compiledStyles(compiled.styles); + +// Upstream's `overlayRoot`/`overlayInteractive`, `highlight`/`cutout`/`hidden` +// and `content` styles, which its compiler folded into these class lists +// (dist/Tour/TourStep.js). +const OVERLAY_CLASS_NAME = { + coachmark: + "xixxii4 x10a8y8t x1ghz6dp x1717udv xc342km xng3xce xjbqb8w x1rea2x4 x47corl", + backdrop: + "xixxii4 x10a8y8t x1ghz6dp x1717udv xc342km xng3xce xjbqb8w x1rea2x4 x67bb7w", +}; +function highlightClassName(hasBackdrop: boolean, isHidden: boolean): string { + if (hasBackdrop) { + return isHidden + ? "x10l6tqk x47corl x1gfj591 xuedmi6 xlr8y92 xefcaua xg01cxk" + : "x10l6tqk x47corl x1gfj591 xuedmi6 xlr8y92 xefcaua"; + } + return isHidden + ? "x10l6tqk x47corl xr5mita x1gfj591 xuedmi6 xlr8y92 xg01cxk" + : "x10l6tqk x47corl xr5mita x1gfj591 xuedmi6 xlr8y92"; +} +const CONTENT_CLASS_NAME = "x78zum5 xdt5ytf xjcht0a x1va8c73"; + +const calloutGapStyles = { + below: styles.calloutGapBelow, + above: styles.calloutGapAbove, + start: styles.calloutGapStart, + end: styles.calloutGapEnd, +} as const; + +/** + * The highlight overlay: a top-layer box sized to the target, plus an optional + * spotlight-cutout dim. Rendered inline (not portaled) so it stays inside the + * consumer's Theme subtree and inherits theme tokens — the popover API promotes + * it into the top layer in place. Rendered before the callout so the callout + * promotes on top of it; promoted once (never re-promoted) so that ordering + * holds across re-measures. + */ +function TourHighlight({ + rect, + hasBackdrop, + onBackdropClick, +}: { + rect: TargetRect | null; + hasBackdrop: boolean; + onBackdropClick: () => void; +}) { + const ref = useRef<HTMLDivElement>(null); + + useClientLayoutEffect(() => { + const el = ref.current; + if (el == null || typeof el.showPopover !== "function") { + return; + } + // ui-common: promote exactly once, and never hide-then-reshow: top-layer + // order is promotion order, so a re-promotion (React StrictMode's dev + // double-invoke of this effect) would move the overlay ABOVE the callout + // and paint the spotlight dim over it. Detaching the node drops it from + // the top layer. + if (!el.matches(":popover-open")) { + el.showPopover(); + } + }, []); + + return ( + <div + ref={ref} + popover="manual" + data-testid={hasBackdrop ? "tour-backdrop" : undefined} + aria-hidden="true" + onClick={hasBackdrop ? onBackdropClick : undefined} + className={ + hasBackdrop ? OVERLAY_CLASS_NAME.backdrop : OVERLAY_CLASS_NAME.coachmark + } + > + <div + data-testid="tour-highlight" + className={highlightClassName(hasBackdrop, rect == null)} + style={ + rect != null + ? { + top: rect.top - HIGHLIGHT_PADDING, + left: rect.left - HIGHLIGHT_PADDING, + width: rect.width + HIGHLIGHT_PADDING * 2, + height: rect.height + HIGHLIGHT_PADDING * 2, + borderRadius: rect.radius, + } + : undefined + } + /> + </div> + ); +} + +export interface TourStepProps { + /** + * Ref to the element this step points at. The callout anchors to it (like a + * Popover trigger); it must be a `<button>` or `[role="button"]` element, + * matching Popover's `anchorRef` contract. Accepts a ref to any HTMLElement + * subtype (e.g. `useRef<HTMLButtonElement>(null)`). + */ + targetRef: React.RefObject<HTMLElement | null>; + /** Step heading. */ + heading: ReactNode; + /** Step body content. */ + children?: ReactNode; + /** + * Which side of the target the callout sits on. + * @default 'below' + */ + placement?: LayerPlacement; + /** + * How the callout aligns along the placement side — e.g. with `placement="below"`, + * `start` left-aligns it under the target, `center` centers it, `end` right-aligns it. + * @default 'start' + */ + alignment?: LayerAlignment; + /** Test id applied to the callout content. */ + "data-testid"?: string; +} + +/** + * A single spotlight step within a `<Tour>`. Renders its callout only while + * active. + * + * @example + * ``` + * <TourStep targetRef={saveRef} heading="Save your work"> + * Changes save automatically. + * </TourStep> + * ``` + */ +export function TourStep({ + targetRef, + heading, + children, + placement = "below", + alignment = "start", + "data-testid": testId, +}: TourStepProps) { + const tour = useContext(TourContext); + const id = useId(); + const isActiveStep = tour != null && tour.activeStepId === id; + + // The active target's viewport box, tracked so the highlight overlay can sit + // exactly over it. null until measured (or when inactive). + const [rect, setRect] = useState<TargetRect | null>(null); + + // Register with the controller on mount so the tour learns this step (and + // its position among siblings). Unregister on unmount. + useEffect(() => { + if (tour == null) { + return; + } + return tour.registerStep(id); + }, [tour, id]); + + // Track the target's box while this step is active. Re-measure on + // scroll/resize so the highlight stays glued to the target. + useEffect(() => { + const el = targetRef.current; + if (el == null || !isActiveStep) { + setRect(null); + return; + } + const measure = () => { + const box = el.getBoundingClientRect(); + setRect({ + top: box.top, + left: box.left, + width: box.width, + height: box.height, + radius: getComputedStyle(el).borderRadius || "0px", + }); + }; + measure(); + window.addEventListener("scroll", measure, true); + window.addEventListener("resize", measure); + return () => { + window.removeEventListener("scroll", measure, true); + window.removeEventListener("resize", measure); + setRect(null); + }; + }, [targetRef, isActiveStep]); + + // Outside a <Tour>, or when this isn't the active step, render nothing. + if (tour == null || !isActiveStep) { + return null; + } + + const { + activeStepIndex, + stepCount, + isStepCountShown, + hasBackdrop, + onNext, + onPrevious, + onDismiss, + } = tour; + + const isFirstStep = activeStepIndex <= 0; + const isLastStep = stepCount > 0 && activeStepIndex === stepCount - 1; + + const content = ( + <div className={CONTENT_CLASS_NAME} data-testid={testId}> + <VStack gap={1}> + <Heading level={4}>{heading}</Heading> + {children != null && <Text type="body">{children}</Text>} + </VStack> + <HStack gap={2} xstyle={styles.footer}> + {isStepCountShown && stepCount > 0 ? ( + <Text type="supporting" color="secondary"> + {`${activeStepIndex + 1} of ${stepCount}`} + </Text> + ) : ( + <span /> + )} + <HStack gap={2}> + {!isFirstStep && ( + <Button variant="ghost" size="sm" label="Back" onClick={onPrevious} /> + )} + <Button + variant="primary" + size="sm" + label={isLastStep ? "Done" : "Next"} + onClick={onNext} + /> + </HStack> + </HStack> + </div> + ); + + return ( + <> + {/* Rendered before the callout so the callout promotes above it in the + top layer (callout > highlight > page). */} + <TourHighlight + rect={rect} + hasBackdrop={hasBackdrop} + onBackdropClick={() => onDismiss("backdrop")} + /> + <Popover + // Popover types anchorRef as RefObject<HTMLElement>; TourStep accepts a + // nullable ref for ergonomics (useRef<HTMLButtonElement>(null)). Popover + // guards a null `.current` internally, so this widening is safe. + anchorRef={targetRef as React.RefObject<HTMLElement>} + isOpen + onOpenChange={(open) => { + // Popover reports close from light-dismiss (backdrop) or Escape. + // Route it to the tour as a dismissal so the whole tour ends, not + // just this step's popover. Escape and outside-click both surface + // here. + if (!open) { + onDismiss("close"); + } + }} + placement={placement} + alignment={alignment} + // Size the callout to its content instead of matching the target's + // width (Popover's default minWidth: anchor-size(width) makes it span a + // wide target); the content's own maxWidth caps it. + width="fit-content" + // Push the callout clear of the highlight ring so the ring is never + // covered (placement-aware; adds to Popover's own anchor gap). + xstyle={calloutGapStyles[placement]} + label={typeof heading === "string" ? heading : "Tour step"} + hasCloseButton + closeButtonLabel="Close tour" + content={content} + /> + </> + ); +} + +TourStep.displayName = "TourStep"; diff --git a/src/forks/Tour/index.ts b/src/forks/Tour/index.ts new file mode 100644 index 0000000..e8fd4ae --- /dev/null +++ b/src/forks/Tour/index.ts @@ -0,0 +1,14 @@ +/** + * ui-common's copy of Astryx lab's Tour, exported from + * `@lablup/ui-common/lab` in place of lab's (exports.exclude.json). + */ +export { Tour } from "./Tour"; +export type { TourProps } from "./Tour"; + +export { TourStep } from "./TourStep"; +export type { TourStepProps } from "./TourStep"; + +export { useTour } from "./useTour"; +export type { UseTourReturn } from "./useTour"; + +export type { TourDismissSource } from "./TourContext"; diff --git a/src/forks/Tour/useTour.ts b/src/forks/Tour/useTour.ts new file mode 100644 index 0000000..8578b91 --- /dev/null +++ b/src/forks/Tour/useTour.ts @@ -0,0 +1,62 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. +// +// Forked from @astryxdesign/lab 0.6.2-canary.c9fb1ad, src/Tour/useTour.ts +// (MIT; see NOTICE). Unchanged: forked with TourStep, whose context it reads. +// Provenance and the drift guard: src/forks/provenance.json. + +"use client"; + +/** + * @file useTour.ts + * @input Uses React useContext, TourContext + * @output Exports useTour hook and UseTourReturn type + * @position Public hook; reads the nearest Tour controller's state + * + * SYNC: When modified, update these files to stay in sync: + * - /packages/lab/src/Tour/TourContext.ts + * - /packages/lab/src/Tour/Tour.doc.mjs + */ + +import { useContext } from "react"; +import { TourContext, type TourDismissSource } from "./TourContext"; + +/** + * Read-only view of the active Tour's state, for custom step UIs or + * instrumentation. Returns `null` when called outside a `<Tour>`. + */ +export interface UseTourReturn { + /** Zero-based index of the active step. */ + activeStepIndex: number; + /** Total number of registered steps. */ + stepCount: number; + /** Whether the active step is the first one. */ + isFirstStep: boolean; + /** Whether the active step is the last one. */ + isLastStep: boolean; + /** Advance to the next step (or complete on the last step). */ + next: () => void; + /** Return to the previous step. */ + previous: () => void; + /** Dismiss the tour with a reason. */ + dismiss: (source: TourDismissSource) => void; +} + +/** + * Access the current Tour controller state. Must be used inside a `<Tour>`; + * returns `null` otherwise so callers can guard. + */ +export function useTour(): UseTourReturn | null { + const ctx = useContext(TourContext); + if (ctx == null) { + return null; + } + return { + activeStepIndex: ctx.activeStepIndex, + stepCount: ctx.stepCount, + isFirstStep: ctx.activeStepIndex <= 0, + isLastStep: ctx.stepCount > 0 && ctx.activeStepIndex === ctx.stepCount - 1, + next: ctx.onNext, + previous: ctx.onPrevious, + dismiss: ctx.onDismiss, + }; +} diff --git a/src/forks/compiled.ts b/src/forks/compiled.ts new file mode 100644 index 0000000..0415fb1 --- /dev/null +++ b/src/forks/compiled.ts @@ -0,0 +1,22 @@ +/** + * The forks under src/forks/ are Astryx source with upstream fixes applied. + * ui-common has no StyleX compile step, so a fork does not run + * `stylex.create`: each of its style namespaces is the object Astryx's own + * build compiled for the same pinned version, copied from the package's + * `dist/`. Its atomic class names are therefore exactly upstream's, and their + * rules already reach the page through `@lablup/ui-common/astryx.css` (core) + * and `@lablup/ui-common/lab/lab.css` (lab), in Astryx's own layer. A fork + * adds no CSS of its own and cannot drift from the component it replaces + * while the pin holds; `forks.test.ts` fails when the pin moves. + */ +import type { StyleXStyles } from "@stylexjs/stylex"; + +/** One compiled StyleX namespace entry: `{ <property key>: "<classes>", $$css: true }`. */ +export type CompiledStyle = Readonly<Record<string, string | boolean>>; + +/** Types a compiled namespace for `stylex.props` and `xstyle`. */ +export function compiledStyles<K extends string>( + styles: Record<K, CompiledStyle>, +): Record<K, StyleXStyles> { + return styles as unknown as Record<K, StyleXStyles>; +} diff --git a/src/forks/forks.test.ts b/src/forks/forks.test.ts new file mode 100644 index 0000000..d87dcc5 --- /dev/null +++ b/src/forks/forks.test.ts @@ -0,0 +1,69 @@ +/** + * Drift guard for the Astryx forks (CONTRIBUTING, "Forks of Astryx + * components"). + * + * Each fork is Astryx source with a fix applied, and renders with the class + * names Astryx compiled for the version it was taken from. Both only hold + * while that exact version is installed, so an Astryx bump fails here until + * someone re-syncs each fork with the new upstream source (or deletes it, + * once upstream carries the fix) and records the result: + * + * node scripts/sync-forks.mjs --accept + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { beforeAll, describe, expect, it } from "vitest"; + +import { + currentState, + extractConst, + readProvenance, +} from "../../scripts/sync-forks.mjs"; + +const ROOT = join(__dirname, "..", ".."); +const provenance = readProvenance(); +let state: Awaited<ReturnType<typeof currentState>>; + +beforeAll(async () => { + state = await currentState(); +}); + +const RESYNC = + "Astryx moved under a fork. Re-apply the fork's fix to the new upstream source " + + "(or delete the fork if upstream has it), then run `node scripts/sync-forks.mjs --accept`."; + +describe.each(Object.entries(provenance))("fork %s", (fork, entry) => { + it(`was taken from the installed ${entry.package}`, () => { + expect(state[fork]?.version, RESYNC).toBe(entry.version); + }); + + it("was taken from upstream files that have not changed", () => { + expect(state[fork]?.files, RESYNC).toEqual(entry.files); + }); + + it("reuses the compiled styles of that version", () => { + if (!entry.styles) return; + const committed = readFileSync(join(ROOT, entry.styles.file), "utf8"); + expect(committed, "run `node scripts/sync-forks.mjs`").toBe(state[fork]?.styles); + }); +}); + +describe("fork bookkeeping", () => { + it("records every fork exports.customs.json declares, and nothing else", () => { + const customs = JSON.parse( + readFileSync(join(ROOT, "exports.customs.json"), "utf8"), + ) as Array<{ name: string; fork?: string }>; + const forks = customs.filter((c) => c.fork !== undefined); + expect(forks.map((c) => c.name).sort()).toEqual(Object.keys(provenance).sort()); + for (const c of forks) { + expect(c.fork?.startsWith(provenance[c.name]!.package)).toBe(true); + } + }); + + it("extracts a compiled constant, braces inside strings included", () => { + const source = + 'const other = 1;\nexport const styles = {\n a: { k: "x}{", $$css: true }\n};\n'; + expect(extractConst(source, "styles")).toBe('{\n a: { k: "x}{", $$css: true }\n}'); + expect(() => extractConst(source, "missing")).toThrow(/no top-level const/); + }); +}); diff --git a/src/forks/provenance.json b/src/forks/provenance.json new file mode 100644 index 0000000..5c8e782 --- /dev/null +++ b/src/forks/provenance.json @@ -0,0 +1,64 @@ +{ + "ComplexSelector": { + "package": "@astryxdesign/core", + "version": "0.6.2", + "upstream": "https://github.com/facebook/astryx/pull/6362", + "files": { + "src/ComplexSelector/ComplexSelector.tsx": "981e9834c3c2773f1e81ee895b320d1216b2c9671a2253292e816b9a53993abc", + "src/ComplexSelector/index.ts": "c33bc95b12e5c3dc3834469b9e057f66f0cb1979c366f880cf24517c710c4314", + "src/ComplexSelector/ComplexSelector.test.tsx": "ff7d2a1754cb460cf45266e5ea96ab64c9e6b5a61b8cc7a322e8f54a7884a93a", + "src/hooks/useResolvedRequired.ts": "1d66b83d89a343e57ad3d198f96b70ec64ba721503f0be2cdadecc7ec8b113c7", + "dist/ComplexSelector/ComplexSelector.js": "4e89217a6b16d46c11fd3c2d1c1b1fe39883602ce0fd5569f22ffcc89f1fdd5b", + "dist/utils/interactionOverlay.stylex.js": "ac284b71a2e357942caf64b0f689422601b7889c81763e33548bbb41137f2f9b" + }, + "styles": { + "file": "src/forks/ComplexSelector/ComplexSelector.styles.ts", + "from": [ + { "file": "dist/ComplexSelector/ComplexSelector.js", "consts": ["styles"] }, + { + "file": "dist/utils/interactionOverlay.stylex.js", + "consts": ["interactionOverlayStyles"] + } + ] + } + }, + "Drawer": { + "package": "@astryxdesign/lab", + "version": "0.6.2-canary.c9fb1ad", + "upstream": null, + "files": { + "src/Drawer/Drawer.tsx": "f72daf426b140474e79dfc79218238f70310e41893694f06459950ea69cabad3", + "src/Drawer/useDrawerDialogPresence.ts": "46f7452950f7a771d14b65fffad8eeb7081a22caac13b6a5d66e7274f3607f6f", + "src/Drawer/index.ts": "ab1a3401c786dc87f0812db992ab5033433b30c8a8b6b402b133a9a54149a77c", + "src/Drawer/Drawer.test.tsx": "d0634ecdd026aa0e8925324b75bde262b2b297087f44c772c463ba9e03bc499b", + "dist/Drawer/Drawer.js": "8867764a9e747dbd96aeb3168d5f9f960bfa02c3d97c9a11c1db279ecc219fff" + }, + "styles": { + "file": "src/forks/Drawer/Drawer.styles.ts", + "from": [ + { + "file": "dist/Drawer/Drawer.js", + "consts": ["styles", "_temp", "dynamicStyles"] + } + ] + } + }, + "Tour": { + "package": "@astryxdesign/lab", + "version": "0.6.2-canary.c9fb1ad", + "upstream": null, + "files": { + "src/Tour/Tour.tsx": "a379030d01f7e2f0d2b2cdae25aafdeacb6b5dd1c858d5cdb7511d8556735325", + "src/Tour/TourStep.tsx": "8977c221017d9a8b466528f1e9ba3d652308a3446d6d42afbfac25c8401da038", + "src/Tour/TourContext.ts": "638a4cde7e3b10952a04dc1d86de5e2fbc3fff88c7ed12243e30f072458dff36", + "src/Tour/useTour.ts": "431878830f7c070e7520dd8c402e6c8cfab1e5ececefa289b3058791ec0ac49b", + "src/Tour/index.ts": "127c290fb8ed1a977d282e0dd407724c0f133634ec8f38b515dc038f1da2efa8", + "src/Tour/Tour.test.tsx": "1df4a3d933bc4ca06b882864b91d7dfc0c8302fc7dd3ee34a652f83a9fdec867", + "dist/Tour/TourStep.js": "67b192f77652222b3a0eafac98ea1397a224108e0f35d4534efca261a27c64b5" + }, + "styles": { + "file": "src/forks/Tour/TourStep.styles.ts", + "from": [{ "file": "dist/Tour/TourStep.js", "consts": ["styles"] }] + } + } +} diff --git a/src/index.ts b/src/index.ts index 369e033..86b3d5a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,6 +14,11 @@ * Excluded Astryx names re-exported unchanged by their replacement: * DialogHeader, DialogHeaderProps, DialogPosition, DialogPurpose, * DialogVariant. + * + * Astryx names exported from ui-common's own copy of the component: + * ComplexSelector, ComplexSelectorHandle, ComplexSelectorProps, + * ComplexSelectorRenderState, ComplexSelectorSize, ComplexSelectorStatus, + * ComplexSelectorVariant. */ export { @@ -77,7 +82,6 @@ export { CommandPaletteInput, CommandPaletteItem, CommandPaletteList, - ComplexSelector, ContextMenu, ContextMenuCheckboxItem, ContextMenuDivider, @@ -646,12 +650,6 @@ export type { CommandPaletteItemProps, CommandPaletteListProps, CommandPaletteProps, - ComplexSelectorHandle, - ComplexSelectorProps, - ComplexSelectorRenderState, - ComplexSelectorSize, - ComplexSelectorStatus, - ComplexSelectorVariant, ComponentStyleMap, ContainerComponent, ContainerOptions, @@ -1333,6 +1331,17 @@ export type { BulkErrorModalProps } from "./components/BulkErrorModal"; export { ColorPicker, toHexColor } from "./components/ColorPicker"; export type { ColorPickerProps } from "./components/ColorPicker"; +// ComplexSelector: ui-common's own copy, in place of Astryx's. +export { ComplexSelector } from "./forks/ComplexSelector"; +export type { + ComplexSelectorHandle, + ComplexSelectorProps, + ComplexSelectorRenderState, + ComplexSelectorSize, + ComplexSelectorStatus, + ComplexSelectorVariant, +} from "./forks/ComplexSelector"; + // ConfirmPopover export { ConfirmPopover } from "./components/ConfirmPopover"; export type { ConfirmPopoverProps } from "./components/ConfirmPopover"; diff --git a/src/test/forkParity.ts b/src/test/forkParity.ts new file mode 100644 index 0000000..be179d0 --- /dev/null +++ b/src/test/forkParity.ts @@ -0,0 +1,19 @@ +/** + * Markup comparison for the Astryx forks (src/forks/). A fork must render what + * the upstream component renders, class for class, except where its fix + * changes something; these helpers make two renders comparable. + */ + +/** + * `element`'s markup with React's generated ids (`useId`: `_r_1_`, `«r1»`, + * `:r1:`) replaced, since two separate renders never share them. + */ +export function comparableMarkup(element: Element | DocumentFragment): string { + const html = + element instanceof Element + ? element.outerHTML + : Array.from(element.childNodes) + .map((n) => (n instanceof Element ? n.outerHTML : (n.textContent ?? ""))) + .join(""); + return html.replace(/(?:_r_|«r|:r)[0-9a-z]+(?:_|»|:)/g, "<id>"); +} diff --git a/test/cli/cli.test.ts b/test/cli/cli.test.ts index 86323b0..5376dc0 100644 --- a/test/cli/cli.test.ts +++ b/test/cli/cli.test.ts @@ -110,6 +110,26 @@ describe("output rewriting", { timeout: 60_000 }, () => { expect(exclusionNotes("nothing", ["component", "Dialog"])).toHaveLength(1); }); + it("says ui-common's forks come from ui-common, under Astryx's name", () => { + const [note] = exclusionNotes("nothing", ["component", "ComplexSelector"]); + expect(note).toMatch( + /^Note: ComplexSelector \(@lablup\/ui-common\/ComplexSelector\) comes from ui-common: .*same API/, + ); + expect(note).toContain("hasClear"); + expect(note).not.toMatch(/does not export|Use ComplexSelector/); + const lab = exclusionNotes( + "import { Drawer, TourStep } from '@lablup/ui-common/lab'", + ); + expect(lab).toHaveLength(2); + expect(lab[0]).toMatch( + /^Note: Drawer \(@lablup\/ui-common\/lab\) comes from ui-common/, + ); + expect(lab[1]).toMatch( + /^Note: Tour \(@lablup\/ui-common\/lab\) comes from ui-common/, + ); + expect(exclusionNotes("Selector and Tourniquet")).toEqual([]); + }); + it("keeps --json output valid and puts notes on stderr", () => { const stdout = JSON.stringify({ type: "component", @@ -212,6 +232,15 @@ describe("agents", { timeout: 60_000 }, () => { ); expect(block).toContain('@import "@lablup/ui-common/theme/lablup/theme.css";'); expect(block).toContain("Use Modal (@lablup/ui-common/Modal), not Dialog"); + // A fork keeps Astryx's name: no "use X, not X" line, and not listed as + // one of ui-common's own components. + expect(block).toContain( + "- ComplexSelector (@lablup/ui-common/ComplexSelector) comes from ui-common: its own copy of Astryx's, same API and import path.", + ); + expect(block).toContain("- Drawer (@lablup/ui-common/lab) comes from ui-common"); + expect(block).toContain("- Tour (@lablup/ui-common/lab) comes from ui-common"); + expect(block).not.toMatch(/Use (ComplexSelector|Drawer|Tour) /); + expect(block).not.toMatch(/ui-common's own components: [^\n]*\bComplexSelector\b/); expect(block).toContain("uiCommonMessages"); // The integration line ui-common contributes is restated, not duplicated. expect(block).not.toContain("INTEGRATIONS:"); diff --git a/vite.config.ts b/vite.config.ts index 89eeb33..6584a80 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -26,6 +26,10 @@ function entryPoints(): Record<string, string> { const patterns = [ "src/components/*/index.ts", + // ui-common's copies of Astryx components (CONTRIBUTING, "Forks of + // Astryx components"). A core fork has its own subpath; lab's are reached + // through the lab mirror. + "src/forks/**/index.ts", // The generated Astryx mirrors (scripts/gen-exports.mjs). Each one is a // one-line re-export that stays a one-line re-export in dist, because // every @astryxdesign/* specifier is external. From 45ca0899f551558e6eb0cddb002aaf829d7a7b33 Mon Sep 17 00:00:00 2001 From: Jongeun <jongeun@lablup.com> Date: Tue, 29 Sep 2026 10:12:42 +0000 Subject: [PATCH 68/87] chore: prepare 0.2.0-alpha.13 --- CHANGELOG.md | 5 ++++- package.json | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 039f0d3..b5c37d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,8 +5,11 @@ Versioning follows the policy in [CONTRIBUTING.md](CONTRIBUTING.md#versioning). ## [Unreleased] +## [0.2.0-alpha.13] + Astryx fixes a product used to carry as pnpm patches now ship in ui-common, -so its consumers get them without patching. +so its consumers get them without patching. `DataGrid` and `DoubleToken` +take two fixes from the same product. ### Added diff --git a/package.json b/package.json index 1e9c2d9..c15ac50 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@lablup/ui-common", - "version": "0.2.0-alpha.12", + "version": "0.2.0-alpha.13", "description": "Lablup's UI layer on Astryx: the Astryx surface re-exported 1:1, the Lablup theme, and shared components", "license": "Apache-2.0", "author": "Lablup Inc.", From fde887f23ca914298b0edc1f8884c8fa32c44b19 Mon Sep 17 00:00:00 2001 From: Jongeun <jongeun@lablup.com> Date: Tue, 29 Sep 2026 11:03:33 +0000 Subject: [PATCH 69/87] fix(Drawer): route Escape through core's layer-dismissal stack Lab's Drawer handled Escape in its own element-level onKeyDown and preventDefault()ed it before core's document-level layer stack saw the press. A popover, selector or complex selector opened inside the drawer lives in its DOM subtree, so one Escape closed both the layer and the drawer. Nested drawers closed the outer one first, because child effects register before parent effects in the LIFO registry. The fork now joins the stack the way core's Dialog does: useLayerDismissal({ isActive: isOpen, escapeBehavior: 'close' }), content wrapped in LayerDepthProvider, and the native cancel answered only when shouldDismissOnCloseRequest() says this drawer is the top-most layer and no IME composition is running. The LIFO registry remains for non-modal z-indexes only. A non-modal drawer now closes on Escape wherever focus is, as the top-most layer, instead of only while focus is inside it; the one upstream test that asserted the old rule is changed and marked. Recorded as a deliberate divergence in provenance.json (notes) and in CONTRIBUTING's fork section. --- CONTRIBUTING.md | 19 ++- scripts/sync-forks.mjs | 2 +- src/forks/Drawer/Drawer.fork.test.tsx | 191 ++++++++++++++++++++++---- src/forks/Drawer/Drawer.test.tsx | 15 +- src/forks/Drawer/Drawer.tsx | 90 +++++------- src/forks/provenance.json | 1 + 6 files changed, 226 insertions(+), 92 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 72022a9..2a4543e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -100,11 +100,20 @@ under Astryx's own name and import path until Astryx ships the fix. It exists because a product's pnpm `patchedDependencies` never reach that product's consumers, and ui-common's consumers import Astryx through ui-common. -| Fork | Where | Fix | Upstream | -| ----------------- | ----------------------------------- | ------------------------------------------------------------------------------------ | -------------------------------------------------------------------- | -| `ComplexSelector` | `@lablup/ui-common/ComplexSelector` | `hasClear` / `onClear` | [facebook/astryx#6362](https://github.com/facebook/astryx/pull/6362) | -| `Drawer` | `@lablup/ui-common/lab` | Escape stays inside (portalled layers, IME composition); `aria-modal` passes through | not filed | -| `Tour` | `@lablup/ui-common/lab` | a step's highlight is promoted once (StrictMode) | not filed | +| Fork | Where | Fix | Upstream | +| ----------------- | ----------------------------------- | ----------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | +| `ComplexSelector` | `@lablup/ui-common/ComplexSelector` | `hasClear` / `onClear` | [facebook/astryx#6362](https://github.com/facebook/astryx/pull/6362) | +| `Drawer` | `@lablup/ui-common/lab` | Escape goes through core's layer-dismissal stack (see below); `aria-modal` passes through | not filed | +| `Tour` | `@lablup/ui-common/lab` | a step's highlight is promoted once (StrictMode) | not filed | + +`Drawer` is more than a fix: lab's drawer handles Escape itself, ahead of +core's layer-dismissal stack, so an Escape in a popover, selector or modal +inside it closed the drawer too. The fork registers with the stack through +`useLayerDismissal`, as core's `Dialog` does, so one press closes only the +top-most layer. A deliberate consequence: a non-modal drawer closes on Escape +wherever focus is, not only while focus is inside it. Keep this change on a +bump until lab's `Drawer` joins the stack itself (its "still differs from +lab's" test fails then); `provenance.json` records it as `notes`. How one is put together: diff --git a/scripts/sync-forks.mjs b/scripts/sync-forks.mjs index 9b28ed4..2ec5aa3 100644 --- a/scripts/sync-forks.mjs +++ b/scripts/sync-forks.mjs @@ -33,7 +33,7 @@ export const PROVENANCE = "src/forks/provenance.json"; const GENERATED_HEADER = "@generated by scripts/sync-forks.mjs. Do not edit."; -/** @returns {Record<string, {package: string, version: string, upstream: string, files: Record<string,string>, styles?: {file: string, from: Array<{file: string, consts: string[]}>}}>} */ +/** @returns {Record<string, {package: string, version: string, upstream: string, notes?: string, files: Record<string,string>, styles?: {file: string, from: Array<{file: string, consts: string[]}>}}>} */ export function readProvenance() { return JSON.parse(readFileSync(join(root, PROVENANCE), "utf8")); } diff --git a/src/forks/Drawer/Drawer.fork.test.tsx b/src/forks/Drawer/Drawer.fork.test.tsx index fcb87f5..e838e12 100644 --- a/src/forks/Drawer/Drawer.fork.test.tsx +++ b/src/forks/Drawer/Drawer.fork.test.tsx @@ -1,14 +1,19 @@ /** - * ui-common's tests for its Drawer fork: the fixes it carries (Escape - * containment, `aria-modal` passthrough), and that everything else renders + * ui-common's tests for its Drawer fork: the changes it carries (Escape + * through core's layer-dismissal stack, `aria-modal` passthrough), and that everything else renders * exactly as lab's does. */ import { useState } from "react"; import { createPortal } from "react-dom"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { fireEvent, render, screen } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { Popover } from "@astryxdesign/core/Popover"; +import { Selector } from "@astryxdesign/core/Selector"; import { Drawer as UpstreamDrawer } from "@astryxdesign/lab"; +import { Modal } from "../../components/Modal"; +import { ComplexSelector } from "../ComplexSelector"; import { comparableMarkup } from "../../test/forkParity"; import { Drawer } from "./Drawer"; @@ -46,26 +51,127 @@ function renderDrawer(DrawerImpl: typeof Drawer, onOpenChange = vi.fn()) { return onOpenChange; } -describe("Drawer fork: Escape containment", () => { - it("still closes on an Escape from inside the drawer", () => { +/** Mock the Popover API, which jsdom does not implement. */ +function mockPopoverApi() { + const originalMatches = HTMLElement.prototype.matches; + HTMLElement.prototype.showPopover = vi.fn(function (this: HTMLElement) { + this.setAttribute("popover-open", ""); + const event = new Event("toggle"); + Object.defineProperty(event, "newState", { value: "open" }); + this.dispatchEvent(event); + }); + HTMLElement.prototype.hidePopover = vi.fn(function (this: HTMLElement) { + this.removeAttribute("popover-open"); + const event = new Event("toggle"); + Object.defineProperty(event, "newState", { value: "closed" }); + this.dispatchEvent(event); + }); + vi.spyOn(HTMLElement.prototype, "matches").mockImplementation(function ( + this: HTMLElement, + selector: string, + ) { + if (selector === ":popover-open") return this.hasAttribute("popover-open"); + return originalMatches.call(this, selector); + }); +} + +/** A drawer holding a Popover, a Selector and a ComplexSelector, each closable on its own. */ +function DrawerWithLayers({ impl: Impl = Drawer }: { impl?: typeof Drawer }) { + const [isDrawerOpen, setDrawerOpen] = useState(true); + const [isPopoverOpen, setPopoverOpen] = useState(false); + const [fruit, setFruit] = useState<string | undefined>(undefined); + return ( + <Impl isOpen={isDrawerOpen} onOpenChange={setDrawerOpen} label="Details"> + <Popover + isOpen={isPopoverOpen} + onOpenChange={setPopoverOpen} + label="Filters" + content={<input aria-label="Popover field" />} + > + <button type="button">Open filters</button> + </Popover> + <Selector + label="Fruit" + value={fruit} + onChange={setFruit} + options={[ + { value: "apple", label: "Apple" }, + { value: "banana", label: "Banana" }, + ]} + /> + <ComplexSelector label="Owner" value="alice" triggerLabel="Alice"> + {() => <input aria-label="Owner search" />} + </ComplexSelector> + </Impl> + ); +} + +const drawerIsOpen = () => + document.querySelector('dialog[aria-label="Details"]')!.hasAttribute("open"); + +describe("Drawer fork: Escape goes through core's layer-dismissal stack", () => { + beforeEach(mockPopoverApi); + + it("closes on an Escape from inside the drawer", () => { const onOpenChange = renderDrawer(Drawer); fireEvent.keyDown(screen.getByLabelText("Drawer field"), { key: "Escape" }); expect(onOpenChange).toHaveBeenCalledExactlyOnceWith(false); }); - it("ignores an Escape from a layer portalled out of its children", () => { - const onOpenChange = renderDrawer(Drawer); - const layerInput = screen.getByTestId("layer-input"); - expect(screen.getByRole("dialog").contains(layerInput)).toBe(false); - const event = new KeyboardEvent("keydown", { - key: "Escape", - bubbles: true, - cancelable: true, - }); - layerInput.dispatchEvent(event); - expect(onOpenChange).not.toHaveBeenCalled(); - // Left for the layer's own dismissal to act on. - expect(event.defaultPrevented).toBe(false); + it("closes only an open Popover inside it, then itself on a second Escape", async () => { + const user = userEvent.setup(); + render(<DrawerWithLayers />); + const trigger = screen.getByRole("button", { name: "Open filters" }); + await user.click(trigger); + expect(trigger).toHaveAttribute("aria-expanded", "true"); + (await screen.findByLabelText("Popover field")).focus(); + await user.keyboard("{Escape}"); + expect(trigger).toHaveAttribute("aria-expanded", "false"); + expect(drawerIsOpen()).toBe(true); + await user.keyboard("{Escape}"); + await waitFor(() => expect(drawerIsOpen()).toBe(false)); + }); + + it("closes only an open Selector inside it, then itself on a second Escape", async () => { + const user = userEvent.setup(); + render(<DrawerWithLayers />); + const trigger = screen.getByRole("combobox", { name: /Fruit/ }); + await user.click(trigger); + expect(trigger).toHaveAttribute("aria-expanded", "true"); + await user.keyboard("{Escape}"); + expect(trigger).toHaveAttribute("aria-expanded", "false"); + expect(drawerIsOpen()).toBe(true); + await user.keyboard("{Escape}"); + await waitFor(() => expect(drawerIsOpen()).toBe(false)); + }); + + it("closes only an open ComplexSelector inside it, then itself on a second Escape", async () => { + const user = userEvent.setup(); + render(<DrawerWithLayers />); + const trigger = screen.getByRole("button", { name: "Owner" }); + await user.click(trigger); + expect(trigger).toHaveAttribute("aria-expanded", "true"); + (await screen.findByLabelText("Owner search")).focus(); + await user.keyboard("{Escape}"); + expect(trigger).toHaveAttribute("aria-expanded", "false"); + expect(drawerIsOpen()).toBe(true); + await user.keyboard("{Escape}"); + await waitFor(() => expect(drawerIsOpen()).toBe(false)); + }); + + it("closes nested drawers top-first", () => { + const closeOuter = vi.fn(); + const closeInner = vi.fn(); + render( + <Drawer isOpen onOpenChange={closeOuter} label="Outer"> + <Drawer isOpen onOpenChange={closeInner} label="Inner"> + <input aria-label="Inner field" /> + </Drawer> + </Drawer>, + ); + fireEvent.keyDown(screen.getByLabelText("Inner field"), { key: "Escape" }); + expect(closeInner).toHaveBeenCalledExactlyOnceWith(false); + expect(closeOuter).not.toHaveBeenCalled(); }); it("ignores an Escape that ends an IME composition", () => { @@ -77,12 +183,49 @@ describe("Drawer fork: Escape containment", () => { expect(onOpenChange).not.toHaveBeenCalled(); }); - // When these fail, lab has shipped the fix: delete the fork and its - // exports.exclude.json entry (CONTRIBUTING, "Forks of Astryx components"). - it("still differs from lab's, which acts on the portalled Escape", () => { - const onOpenChange = renderDrawer(UpstreamDrawer); - fireEvent.keyDown(screen.getByTestId("layer-input"), { key: "Escape" }); - expect(onOpenChange).toHaveBeenCalledWith(false); + it("answers a native close request only while it is the top layer", () => { + const closeOuter = vi.fn(); + render( + <Drawer isOpen onOpenChange={closeOuter} label="Outer"> + <Drawer isOpen onOpenChange={() => {}} label="Inner"> + Inner + </Drawer> + </Drawer>, + ); + const cancel = new Event("cancel", { cancelable: true }); + fireEvent(screen.getByRole("dialog", { name: "Outer" }), cancel); + expect(cancel.defaultPrevented).toBe(true); + expect(closeOuter).not.toHaveBeenCalled(); + }); + + it("leaves an Escape in a modal portalled out of it to that modal", async () => { + const closeDrawer = vi.fn(); + const closeModal = vi.fn(); + render( + <Drawer isOpen onOpenChange={closeDrawer} label="Details"> + <Modal isOpen onOpenChange={closeModal} title="Edit"> + <input aria-label="Modal field" /> + </Modal> + </Drawer>, + ); + const field = await screen.findByLabelText("Modal field"); + expect( + screen.getByRole("dialog", { name: "Details", hidden: true }).contains(field), + ).toBe(false); + fireEvent.keyDown(field, { key: "Escape" }); + expect(closeModal).toHaveBeenCalledWith(false); + expect(closeDrawer).not.toHaveBeenCalled(); + }); + + // When this fails, lab has moved Drawer onto the stack: delete the fork's + // Escape change (CONTRIBUTING, "Forks of Astryx components"). + it("still differs from lab's, which closes itself along with the Popover", async () => { + const user = userEvent.setup(); + render(<DrawerWithLayers impl={UpstreamDrawer} />); + await user.click(screen.getByRole("button", { name: "Open filters" })); + (await screen.findByLabelText("Popover field")).focus(); + await user.keyboard("{Escape}"); + await waitFor(() => expect(drawerIsOpen()).toBe(false)); }); }); diff --git a/src/forks/Drawer/Drawer.test.tsx b/src/forks/Drawer/Drawer.test.tsx index 502ac7d..3b9f64d 100644 --- a/src/forks/Drawer/Drawer.test.tsx +++ b/src/forks/Drawer/Drawer.test.tsx @@ -4,6 +4,8 @@ // Forked from @astryxdesign/lab 0.6.2-canary.c9fb1ad, src/Drawer/Drawer.test.tsx // (MIT; see NOTICE). Upstream's tests, run against the fork; ui-common's own tests are // in the *.fork.test.tsx beside it. +// Changed: the LIFO test's Escape in the bottom drawer, which the fork hands to +// the top drawer through core's layer-dismissal stack (marked `ui-common:`). // Provenance and the drift guard: src/forks/provenance.json. /** @@ -503,18 +505,13 @@ describe("Drawer", () => { </>, ); - // Escape inside the first (bottom-of-stack) drawer is ignored. + // ui-common: Escape goes through core's layer-dismissal stack, which + // hands the press to the top-most layer wherever it happened. Upstream + // ignored an Escape inside the bottom drawer instead. fireEvent.keyDown(screen.getByRole("dialog", { name: "First" }), { key: "Escape", }); - expect(closeFirst).not.toHaveBeenCalled(); - expect(closeSecond).not.toHaveBeenCalled(); - - // Escape inside the last-opened drawer closes it. - fireEvent.keyDown(screen.getByRole("dialog", { name: "Second" }), { - key: "Escape", - }); - expect(closeSecond).toHaveBeenCalledWith(false); + expect(closeSecond).toHaveBeenCalledExactlyOnceWith(false); expect(closeFirst).not.toHaveBeenCalled(); }); diff --git a/src/forks/Drawer/Drawer.tsx b/src/forks/Drawer/Drawer.tsx index 162010e..221de9a 100644 --- a/src/forks/Drawer/Drawer.tsx +++ b/src/forks/Drawer/Drawer.tsx @@ -7,17 +7,19 @@ "use client"; /** - * Drawer, ui-common's copy of Astryx lab's, with two fixes applied (not yet - * upstream): + * Drawer, ui-common's copy of Astryx lab's, with two changes (not upstream): * - * - **Escape stays inside.** The drawer's Escape handler acts only on an - * Escape that happened in its own DOM subtree and did not end an IME - * composition. React events follow the React tree, so a layer portalled out - * of the drawer's children (a modal opened from inside it) bubbled its - * Escape through here; acting on it, and preventing its default, made the - * shared layer-dismissal stack stand down, so neither the layer nor the - * drawer closed. An Escape that ends a composition cancels the composition, - * as the layer stack already treats it. + * - **Escape goes through core's layer-dismissal stack.** Lab's drawer runs + * its own element-level Escape handler, which claims the press before the + * stack's document listener sees it, so an Escape in a popover, selector or + * modal opened inside the drawer closed the drawer too (or, for a portalled + * modal, neither). The drawer now registers with `useLayerDismissal` the way + * core's `Dialog` does and wraps its content in `LayerDepthProvider`: one + * press closes exactly the top-most layer, nested drawers close top-first, + * an IME Escape closes nothing, and the native `cancel` answers only while + * this drawer is on top. A non-modal drawer therefore closes on Escape + * wherever focus is, as core's non-modal popovers do, not only while focus + * is inside it. * - **`aria-modal` passes through.** A scrimless drawer is non-modal, but a * consumer that restores modality by hand (its own mask and focus trap) can * now say so; the default is unchanged. @@ -25,10 +27,11 @@ * Everything else is upstream's. Its style namespaces are Astryx's compiled * output (src/forks/compiled.ts). The LIFO drawer registry is module-level, * so this copy stacks with other drawers from this copy only; lab's own - * `Drawer` is not exported by ui-common. + * `Drawer` is not exported by ui-common. Escape no longer consults it: it + * only assigns non-modal z-indexes. * * Delete this fork, and its exports.exclude.json entry, once lab ships both - * fixes (CONTRIBUTING, "Forks of Astryx components"). + * (CONTRIBUTING, "Forks of Astryx components"). */ /* @@ -69,6 +72,7 @@ import type { StyleXStyles } from "@stylexjs/stylex"; import type { BaseProps } from "@astryxdesign/core"; import { Icon } from "@astryxdesign/core/Icon"; import { IconButton } from "@astryxdesign/core/IconButton"; +import { LayerDepthProvider, useLayerDismissal } from "@astryxdesign/core/Layer"; import { useScrollLock } from "@astryxdesign/core/hooks"; import { composeEventHandlers, @@ -87,9 +91,9 @@ import { useDrawerDialogPresence } from "./useDrawerDialogPresence"; // ============================================================================= // Module-level registry of currently open drawers, in open order (last entry -// is the top of the stack). SSR-safe: only mutated inside effects. Escape -// handling consults isTopDrawer() so sibling drawers close innermost-first, -// and non-modal (show()) drawers get incrementing z-indexes so the +// is the top of the stack). SSR-safe: only mutated inside effects. +// ui-common: Escape no longer consults it (core's layer stack orders that); +// non-modal (show()) drawers get incrementing z-indexes so the // last-opened one paints on top; modal drawers rely on the native top // layer's chronological stacking instead. type DrawerRegistryEntry = { id: string; close: () => void }; @@ -118,10 +122,6 @@ function unregisterDrawer(id: string): void { } } -function isTopDrawer(id: string): boolean { - return openDrawerStack[openDrawerStack.length - 1]?.id === id; -} - // ============================================================================= // Styles // ============================================================================= @@ -203,7 +203,8 @@ export interface DrawerProps extends BaseProps<HTMLDialogElement> { * - `true` (default) — `showModal()`: top layer, focus trap, body scroll * lock, click-outside-to-close. * - `false` — `show()`: non-modal overlay; the page behind stays - * interactive. Escape still closes while focus is inside the drawer. + * interactive. Escape still closes it (ui-common: wherever focus is, as + * the top-most layer). * @default true */ hasScrim?: boolean; @@ -314,44 +315,27 @@ export function Drawer({ // Lock body scroll while a modal drawer is open (iOS Safari workaround). useScrollLock(isOpen && hasScrim); - // Escape closes. The native `cancel` event only fires for showModal(); - // this React keydown handler covers the non-modal show() path too. Only the - // top of the drawer stack closes, so stacked siblings peel off - // innermost-first. - const handleKeyDown = useCallback( - (event: React.KeyboardEvent<HTMLDialogElement>) => { - // ui-common: React events follow the REACT tree, so a layer portalled - // out of this drawer's children bubbles its Escape through here although - // it is not in this dialog's DOM subtree. Acting on it (and preventing - // its default) makes the shared layer-dismissal stack stand down, so - // neither the layer nor the drawer closes. An Escape that ends an IME - // composition cancels the composition, not the drawer. - if ( - event.key === "Escape" && - !event.nativeEvent.isComposing && - event.currentTarget.contains(event.target as Node) - ) { - event.preventDefault(); - if (isTopDrawer(drawerId)) { - onOpenChange(false); - } - } - }, - [onOpenChange, drawerId], - ); + // ui-common: Escape goes through core's shared layer-dismissal stack, as in + // core's Dialog. The stack owns the one Escape listener and hands each press + // to the top-most layer, so a popover or modal opened inside this drawer + // closes first, and nested drawers peel off top-first. + const { shouldDismissOnCloseRequest } = useLayerDismissal({ + isActive: isOpen, + escapeBehavior: "close", + onDismiss: () => onOpenChangeRef.current(false), + }); - // Native cancel event (browser Escape handling) — prevent the browser - // from closing the dialog directly and route through onOpenChange so the - // caller's state stays the source of truth. Same top-of-stack rule as - // the keydown path. + // Native cancel event (a close request the stack never saw a press for) — + // prevent the browser from closing the dialog directly, then answer it by + // the stack's rules: only the top-most layer, never mid-composition. const handleCancel = useCallback( (event: React.SyntheticEvent<HTMLDialogElement>) => { event.preventDefault(); - if (isTopDrawer(drawerId)) { + if (shouldDismissOnCloseRequest()) { onOpenChange(false); } }, - [onOpenChange, drawerId], + [onOpenChange, shouldDismissOnCloseRequest], ); // Clicks on the ::backdrop target the <dialog> element itself; clicks on @@ -417,13 +401,13 @@ export function Drawer({ (hasScrim ? "true" : undefined) } onClick={composeEventHandlers(onClickProp, handleClick)} - onKeyDown={composeEventHandlers(onKeyDownProp, handleKeyDown)} + onKeyDown={onKeyDownProp} onCancel={handleCancel} > {/* Scrollable content area — tabIndex so the dialog's focusing steps land on the panel body rather than the first button inside. */} <div tabIndex={-1} className={CONTENT_CLASS_NAME}> - {children} + <LayerDepthProvider>{children}</LayerDepthProvider> </div> {hasCloseButton && ( <div className={CONTROLS_CLASS_NAME}> diff --git a/src/forks/provenance.json b/src/forks/provenance.json index 5c8e782..72df54f 100644 --- a/src/forks/provenance.json +++ b/src/forks/provenance.json @@ -26,6 +26,7 @@ "package": "@astryxdesign/lab", "version": "0.6.2-canary.c9fb1ad", "upstream": null, + "notes": "Deliberate divergence beyond a fix: Escape goes through core's layer-dismissal stack (useLayerDismissal + LayerDepthProvider, as core Dialog does) instead of lab's element-level handler and LIFO registry, so a popover, selector or modal inside the drawer closes first and a non-modal drawer closes on Escape wherever focus is. Also aria-modal passes through. On an Astryx bump, keep both unless lab's Drawer registers with the stack itself.", "files": { "src/Drawer/Drawer.tsx": "f72daf426b140474e79dfc79218238f70310e41893694f06459950ea69cabad3", "src/Drawer/useDrawerDialogPresence.ts": "46f7452950f7a771d14b65fffad8eeb7081a22caac13b6a5d66e7274f3607f6f", From 55a030c273423337398e52e57a527628161d6d1c Mon Sep 17 00:00:00 2001 From: Jongeun <jongeun@lablup.com> Date: Tue, 29 Sep 2026 11:13:42 +0000 Subject: [PATCH 70/87] feat(PagedSelector): add a searchable selector over paged options The product-neutral part of Backend.AI WebUI's BAIComplexSelect: a selector, single or multiple, whose options load a page at a time. Scrolling the panel near its end calls onEndReached (once per arrival, within endReachedThreshold px), search is reported per keystroke through onSearchChange, and the foot shows the total count with a spinner while the next page loads. Built on ui-common's ComplexSelector; the panel is drawn like Astryx Selector's. Props are Astryx-shaped: value holds option values (string | null, or string[] with isMultiple), onChange also hands over each chosen value's { value, label }, and a selected value missing from the loaded options is named from `labels`, then from any page it was seen on. options[] take isDisabled, endContent, labelContent, icon and description; hasClear / onClear, emptyText, header / footer, triggerDisplay, maxTriggerItems and selectionIndicator follow MultiSelector's and Selector's vocabulary. - The search row is Astryx's PanelSearchInput, which core does not export, adapted with its compiled class names and its interaction- modality store (shared through the same document symbol). No stylesheet, and the keyboard-only focus ring stays Astryx's. - ComplexSelector's content inset goes through contentXstyle as Astryx's compiled padding:0 class, so no StyleX compiler and no selector into ComplexSelector's markup. A test fails if astryx.css stops defining any borrowed class. - Strings are uic.PagedSelector.* (placeholder with {label}, searchOptions, searchPlaceholder, clearSearch, noResults, loading, totalItems with {total}), each also a prop, translated in all 20 locale files from the product's existing translations. - Option rows, list and foot are uic- classes in @layer ui-common. The popup and trigger tests are ported from the product onto the new props, plus paging, multiple-selection, label-resolution and clear tests. Also updates the ui-common docs topic's Drawer line for the previous commit. --- CONTRIBUTING.md | 81 +-- NOTICE | 2 + README.md | 1 + astryx/components/PagedSelector.doc.mjs | 183 ++++++ astryx/components/PagedSelector.tsx | 3 + astryx/docs/ui-common.doc.mjs | 3 +- exports.customs.json | 1 + .../PagedSelector/PagedSelector.css | 84 +++ .../PagedSelector/PagedSelector.messages.ts | 36 ++ .../PagedSelector/PagedSelector.test.tsx | 557 ++++++++++++++++++ .../PagedSelector/PagedSelector.tsx | 546 +++++++++++++++++ .../PagedSelector/PanelSearchInput.tsx | 181 ++++++ src/components/PagedSelector/index.ts | 8 + src/i18n/catalog.ts | 2 + src/i18n/locales/de-DE.json | 28 + src/i18n/locales/el-GR.json | 28 + src/i18n/locales/es-ES.json | 28 + src/i18n/locales/fi-FI.json | 28 + src/i18n/locales/fr-FR.json | 28 + src/i18n/locales/id-ID.json | 28 + src/i18n/locales/it-IT.json | 28 + src/i18n/locales/ja-JP.json | 28 + src/i18n/locales/ko-KR.json | 28 + src/i18n/locales/mn-MN.json | 28 + src/i18n/locales/ms-MY.json | 28 + src/i18n/locales/pl-PL.json | 28 + src/i18n/locales/pt-BR.json | 28 + src/i18n/locales/pt-PT.json | 28 + src/i18n/locales/ru-RU.json | 28 + src/i18n/locales/th-TH.json | 28 + src/i18n/locales/tr-TR.json | 28 + src/i18n/locales/vi-VN.json | 28 + src/i18n/locales/zh-CN.json | 28 + src/i18n/locales/zh-TW.json | 28 + src/index.ts | 10 + 35 files changed, 2217 insertions(+), 41 deletions(-) create mode 100644 astryx/components/PagedSelector.doc.mjs create mode 100644 astryx/components/PagedSelector.tsx create mode 100644 src/components/PagedSelector/PagedSelector.css create mode 100644 src/components/PagedSelector/PagedSelector.messages.ts create mode 100644 src/components/PagedSelector/PagedSelector.test.tsx create mode 100644 src/components/PagedSelector/PagedSelector.tsx create mode 100644 src/components/PagedSelector/PanelSearchInput.tsx create mode 100644 src/components/PagedSelector/index.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2a4543e..3bd204b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -156,46 +156,47 @@ the recorded version or file. On an Astryx bump: ## Components -| Component | Source | Built on | -| -------------------------------------------------------------- | ------------------------------------ | -------------------------------------------------- | -| `Modal` | `src/components/Modal/` | `Dialog` (inline), `DialogHeader`, `Layout` | -| `PageLayout` | `src/components/PageLayout/` | plain CSS | -| `PageHeader` | `src/components/PageHeader/` | `Heading`, `Text`, `Button`, `IconButton` | -| `StatCard` | `src/components/StatCard/` | `Card`, `ClickableCard`, `Text`, `Skeleton` | -| `ErrorState` | `src/components/ErrorState/` | `Icon`, `Heading`, `Text`, `Button` | -| `SkeletonCard`, `SkeletonText`, `SkeletonChart`, `SkeletonRow` | `src/components/Skeleton/` | `Skeleton` | -| `SmoothHeight` | `src/components/SmoothHeight/` | plain CSS | -| `DigitPopIn` | `src/components/DigitPopIn/` | plain CSS | -| `CountBadge` | `src/components/CountBadge/` | `Badge` | -| `DoubleBadge` | `src/components/DoubleBadge/` | `Badge`, `HStack` | -| `BooleanToken` | `src/components/BooleanToken/` | `Token` | -| `IconWithTooltip` | `src/components/IconWithTooltip/` | `Tooltip`, `Text` | -| `ImageWithFallback` | `src/components/ImageWithFallback/` | plain `<img>` | -| `NotificationStack` | `src/components/NotificationStack/` | `Banner`, `Button`, `ProgressBar`, `Stack`, `Text` | -| `OverlayScrollbar` | `src/components/OverlayScrollbar/` | plain CSS | -| `ConfirmPopover` | `src/components/ConfirmPopover/` | `Popover`, `Button`, `Stack`, `Text` | -| `SelectionLabel` | `src/components/SelectionLabel/` | `Text`, `IconButton`, `HStack` | -| `UncontrolledInput` | `src/components/UncontrolledInput/` | `TextInput`, `NumberInput` | -| `AlertModal` | `src/components/AlertModal/` | `Modal`, `Heading`, `Text`, `Button`, `Layout` | -| `DeleteConfirmModal` | `src/components/DeleteConfirmModal/` | `Modal`, `TextInput`, `Token`, `Banner`, `Text` | -| `StepNumberInput`, `NumberStepper` | `src/components/StepNumberInput/` | `InputGroup`, `NumberInput`, `Icon` | -| `BoardItemTitle` | `src/components/BoardItemTitle/` | `HStack`, `Heading`, `Icon`, `IconWithTooltip` | -| `Statistic` | `src/components/Statistic/` | `Stack`, `Text`, `Tooltip` | -| `DividedRow` | `src/components/DividedRow/` | plain CSS | -| `TokenList` | `src/components/TokenList/` | `Token`, `Badge`, `Link`, `HoverCard`, `Popover` | -| `TokenRow` | `src/components/TokenRow/` | `Token`, `HStack` | -| `NotificationItem` | `src/components/NotificationItem/` | `Stack`, `Text` | -| `UnitGrid`, `UnitGridSkeleton` | `src/components/UnitGrid/` | `Stack`, `Text`, `VisuallyHidden`, `Skeleton` | -| `ColorPicker` | `src/components/ColorPicker/` | `Popover`, `TextInput`, `Button` | -| `Form` (engine, `Form.Item` shell, hooks) | `src/components/Form/` | `Tooltip`, plain CSS | -| `BulkEditFormItem` | `src/components/BulkEditFormItem/` | `Form.Item`, `TextInput`, `Link`, `HStack` | -| `DataGrid`, `DataGridSettingsModal`, `DataGridExportModal` | `src/components/DataGrid/` | `Table` + plugins, `Pagination`, `Modal`, dnd-kit | -| `BulkErrorModal` | `src/components/BulkErrorModal/` | `Modal`, `Banner`, `DataGrid` | -| `ProgressWithLabel` | `src/components/ProgressWithLabel/` | `Text`, plain CSS | -| `TextHighlighter` | `src/components/TextHighlighter/` | plain CSS | -| `CountdownBorder` | `src/components/CountdownBorder/` | SVG, plain CSS | -| `DoubleToken` | `src/components/DoubleToken/` | `Token`, `HStack`, `TextHighlighter` | -| `ListBanner` | `src/components/ListBanner/` | `Banner` | +| Component | Source | Built on | +| -------------------------------------------------------------- | ------------------------------------ | --------------------------------------------------- | +| `Modal` | `src/components/Modal/` | `Dialog` (inline), `DialogHeader`, `Layout` | +| `PageLayout` | `src/components/PageLayout/` | plain CSS | +| `PageHeader` | `src/components/PageHeader/` | `Heading`, `Text`, `Button`, `IconButton` | +| `StatCard` | `src/components/StatCard/` | `Card`, `ClickableCard`, `Text`, `Skeleton` | +| `ErrorState` | `src/components/ErrorState/` | `Icon`, `Heading`, `Text`, `Button` | +| `SkeletonCard`, `SkeletonText`, `SkeletonChart`, `SkeletonRow` | `src/components/Skeleton/` | `Skeleton` | +| `SmoothHeight` | `src/components/SmoothHeight/` | plain CSS | +| `DigitPopIn` | `src/components/DigitPopIn/` | plain CSS | +| `CountBadge` | `src/components/CountBadge/` | `Badge` | +| `DoubleBadge` | `src/components/DoubleBadge/` | `Badge`, `HStack` | +| `BooleanToken` | `src/components/BooleanToken/` | `Token` | +| `IconWithTooltip` | `src/components/IconWithTooltip/` | `Tooltip`, `Text` | +| `ImageWithFallback` | `src/components/ImageWithFallback/` | plain `<img>` | +| `NotificationStack` | `src/components/NotificationStack/` | `Banner`, `Button`, `ProgressBar`, `Stack`, `Text` | +| `OverlayScrollbar` | `src/components/OverlayScrollbar/` | plain CSS | +| `ConfirmPopover` | `src/components/ConfirmPopover/` | `Popover`, `Button`, `Stack`, `Text` | +| `PagedSelector` | `src/components/PagedSelector/` | `ComplexSelector` (fork), `SelectorOption`, `Token` | +| `SelectionLabel` | `src/components/SelectionLabel/` | `Text`, `IconButton`, `HStack` | +| `UncontrolledInput` | `src/components/UncontrolledInput/` | `TextInput`, `NumberInput` | +| `AlertModal` | `src/components/AlertModal/` | `Modal`, `Heading`, `Text`, `Button`, `Layout` | +| `DeleteConfirmModal` | `src/components/DeleteConfirmModal/` | `Modal`, `TextInput`, `Token`, `Banner`, `Text` | +| `StepNumberInput`, `NumberStepper` | `src/components/StepNumberInput/` | `InputGroup`, `NumberInput`, `Icon` | +| `BoardItemTitle` | `src/components/BoardItemTitle/` | `HStack`, `Heading`, `Icon`, `IconWithTooltip` | +| `Statistic` | `src/components/Statistic/` | `Stack`, `Text`, `Tooltip` | +| `DividedRow` | `src/components/DividedRow/` | plain CSS | +| `TokenList` | `src/components/TokenList/` | `Token`, `Badge`, `Link`, `HoverCard`, `Popover` | +| `TokenRow` | `src/components/TokenRow/` | `Token`, `HStack` | +| `NotificationItem` | `src/components/NotificationItem/` | `Stack`, `Text` | +| `UnitGrid`, `UnitGridSkeleton` | `src/components/UnitGrid/` | `Stack`, `Text`, `VisuallyHidden`, `Skeleton` | +| `ColorPicker` | `src/components/ColorPicker/` | `Popover`, `TextInput`, `Button` | +| `Form` (engine, `Form.Item` shell, hooks) | `src/components/Form/` | `Tooltip`, plain CSS | +| `BulkEditFormItem` | `src/components/BulkEditFormItem/` | `Form.Item`, `TextInput`, `Link`, `HStack` | +| `DataGrid`, `DataGridSettingsModal`, `DataGridExportModal` | `src/components/DataGrid/` | `Table` + plugins, `Pagination`, `Modal`, dnd-kit | +| `BulkErrorModal` | `src/components/BulkErrorModal/` | `Modal`, `Banner`, `DataGrid` | +| `ProgressWithLabel` | `src/components/ProgressWithLabel/` | `Text`, plain CSS | +| `TextHighlighter` | `src/components/TextHighlighter/` | plain CSS | +| `CountdownBorder` | `src/components/CountdownBorder/` | SVG, plain CSS | +| `DoubleToken` | `src/components/DoubleToken/` | `Token`, `HStack`, `TextHighlighter` | +| `ListBanner` | `src/components/ListBanner/` | `Banner` | Each has tests beside it. `src/components/componentStyles.test.ts` holds every stylesheet to the styling rules below. diff --git a/NOTICE b/NOTICE index 7b0c422..db54839 100644 --- a/NOTICE +++ b/NOTICE @@ -43,6 +43,8 @@ The forks of Astryx components under src/forks/ (shipped in dist/forks/), including their generated *.styles files, are copies of Astryx source from @astryxdesign/core and @astryxdesign/lab (https://github.com/facebook/astryx), with fixes applied; src/forks/provenance.json records each one's origin. +src/components/PagedSelector/PanelSearchInput.tsx is adapted from +@astryxdesign/core's Field/PanelSearchInput and utils/interactionModality. Astryx is licensed under the MIT License: -------------------------------------------------------------------------------- diff --git a/README.md b/README.md index 66f7505..5694eb8 100644 --- a/README.md +++ b/README.md @@ -193,6 +193,7 @@ import { Modal } from "@lablup/ui-common/Modal"; | `NotificationStack` | Floating notices with task progress and actions | `/components/NotificationStack` | | `OverlayScrollbar` | A persistent scroll thumb drawn over a scroll container | `/components/OverlayScrollbar` | | `ConfirmPopover` | A one-click confirmation anchored to its trigger | `/components/ConfirmPopover` | +| `PagedSelector` | A searchable selector over options loaded a page at a time | `/components/PagedSelector` | | `SelectionLabel` | "3 selected", with a button that clears the selection | `/components/SelectionLabel` | | `UncontrolledInput` | A field that reports its value on Enter or blur | `/components/UncontrolledInput` | | `AlertModal` | An alert dialog, in place of Astryx `AlertDialog` | `/AlertModal` | diff --git a/astryx/components/PagedSelector.doc.mjs b/astryx/components/PagedSelector.doc.mjs new file mode 100644 index 0000000..13b219f --- /dev/null +++ b/astryx/components/PagedSelector.doc.mjs @@ -0,0 +1,183 @@ +/** + * `astryx component PagedSelector` (and `ui-common component PagedSelector`). + * + * @type {import('@astryxdesign/cli/authoring').ComponentDoc} + */ +export default { + type: "component", + name: "PagedSelector", + displayName: "PagedSelector", + import: "@lablup/ui-common", + category: "Input", + keywords: [ + "select", + "selector", + "infinite scroll", + "load more", + "paged", + "pagination", + "server search", + "async select", + "multi select", + ], + description: + "A searchable selector, single or multiple, over options loaded a page at a time: scrolling the panel near its end calls onEndReached, and search is reported per keystroke for a server-side query. Built on ComplexSelector; the panel is drawn like Selector's.", + props: [ + { + name: "label", + type: "string", + description: "Field label and accessible name.", + required: true, + }, + { + name: "options", + type: "PagedSelectorOption[]", + description: + "The loaded options, every page so far: { value, label, labelContent?, icon?, description?, endContent?, isDisabled? }. label is a string: the trigger text and the row's name.", + required: true, + }, + { + name: "value", + type: "string | null | string[]", + description: "The selected option value; an array with isMultiple.", + }, + { + name: "onChange", + type: "(value, items) => void", + description: + "Called with the new value and each chosen value's { value, label }, so labels survive values that are not on the loaded page.", + }, + { + name: "isMultiple", + type: "boolean", + description: "Select several; the panel stays open on a pick.", + default: "false", + }, + { + name: "labels", + type: "Record<string, string>", + description: + "Labels for selected values that may not be among options (another page, filtered out).", + }, + { + name: "hasSearch", + type: "boolean", + description: "Search box at the top of the panel.", + default: "true", + }, + { + name: "searchValue / onSearchChange", + type: "string / (value: string) => void", + description: "Controlled search text, and every keystroke; debounce it yourself.", + }, + { + name: "onEndReached", + type: "() => void", + description: + "Called once each time the list is scrolled to within endReachedThreshold px of its end. Load the next page here.", + }, + { + name: "endReachedThreshold", + type: "number", + description: "Distance from the end, in px, that counts as reaching it.", + default: "30", + }, + { + name: "onAtEndChange", + type: "(isAtEnd: boolean) => void", + description: "Called when the list arrives at, or leaves, its end.", + }, + { + name: "isLoading", + type: "boolean", + description: + "Spinner in the trigger; an empty list shows a loading row instead of No results.", + }, + { + name: "isLoadingMore", + type: "boolean", + description: "Spinner in the foot while the next page loads.", + }, + { + name: "totalCount", + type: "number", + description: 'Options in all pages; shows "Total N items" in the foot.', + }, + { + name: "emptyText", + type: "ReactNode", + description: "Replaces the empty list's content, the loading row included.", + }, + { + name: "header / footer", + type: "ReactNode / ReactNode | (close) => ReactNode", + description: + "Above and below the option list. A footer replaces the total count.", + }, + { + name: "hasClear / onClear", + type: "boolean / () => void", + description: + "Clear button in the trigger; clears to null (or []) unless onClear is given.", + }, + { + name: "triggerDisplay", + type: '"labels" | "badges"', + description: + 'Multiple selection in the trigger: "A, B, C, +2" as MultiSelector, or tokens.', + default: '"labels"', + }, + { + name: "maxTriggerItems", + type: "number", + description: "Selected items named in the trigger before +N.", + default: "3", + }, + { + name: "selectionIndicator", + type: '"check" | "checkbox"', + description: + "The theme's check at a chosen row's end, or a checkbox at its start.", + default: '"check"', + }, + { + name: "listMaxHeight", + type: "number", + description: "Height the option list scrolls within, in px.", + default: "260", + }, + { + name: "placeholder, searchPlaceholder, searchLabel, clearSearchLabel, formatTotalCount", + type: "string / (total) => string", + description: + "Built-in strings; defaults from the uic.PagedSelector.* catalog keys.", + }, + { + name: "isDisabled, isRequired, isOptional, description, status, size, width, onOpenChange, …", + type: "ComplexSelector props", + description: "Passed to ComplexSelector.", + }, + ], + usage: { + description: + "For a list too long to load at once: a connection paged 10-20 rows at a time, searched on the server. For a list you hold in full, use Selector or MultiSelector.", + bestPractices: [ + { + guidance: true, + description: + "Guard onEndReached with the connection's hasNext and in-flight state; it fires on each arrival at the end.", + }, + { + guidance: true, + description: + "Keep each selected value's label (onChange hands it over) and pass them back in labels.", + }, + ], + }, + examples: [ + { + label: "A paged, server-searched list", + code: '<PagedSelector\n label="Owner"\n value={ownerId}\n onChange={(id) => setOwnerId(id)}\n options={users.map((u) => ({ value: u.id, label: u.email }))}\n onSearchChange={setQuery}\n onEndReached={() => hasNext && !isLoadingNext && loadNext(10)}\n isLoadingMore={isLoadingNext}\n totalCount={count}\n/>', + }, + ], +}; diff --git a/astryx/components/PagedSelector.tsx b/astryx/components/PagedSelector.tsx new file mode 100644 index 0000000..28bd7c9 --- /dev/null +++ b/astryx/components/PagedSelector.tsx @@ -0,0 +1,3 @@ +// The Astryx CLI pairs each component doc with a same-stem source file. +// ui-common ships no source, so this names the export instead. +export { PagedSelector } from "@lablup/ui-common"; diff --git a/astryx/docs/ui-common.doc.mjs b/astryx/docs/ui-common.doc.mjs index 4f8a673..bacc088 100644 --- a/astryx/docs/ui-common.doc.mjs +++ b/astryx/docs/ui-common.doc.mjs @@ -46,7 +46,7 @@ export const docs = { }, { type: "prose", - text: "The exception is a fixed copy of an Astryx component, carrying an upstream fix Astryx has not shipped yet. It keeps Astryx's name, props and import path, and replaces Astryx's in ui-common's exports: `ComplexSelector` (`@lablup/ui-common/ComplexSelector`) adds `hasClear` and `onClear`; lab's `Drawer` keeps an Escape from a layer opened inside it, or one ending an IME composition, from closing it and passes `aria-modal` through; lab's `Tour` keeps a step's spotlight under its callout in StrictMode (both at `@lablup/ui-common/lab`).", + text: "The exception is a fixed copy of an Astryx component, carrying an upstream fix Astryx has not shipped yet. It keeps Astryx's name, props and import path, and replaces Astryx's in ui-common's exports: `ComplexSelector` (`@lablup/ui-common/ComplexSelector`) adds `hasClear` and `onClear`; lab's `Drawer` routes Escape through core's layer-dismissal stack, so a popover, selector or modal inside it closes first, and passes `aria-modal` through; lab's `Tour` keeps a step's spotlight under its callout in StrictMode (both at `@lablup/ui-common/lab`).", }, { type: "list", @@ -63,6 +63,7 @@ export const docs = { "`NotificationStack`: floating notices with task progress, Cancel/Retry and an action; the caller owns the list.", "`OverlayScrollbar`: a persistent scroll thumb over a scroll container, in place of its native bar.", "`ConfirmPopover`: a one-click confirmation on Popover, for reversible actions.", + "`PagedSelector`: a searchable selector, single or multiple, over options loaded a page at a time; scrolling near the end calls `onEndReached`. Built on ComplexSelector, drawn like Selector.", '`SelectionLabel`: "3 selected" with a button that clears the selection.', "`UncontrolledInput`: a text or number field that reports its value on Enter or blur.", "`AlertModal`: the alert-dialog pattern on Modal, in its level stack. ui-common hides AlertDialog; use this.", diff --git a/exports.customs.json b/exports.customs.json index 5628cb9..a301b90 100644 --- a/exports.customs.json +++ b/exports.customs.json @@ -40,6 +40,7 @@ { "name": "OverlayScrollbar", "source": "components/OverlayScrollbar/index.ts" }, { "name": "PageHeader", "source": "components/PageHeader/index.ts" }, { "name": "PageLayout", "source": "components/PageLayout/index.ts" }, + { "name": "PagedSelector", "source": "components/PagedSelector/index.ts" }, { "name": "ProgressWithLabel", "source": "components/ProgressWithLabel/index.ts" }, { "name": "SelectionLabel", "source": "components/SelectionLabel/index.ts" }, { "name": "Skeleton composites", "source": "components/Skeleton/index.ts" }, diff --git a/src/components/PagedSelector/PagedSelector.css b/src/components/PagedSelector/PagedSelector.css new file mode 100644 index 0000000..ccd0fe9 --- /dev/null +++ b/src/components/PagedSelector/PagedSelector.css @@ -0,0 +1,84 @@ +/* + * PagedSelector's panel body, shaped like Astryx Selector's (its `dropdown` + * and `item` styles). The search row is Astryx's own classes + * (PanelSearchInput.tsx) and needs nothing here; ComplexSelector's content + * inset is dropped through `contentXstyle`, not a selector into its markup. + */ +@layer ui-common { + .uic-paged-selector__listbox { + box-sizing: border-box; + overflow-y: auto; + padding-block: var(--spacing-1); + /* The trigger's text inset includes its border; the panel mirrors that + pixel so rows line up with the closed control's label. */ + padding-inline: calc(var(--spacing-1) + var(--border-width)); + } + + .uic-paged-selector__empty { + padding: var(--spacing-3); + text-align: center; + } + + .uic-paged-selector__option { + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--spacing-2); + width: 100%; + padding-block: var(--spacing-1-5); + padding-inline: var(--spacing-2); + border-radius: var(--radius-element); + color: var(--color-text-primary); + background-color: transparent; + cursor: pointer; + text-align: start; + } + + .uic-paged-selector__option[data-size="sm"] { + padding-block: var(--spacing-1); + } + + .uic-paged-selector__option[data-highlighted="true"] { + background-color: var(--color-overlay-hover); + } + + /* Weight alone, as Selector marks its chosen row: a wash would compete with + the highlight that follows the pointer. */ + .uic-paged-selector__option[data-selected="true"] { + font-weight: var(--font-weight-medium); + } + + .uic-paged-selector__option[data-disabled="true"] { + opacity: 0.5; + cursor: not-allowed; + } + + .uic-paged-selector__option-content { + display: flex; + align-items: center; + gap: var(--spacing-2); + flex: 1; + min-width: 0; + } + + /* Stretch the option so its end content lands at the row's trailing edge. */ + .uic-paged-selector__option-content > * { + flex: 1; + min-width: 0; + } + + /* Reserved on every row, so a chosen row keeps the same geometry. */ + .uic-paged-selector__option-mark { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + min-width: 1rem; + } + + .uic-paged-selector__foot { + padding-block: var(--spacing-1); + padding-inline: calc(var(--spacing-2) + var(--border-width)); + } +} diff --git a/src/components/PagedSelector/PagedSelector.messages.ts b/src/components/PagedSelector/PagedSelector.messages.ts new file mode 100644 index 0000000..34b5200 --- /dev/null +++ b/src/components/PagedSelector/PagedSelector.messages.ts @@ -0,0 +1,36 @@ +import { defineMessages } from "../../i18n/catalog"; + +export const pagedSelectorMessages = defineMessages({ + "uic.PagedSelector.placeholder": { + defaultMessage: "Select {label}", + description: + "Placeholder of a selector with nothing selected; {label} is the field's label", + }, + "uic.PagedSelector.searchOptions": { + defaultMessage: "Search options", + description: + "Accessible name of the search box at the top of a selector's option list", + }, + "uic.PagedSelector.searchPlaceholder": { + defaultMessage: "Search", + description: "Placeholder of the search box at the top of a selector's option list", + }, + "uic.PagedSelector.clearSearch": { + defaultMessage: "Clear search options", + description: "Accessible name of the button that empties a selector's search box", + }, + "uic.PagedSelector.noResults": { + defaultMessage: "No results", + description: "Shown in a selector's option list when no option matches", + }, + "uic.PagedSelector.loading": { + defaultMessage: "Loading...", + description: + "Shown in a selector's empty option list while options are being fetched", + }, + "uic.PagedSelector.totalItems": { + defaultMessage: "Total {total} items", + description: + "Foot of a selector's option list; {total} is how many options exist in all pages", + }, +}); diff --git a/src/components/PagedSelector/PagedSelector.test.tsx b/src/components/PagedSelector/PagedSelector.test.tsx new file mode 100644 index 0000000..2d167f5 --- /dev/null +++ b/src/components/PagedSelector/PagedSelector.test.tsx @@ -0,0 +1,557 @@ +/** + * PagedSelector, ported from Backend.AI WebUI's BAIComplexSelect tests (its + * popup and trigger suites), onto PagedSelector's props: option values in + * `value` / `onChange`, `isMultiple`, `options[].isDisabled`. + * + * jsdom implements no Popover API, so the mock below is Astryx's own + * (`ComplexSelector.test.tsx`), and a `[popover]` subtree stays hidden from + * the accessibility tree even once open, so popup queries pass + * `{ hidden: true }`. `usePopover`'s autofocus finds nothing under jsdom + * either, so the tests focus the key surface themselves. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { useState } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +import { PagedSelector, type PagedSelectorOption } from "./PagedSelector"; +import { PANEL_SEARCH_CLASS_NAMES } from "./PanelSearchInput"; +import { CONTENT_RESET } from "./PagedSelector"; + +const originalMatches = HTMLElement.prototype.matches; + +beforeEach(() => { + HTMLElement.prototype.showPopover = vi.fn(function (this: HTMLElement) { + this.setAttribute("popover-open", ""); + const event = new Event("toggle"); + Object.defineProperty(event, "newState", { value: "open" }); + this.dispatchEvent(event); + }); + HTMLElement.prototype.hidePopover = vi.fn(function (this: HTMLElement) { + this.removeAttribute("popover-open"); + const event = new Event("toggle"); + Object.defineProperty(event, "newState", { value: "closed" }); + this.dispatchEvent(event); + }); + vi.spyOn(HTMLElement.prototype, "matches").mockImplementation(function ( + this: HTMLElement, + selector: string, + ) { + if (selector === ":popover-open") return this.hasAttribute("popover-open"); + return originalMatches.call(this, selector); + }); +}); + +const OPTIONS: PagedSelectorOption[] = [ + { value: "a", label: "alpha" }, + { value: "b", label: "bravo" }, + { value: "c", label: "charlie" }, +]; + +/** `b` is unselectable: the row every disabled assertion aims at. */ +const WITH_DISABLED: PagedSelectorOption[] = [ + { value: "a", label: "alpha" }, + { value: "b", label: "bravo", isDisabled: true }, + { value: "c", label: "charlie" }, +]; + +const FOUR: PagedSelectorOption[] = [...OPTIONS, { value: "d", label: "delta" }]; + +const h = { hidden: true } as const; + +const trigger = () => screen.getAllByRole("button")[0]!; +const triggerText = () => trigger().textContent?.trim() ?? ""; +const listbox = () => screen.getByRole("listbox", h); +const optionRows = () => screen.getAllByRole("option", h); +const searchBox = () => screen.getByRole("combobox", h); +const highlightedLabels = () => + optionRows() + .filter((row) => row.getAttribute("data-highlighted") === "true") + .map((row) => row.textContent); + +describe("PagedSelector trigger", () => { + it("names every selected option while under the cap", () => { + render( + <PagedSelector label="Targets" isMultiple options={FOUR} value={["a", "b"]} />, + ); + expect(triggerText()).toContain("alpha, bravo"); + expect(triggerText()).not.toMatch(/\d+\s+selected/i); + }); + + it('collapses past `maxTriggerItems` to the "+N" form Astryx uses', () => { + render( + <PagedSelector + label="Targets" + isMultiple + options={FOUR} + value={["a", "b", "c", "d"]} + />, + ); + expect(triggerText()).toContain("alpha, bravo, charlie, +1"); + }); + + it("renders chips with triggerDisplay badges", () => { + render( + <PagedSelector + label="Targets" + isMultiple + triggerDisplay="badges" + options={FOUR} + value={["a", "b"]} + />, + ); + expect(triggerText()).toContain("alpha"); + expect(triggerText()).toContain("bravo"); + expect(triggerText()).not.toContain("alpha, bravo"); + }); + + it("single mode shows the one label, no separators", () => { + render(<PagedSelector label="Target" options={FOUR} value="c" />); + expect(triggerText()).toContain("charlie"); + expect(triggerText()).not.toContain(","); + }); + + it("names a selected value that is not on the loaded page from `labels`", () => { + render( + <PagedSelector + label="Target" + options={OPTIONS} + value="z" + labels={{ z: "zulu" }} + />, + ); + expect(triggerText()).toContain("zulu"); + }); + + it("keeps the label of a value picked from a page that is gone", async () => { + const user = userEvent.setup(); + function Harness() { + const [value, setValue] = useState<string | null>(null); + const [options, setOptions] = useState(OPTIONS); + return ( + <> + <PagedSelector + label="Target" + options={options} + value={value} + onChange={setValue} + /> + <button type="button" onClick={() => setOptions([])}> + next query + </button> + </> + ); + } + render(<Harness />); + await user.click(trigger()); + await user.click(optionRows()[1]!); + await user.click(screen.getByRole("button", { name: "next query" })); + expect(triggerText()).toContain("bravo"); + }); + + it("uses the placeholder that names the field", () => { + render(<PagedSelector label="Owner" options={OPTIONS} />); + expect(triggerText()).toContain("Select Owner"); + }); + + it("clears to null, or to [] when multiple, and hides the clear with nothing selected", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + const { unmount } = render( + <PagedSelector + label="Target" + hasClear + options={OPTIONS} + value="a" + onChange={onChange} + />, + ); + await user.click(screen.getByRole("button", { name: /clear/i })); + expect(onChange).toHaveBeenCalledWith(null, null); + unmount(); + + render( + <PagedSelector + label="Targets" + isMultiple + hasClear + options={OPTIONS} + value={["a"]} + onChange={onChange} + />, + ); + await user.click(screen.getByRole("button", { name: /clear/i })); + expect(onChange).toHaveBeenLastCalledWith([], []); + }); +}); + +describe("PagedSelector popup: keyboard", () => { + it("commits the arrowed-to option with Enter from the search box", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render(<PagedSelector label="Targets" options={OPTIONS} onChange={onChange} />); + + await user.click(trigger()); + searchBox().focus(); + await user.keyboard("{ArrowDown}{ArrowDown}"); + expect(highlightedLabels()).toEqual(["bravo"]); + + await user.keyboard("{Enter}"); + expect(onChange).toHaveBeenCalledWith("b", { value: "b", label: "bravo" }); + }); + + it("keeps the searchless popup operable: the listbox itself takes the keys", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render( + <PagedSelector + label="Targets" + hasSearch={false} + options={OPTIONS} + onChange={onChange} + />, + ); + + await user.click(trigger()); + expect(listbox()).toHaveAttribute("tabindex", "0"); + expect(screen.queryByRole("combobox", h)).not.toBeInTheDocument(); + + listbox().focus(); + await user.keyboard("{ArrowDown}"); + expect(highlightedLabels()).toEqual(["alpha"]); + expect(listbox()).toHaveAttribute("aria-activedescendant", optionRows()[0]!.id); + + await user.keyboard("{Enter}"); + expect(onChange).toHaveBeenCalledWith("a", { value: "a", label: "alpha" }); + }); + + it("commits with Space too, but only where no input is swallowing it", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + const onSearchChange = vi.fn(); + const { unmount } = render( + <PagedSelector + label="Targets" + hasSearch={false} + options={OPTIONS} + onChange={onChange} + />, + ); + await user.click(trigger()); + listbox().focus(); + await user.keyboard("{ArrowDown} "); + expect(onChange).toHaveBeenCalledWith("a", { value: "a", label: "alpha" }); + unmount(); + + render( + <PagedSelector + label="Targets" + options={OPTIONS} + onChange={onChange} + onSearchChange={onSearchChange} + />, + ); + onChange.mockClear(); + await user.click(trigger()); + searchBox().focus(); + await user.keyboard("{ArrowDown} "); + expect(onChange).not.toHaveBeenCalled(); + expect(onSearchChange).toHaveBeenLastCalledWith(" "); + }); + + it("Home and End land on real options", async () => { + const user = userEvent.setup(); + render(<PagedSelector label="Targets" options={OPTIONS} />); + await user.click(trigger()); + searchBox().focus(); + + await user.keyboard("{End}"); + expect(highlightedLabels()).toEqual(["charlie"]); + await user.keyboard("{Home}"); + expect(highlightedLabels()).toEqual(["alpha"]); + }); +}); + +describe("PagedSelector popup: multiple", () => { + it("toggles options and reports the whole selection with its labels", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render( + <PagedSelector + label="Targets" + isMultiple + options={OPTIONS} + value={["z", "a"]} + labels={{ z: "zulu" }} + onChange={onChange} + />, + ); + await user.click(trigger()); + expect(listbox()).toHaveAttribute("aria-multiselectable", "true"); + + await user.click(optionRows()[1]!); + expect(onChange).toHaveBeenLastCalledWith( + ["z", "a", "b"], + [ + { value: "z", label: "zulu" }, + { value: "a", label: "alpha" }, + { value: "b", label: "bravo" }, + ], + ); + await user.click(optionRows()[0]!); + expect(onChange).toHaveBeenLastCalledWith(["z"], [{ value: "z", label: "zulu" }]); + // The panel stays open for the next pick. + expect(trigger()).toHaveAttribute("aria-expanded", "true"); + }); +}); + +describe("PagedSelector popup: disabled options", () => { + it("never lets a disabled row wear the highlight on hover", async () => { + const user = userEvent.setup(); + render(<PagedSelector label="Targets" options={WITH_DISABLED} />); + await user.click(trigger()); + + await user.hover(optionRows()[0]!); + expect(highlightedLabels()).toEqual(["alpha"]); + await user.hover(optionRows()[1]!); + expect(highlightedLabels()).toEqual(["alpha"]); + }); + + it("arrows step over the disabled row instead of resting on it", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render( + <PagedSelector label="Targets" options={WITH_DISABLED} onChange={onChange} />, + ); + await user.click(trigger()); + searchBox().focus(); + + await user.keyboard("{ArrowDown}"); + expect(highlightedLabels()).toEqual(["alpha"]); + await user.keyboard("{ArrowDown}"); + expect(highlightedLabels()).toEqual(["charlie"]); + + await user.keyboard("{Enter}"); + expect(onChange).toHaveBeenCalledWith("c", { value: "c", label: "charlie" }); + }); + + it("clicking a disabled row commits nothing", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render( + <PagedSelector label="Targets" options={WITH_DISABLED} onChange={onChange} />, + ); + await user.click(trigger()); + await user.click(optionRows()[1]!); + expect(onChange).not.toHaveBeenCalled(); + expect(optionRows()[1]).toHaveAttribute("aria-disabled", "true"); + }); +}); + +describe("PagedSelector popup: search box", () => { + it("clears the query and reports the clear", async () => { + const user = userEvent.setup(); + const onSearchChange = vi.fn(); + render( + <PagedSelector + label="Targets" + options={OPTIONS} + onSearchChange={onSearchChange} + />, + ); + await user.click(trigger()); + + await user.type(searchBox(), "br"); + expect(searchBox()).toHaveValue("br"); + expect(onSearchChange).toHaveBeenLastCalledWith("br"); + + await user.click(screen.getByRole("button", { ...h, name: /clear search/i })); + expect(searchBox()).toHaveValue(""); + expect(onSearchChange).toHaveBeenLastCalledWith(""); + expect( + screen.queryByRole("button", { ...h, name: /clear search/i }), + ).not.toBeInTheDocument(); + }); + + it("does not highlight a disabled first row when a query lands on one", async () => { + const user = userEvent.setup(); + render(<PagedSelector label="Targets" options={WITH_DISABLED} />); + await user.click(trigger()); + await user.type(searchBox(), "a"); + expect(highlightedLabels()).toEqual(["alpha"]); + }); + + it("is Astryx's panel search row, drawn with Astryx's own classes", async () => { + const user = userEvent.setup(); + render(<PagedSelector label="Targets" options={OPTIONS} />); + await user.click(trigger()); + expect(searchBox()).toHaveAccessibleName("Search options"); + expect(searchBox()).toHaveAttribute("placeholder", "Search"); + expect(searchBox().className).toBe(PANEL_SEARCH_CLASS_NAMES.input); + }); +}); + +describe("PagedSelector popup: reopen", () => { + it("drops the highlight when the panel closes", async () => { + const user = userEvent.setup(); + render(<PagedSelector label="Targets" options={OPTIONS} />); + + await user.click(trigger()); + searchBox().focus(); + await user.keyboard("{ArrowDown}{ArrowDown}"); + expect(highlightedLabels()).toEqual(["bravo"]); + + await user.click(trigger()); + await user.click(trigger()); + expect(highlightedLabels()).toEqual([]); + }); + + it("reports open and close", async () => { + const user = userEvent.setup(); + const onOpenChange = vi.fn(); + render( + <PagedSelector label="Targets" options={OPTIONS} onOpenChange={onOpenChange} />, + ); + await user.click(trigger()); + expect(onOpenChange).toHaveBeenLastCalledWith(true); + await user.click(trigger()); + expect(onOpenChange).toHaveBeenLastCalledWith(false); + }); +}); + +describe("PagedSelector popup: empty state", () => { + it('says "no results" for an empty list that is not loading', async () => { + const user = userEvent.setup(); + render(<PagedSelector label="Targets" options={[]} />); + await user.click(trigger()); + expect(listbox()).toHaveTextContent("No results"); + expect(listbox()).not.toHaveTextContent(/loading/i); + }); + + it('says "loading" instead while the list is still in flight', async () => { + const user = userEvent.setup(); + render(<PagedSelector label="Targets" options={[]} isLoading />); + await user.click(trigger()); + expect(listbox()).toHaveTextContent("Loading..."); + expect(listbox()).not.toHaveTextContent(/no results/i); + }); + + it("leaves a populated list alone while loading", async () => { + const user = userEvent.setup(); + render(<PagedSelector label="Targets" options={OPTIONS} isLoading />); + await user.click(trigger()); + expect(optionRows()).toHaveLength(3); + expect(listbox()).not.toHaveTextContent(/loading/i); + }); + + it("lets `emptyText` win over both", async () => { + const user = userEvent.setup(); + render( + <PagedSelector + label="Targets" + options={[]} + isLoading + emptyText={<span>pick a scope first</span>} + />, + ); + await user.click(trigger()); + expect(listbox()).toHaveTextContent("pick a scope first"); + expect(listbox()).not.toHaveTextContent(/loading/i); + }); +}); + +describe("PagedSelector popup: paging", () => { + function scrollTo(el: HTMLElement, scrollTop: number) { + Object.defineProperty(el, "scrollHeight", { configurable: true, value: 500 }); + Object.defineProperty(el, "clientHeight", { configurable: true, value: 200 }); + el.scrollTop = scrollTop; + fireEvent.scroll(el); + } + + it("fires onEndReached once per arrival within the threshold", async () => { + const user = userEvent.setup(); + const onEndReached = vi.fn(); + const onAtEndChange = vi.fn(); + render( + <PagedSelector + label="Targets" + options={OPTIONS} + onEndReached={onEndReached} + onAtEndChange={onAtEndChange} + endReachedThreshold={30} + />, + ); + await user.click(trigger()); + scrollTo(listbox(), 100); + expect(onEndReached).not.toHaveBeenCalled(); + scrollTo(listbox(), 275); + scrollTo(listbox(), 290); + expect(onEndReached).toHaveBeenCalledTimes(1); + expect(onAtEndChange).toHaveBeenLastCalledWith(true); + scrollTo(listbox(), 0); + expect(onAtEndChange).toHaveBeenLastCalledWith(false); + scrollTo(listbox(), 300); + expect(onEndReached).toHaveBeenCalledTimes(2); + }); + + it("shows the total count, with a spinner while the next page loads", async () => { + const user = userEvent.setup(); + render( + <PagedSelector label="Targets" options={OPTIONS} totalCount={42} isLoadingMore />, + ); + await user.click(trigger()); + expect( + screen.getAllByText("Total 42 items", { exact: false }).length, + ).toBeGreaterThan(0); + }); + + it("renders a footer function with close", async () => { + const user = userEvent.setup(); + render( + <PagedSelector + label="Targets" + options={OPTIONS} + footer={(close) => ( + <button type="button" onClick={close}> + done + </button> + )} + />, + ); + await user.click(trigger()); + await user.click(screen.getByRole("button", { ...h, name: "done" })); + expect(trigger()).toHaveAttribute("aria-expanded", "false"); + }); +}); + +describe("PagedSelector popup surface", () => { + it("drops ComplexSelector's content padding through contentXstyle", async () => { + const user = userEvent.setup(); + const { container } = render(<PagedSelector label="Targets" options={OPTIONS} />); + await user.click(trigger()); + const content = listbox().closest(".astryx-complex-selector-popup > div"); + expect(content ?? container).toHaveClass(CONTENT_RESET.padding); + }); +}); + +/** + * The search row and the popup reset use class names Astryx compiled for the + * installed version, whose rules arrive with astryx.css. An Astryx bump that + * renames them fails here instead of silently unstyling the row. + */ +describe("PagedSelector's borrowed Astryx classes", () => { + const astryxCss = readFileSync( + join(__dirname, "../../../node_modules/@astryxdesign/core/dist/astryx.css"), + "utf8", + ); + const classes = [ + ...Object.values(PANEL_SEARCH_CLASS_NAMES), + CONTENT_RESET.padding, + ].flatMap((names) => names.split(" ")); + + it.each(classes)("astryx.css defines .%s", (name) => { + expect(astryxCss).toContain(`.${name}`); + }); +}); diff --git a/src/components/PagedSelector/PagedSelector.tsx b/src/components/PagedSelector/PagedSelector.tsx new file mode 100644 index 0000000..e58a9ee --- /dev/null +++ b/src/components/PagedSelector/PagedSelector.tsx @@ -0,0 +1,546 @@ +/** + * PagedSelector + * + * A searchable selector, single or multiple, over a list that loads a page at + * a time: scrolling the panel near its end calls `onEndReached`, which is + * where a consumer loads the next page (a Relay `loadNext`, a cursor fetch). + * Search is the consumer's too: `onSearchChange` reports every keystroke. + * + * Astryx's `Selector` / `MultiSelector` mount every option and have no + * scroll callback; `Typeahead` replaces its list on each query. This is + * built on `ComplexSelector` (ui-common's copy), which owns the field, + * trigger, popover and focus return and hands the panel body back, so the + * body can own the scroll container. The body is drawn the way `Selector`'s + * is: Astryx's panel search row, then option rows with the theme's check. + * + * - `value` holds option values: a string (or null) alone, a string array + * with `isMultiple`. `onChange` also gets each chosen value's label, so a + * consumer can keep labels for values that are not on the loaded page. + * - A selected value missing from `options` (on another page, or filtered out + * by the query) is named from `labels`, then from any page it was seen on, + * then shown as the value itself. + * - Keyboard: ArrowUp/Down, Home/End move a highlight that skips disabled + * rows; Enter commits it, and Space too when there is no search box. The + * panel is `ComplexSelector`'s dialog holding a listbox, not an ARIA + * combobox. + * - Options render one DOM row each; paging is what keeps that bounded. + * + * @example + * <PagedSelector + * label="Owner" + * value={ownerId} + * onChange={(id) => setOwnerId(id)} + * options={users.map((u) => ({ value: u.id, label: u.email }))} + * onSearchChange={setQuery} + * onEndReached={() => hasNext && loadNext(10)} + * isLoadingMore={isLoadingNext} + * totalCount={count} + * /> + */ +import { useId, useLayoutEffect, useRef, useState, type ReactNode } from "react"; +import { + ComplexSelector, + type ComplexSelectorProps, +} from "../../forks/ComplexSelector"; +import { compiledStyles } from "../../forks/compiled"; +import { Divider } from "@astryxdesign/core/Divider"; +import { useIndicator } from "@astryxdesign/core/Indicator"; +import { SelectorOption } from "@astryxdesign/core/Selector"; +import { Spinner } from "@astryxdesign/core/Spinner"; +import { HStack } from "@astryxdesign/core/Stack"; +import { Text } from "@astryxdesign/core/Text"; +import { Token } from "@astryxdesign/core/Token"; +import { VisuallyHidden } from "@astryxdesign/core/VisuallyHidden"; +import { themeProps } from "@astryxdesign/core/utils"; + +import { useUicTranslator } from "../../i18n/useUicTranslator"; +import { PanelSearchInput } from "./PanelSearchInput"; +import "./PagedSelector.css"; + +export interface PagedSelectorOption { + value: string; + /** The option's name: the trigger text and the row's accessible name. */ + label: string; + /** Drawn in the row in place of `label`, for a row richer than a string. */ + labelContent?: ReactNode; + /** Leading visual (avatar, glyph). */ + icon?: ReactNode; + /** Secondary line under the label. */ + description?: ReactNode; + /** Trailing content (tokens, meta). */ + endContent?: ReactNode; + isDisabled?: boolean; +} + +/** A chosen value with its label, as `onChange` reports it. */ +export interface PagedSelectorSelectedItem { + value: string; + label: string; +} + +type Passthrough = Pick< + ComplexSelectorProps<string[]>, + | "label" + | "isLabelHidden" + | "description" + | "isOptional" + | "isRequired" + | "isDisabled" + | "isLoading" + | "status" + | "statusVariant" + | "labelTooltip" + | "size" + | "variant" + | "startIcon" + | "width" + | "placement" + | "alignment" + | "onOpenChange" + | "xstyle" + | "className" + | "style" + | "data-testid" +>; + +interface PagedSelectorBaseProps extends Passthrough { + /** The loaded options, every page so far, in order. */ + options: readonly PagedSelectorOption[]; + /** Labels for selected values that may not be among `options`. */ + labels?: Readonly<Record<string, string>>; + /** + * Placeholder while nothing is selected. + * @default the catalog's uic.PagedSelector.placeholder ("Select {label}") + */ + placeholder?: string; + /** Show the search box at the top of the panel. @default true */ + hasSearch?: boolean; + /** Controlled search text; uncontrolled without it. */ + searchValue?: string; + /** Called on every keystroke in the search box; debounce it yourself. */ + onSearchChange?: (searchValue: string) => void; + /** @default the catalog's uic.PagedSelector.searchPlaceholder ("Search") */ + searchPlaceholder?: string; + /** @default the catalog's uic.PagedSelector.searchOptions ("Search options") */ + searchLabel?: string; + /** @default the catalog's uic.PagedSelector.clearSearch ("Clear search options") */ + clearSearchLabel?: string; + /** + * Called once each time the list is scrolled to within + * `endReachedThreshold` px of its end. Load the next page here. + */ + onEndReached?: () => void; + /** Distance from the end, in px, that counts as reaching it. @default 30 */ + endReachedThreshold?: number; + /** Called when the list arrives at, or leaves, its end. */ + onAtEndChange?: (isAtEnd: boolean) => void; + /** A spinner in the foot while the next page loads. */ + isLoadingMore?: boolean; + /** Options in all pages; shows the "Total N items" foot when positive. */ + totalCount?: number; + /** @default the catalog's uic.PagedSelector.totalItems ("Total {total} items") */ + formatTotalCount?: (total: number) => string; + /** + * Replaces the empty list's content, the loading row included. + * @default the catalog's uic.PagedSelector.loading while `isLoading`, else + * uic.PagedSelector.noResults ("No results") + */ + emptyText?: ReactNode; + /** Above the option list, under the search box. */ + header?: ReactNode; + /** Below the option list, in place of the total count. A function gets `close`. */ + footer?: ReactNode | ((close: () => void) => ReactNode); + /** Height the option list scrolls within, in px. @default 260 */ + listMaxHeight?: number; + /** + * How a multiple selection shows in the trigger: `labels` ("A, B, C, +2", + * as `MultiSelector`) or `badges` (tokens). @default "labels" + */ + triggerDisplay?: "labels" | "badges"; + /** Selected items the trigger names before "+N". @default 3 */ + maxTriggerItems?: number; + /** How a selected row is marked: the theme's check at its end, or a checkbox at its start. @default "check" */ + selectionIndicator?: "check" | "checkbox"; + /** A clear button in the trigger while something is selected. */ + hasClear?: boolean; + /** Runs on clear. @default onChange(null) alone, onChange([]) with isMultiple */ + onClear?: () => void; +} + +export interface PagedSelectorSingleProps extends PagedSelectorBaseProps { + isMultiple?: false; + value?: string | null; + onChange?: (value: string | null, item: PagedSelectorSelectedItem | null) => void; +} + +export interface PagedSelectorMultipleProps extends PagedSelectorBaseProps { + isMultiple: true; + value?: readonly string[]; + onChange?: (value: string[], items: PagedSelectorSelectedItem[]) => void; +} + +export type PagedSelectorProps = PagedSelectorSingleProps | PagedSelectorMultipleProps; + +/** + * `ComplexSelector` insets its panel body by `--spacing-3`; this body brings + * Selector's own gutters, so the inset goes. Astryx's compiled `padding: 0` + * class (kmVPX3 is StyleX's key for `padding`), so no StyleX compiler is + * needed; the test suite fails if astryx.css stops defining it. + */ +export const CONTENT_RESET = { padding: "x1717udv" } as const; +const contentStyles = compiledStyles({ + reset: { kmVPX3: CONTENT_RESET.padding, $$css: true }, +}); + +export function PagedSelector(props: PagedSelectorProps) { + const { + label, + options, + labels, + placeholder, + hasSearch = true, + searchValue, + onSearchChange, + searchPlaceholder, + searchLabel, + clearSearchLabel, + onEndReached, + endReachedThreshold = 30, + onAtEndChange, + isLoadingMore, + totalCount, + formatTotalCount, + emptyText, + header, + footer, + listMaxHeight = 260, + triggerDisplay = "labels", + maxTriggerItems = 3, + selectionIndicator = "check", + hasClear, + onClear, + onOpenChange, + isLoading, + size, + isMultiple, + value: _value, + onChange: _onChange, + ...rest + } = props; + const t = useUicTranslator(); + const CheckMark = useIndicator("check"); + const CheckboxMark = useIndicator("checkbox"); + const listboxId = useId(); + const optionIdPrefix = useId(); + + const selectedValues: string[] = props.isMultiple + ? [...(props.value ?? [])] + : props.value != null + ? [props.value] + : []; + + // Labels of every option seen, so a value picked from a page the list has + // since dropped (a new query) keeps its name. + const seenLabels = useRef(new Map<string, string>()); + for (const option of options) seenLabels.current.set(option.value, option.label); + const labelOf = (value: string) => + labels?.[value] ?? + options.find((option) => option.value === value)?.label ?? + seenLabels.current.get(value) ?? + value; + + const emit = (values: string[]) => { + const items = values.map((value) => ({ value, label: labelOf(value) })); + if (props.isMultiple) props.onChange?.(values, items); + else props.onChange?.(values[0] ?? null, items[0] ?? null); + }; + + // -1: an opened Astryx Selector highlights nothing until the pointer or an + // arrow key picks a row. + const [highlightedIndex, setHighlightedIndex] = useState(-1); + const [internalSearch, setInternalSearch] = useState(""); + const search = searchValue ?? internalSearch; + const isAtEnd = useRef(false); + + const optionIdOf = (index: number) => `${optionIdPrefix}-option-${index}`; + const clampedIndex = + options.length === 0 || highlightedIndex < 0 + ? -1 + : Math.min(highlightedIndex, options.length - 1); + // A row that went disabled under a page append does not keep the highlight. + const activeIndex = + clampedIndex >= 0 && options[clampedIndex]?.isDisabled ? -1 : clampedIndex; + + const nextEnabledIndex = (from: number, step: 1 | -1) => { + for (let i = from; i >= 0 && i < options.length; i += step) { + if (!options[i]?.isDisabled) return i; + } + return -1; + }; + + useLayoutEffect(() => { + if (activeIndex < 0) return; + document + .getElementById(optionIdOf(activeIndex)) + ?.scrollIntoView({ block: "nearest" }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeIndex]); + + const handleScroll = (event: React.UIEvent<HTMLDivElement>) => { + if (!onEndReached && !onAtEndChange) return; + const el = event.currentTarget; + const isAtEndNow = + el.scrollHeight - el.scrollTop - el.clientHeight <= endReachedThreshold; + if (isAtEndNow === isAtEnd.current) return; + isAtEnd.current = isAtEndNow; + onAtEndChange?.(isAtEndNow); + if (isAtEndNow) onEndReached?.(); + }; + + const isSelected = (value: string) => selectedValues.includes(value); + + const commit = (option: PagedSelectorOption, close: () => void) => { + if (!isMultiple) { + emit([option.value]); + close(); + return; + } + emit( + isSelected(option.value) + ? selectedValues.filter((value) => value !== option.value) + : [...selectedValues, option.value], + ); + }; + + // Shared by the search box and, with no search box, the listbox itself. + // Space commits only where it is not typing into the search box. + const handleNavKeyDown = ( + event: React.KeyboardEvent<HTMLElement>, + close: () => void, + hasSpaceCommit: boolean, + ) => { + if (options.length === 0) return; + const moveTo = (index: number) => { + if (index >= 0) setHighlightedIndex(index); + }; + switch (event.key) { + case "ArrowDown": + event.preventDefault(); + moveTo(nextEnabledIndex(activeIndex < 0 ? 0 : activeIndex + 1, 1)); + break; + case "ArrowUp": + event.preventDefault(); + moveTo( + activeIndex < 0 + ? nextEnabledIndex(0, 1) + : nextEnabledIndex(activeIndex - 1, -1), + ); + break; + case "Home": + event.preventDefault(); + moveTo(nextEnabledIndex(0, 1)); + break; + case "End": + event.preventDefault(); + moveTo(nextEnabledIndex(options.length - 1, -1)); + break; + case " ": + case "Enter": { + if (event.key === " " && !hasSpaceCommit) break; + const option = options[activeIndex]; + if (!option || option.isDisabled) break; + event.preventDefault(); + commit(option, close); + break; + } + default: + break; + } + }; + + const triggerLabel = (() => { + if (selectedValues.length === 0) return undefined; + if (!isMultiple) return labelOf(selectedValues[0]!); + const shown = selectedValues.slice(0, maxTriggerItems); + const remaining = selectedValues.length - shown.length; + if (triggerDisplay === "badges") { + // Display-only: the trigger is ComplexSelector's own <button>, so a + // removable token would nest a button in a button. + return ( + <HStack gap={0.5} vAlign="center" wrap="wrap"> + {shown.map((value) => ( + <Token key={value} label={labelOf(value)} size="sm" /> + ))} + {remaining > 0 ? <Text color="secondary">{`+${remaining}`}</Text> : null} + </HStack> + ); + } + const joined = shown.map(labelOf).join(", "); + return remaining > 0 ? `${joined}, +${remaining}` : joined; + })(); + + const totalText = (total: number) => + formatTotalCount?.(total) ?? t("uic.PagedSelector.totalItems", { total }); + const loadingText = t("uic.PagedSelector.loading"); + + return ( + <ComplexSelector<string[]> + {...rest} + label={label} + value={selectedValues} + onChange={emit} + triggerLabel={triggerLabel} + placeholder={placeholder ?? t("uic.PagedSelector.placeholder", { label })} + isLoading={isLoading} + size={size} + hasClear={hasClear} + onClear={onClear ?? (() => emit([]))} + onOpenChange={(isOpen) => { + // Astryx drops its highlight when the panel closes, so the next open + // starts clean. + if (!isOpen) setHighlightedIndex(-1); + onOpenChange?.(isOpen); + }} + contentXstyle={contentStyles.reset} + > + {(_current, _commit, close) => ( + <div> + {hasSearch ? ( + <> + <PanelSearchInput + label={searchLabel ?? t("uic.PagedSelector.searchOptions")} + clearLabel={clearSearchLabel ?? t("uic.PagedSelector.clearSearch")} + placeholder={ + searchPlaceholder ?? t("uic.PagedSelector.searchPlaceholder") + } + value={search} + onValueChange={(next) => { + setInternalSearch(next); + // Seeds the first selectable row, so type-then-Enter works. + setHighlightedIndex(nextEnabledIndex(0, 1)); + onSearchChange?.(next); + }} + onKeyDown={(event) => handleNavKeyDown(event, close, false)} + aria-controls={listboxId} + aria-activedescendant={ + activeIndex >= 0 ? optionIdOf(activeIndex) : undefined + } + /> + <Divider /> + </> + ) : null} + {header} + <div + id={listboxId} + role="listbox" + aria-label={label} + aria-multiselectable={isMultiple || undefined} + // With no search box nothing else in the panel takes focus, so the + // popover's autofocus lands here and the arrows work. + tabIndex={hasSearch ? undefined : 0} + aria-activedescendant={ + !hasSearch && activeIndex >= 0 ? optionIdOf(activeIndex) : undefined + } + onKeyDown={ + hasSearch ? undefined : (event) => handleNavKeyDown(event, close, true) + } + onScroll={handleScroll} + className="uic-paged-selector__listbox" + style={{ maxHeight: listMaxHeight }} + > + {options.length === 0 ? ( + <div className="uic-paged-selector__empty"> + {emptyText !== undefined ? ( + emptyText + ) : isLoading ? ( + <HStack gap={1} vAlign="center" hAlign="center"> + <Spinner size="sm" /> + <Text color="secondary">{loadingText}</Text> + </HStack> + ) : ( + <Text color="secondary">{t("uic.PagedSelector.noResults")}</Text> + )} + </div> + ) : ( + options.map((option, index) => { + const selected = isSelected(option.value); + const isDisabled = option.isDisabled ?? false; + return ( + <div + key={option.value} + id={optionIdOf(index)} + role="option" + aria-selected={selected} + aria-disabled={isDisabled || undefined} + className="uic-paged-selector__option" + data-size={size === "sm" ? "sm" : undefined} + data-selected={selected ? "true" : undefined} + data-highlighted={index === activeIndex ? "true" : undefined} + data-disabled={isDisabled ? "true" : undefined} + onClick={() => { + if (isDisabled) return; + setHighlightedIndex(index); + commit(option, close); + }} + onMouseEnter={() => { + if (!isDisabled) setHighlightedIndex(index); + }} + > + {selectionIndicator === "checkbox" && ( + <span className="uic-paged-selector__option-mark"> + <CheckboxMark + state={selected ? "checked" : "unchecked"} + size="sm" + isDisabled={isDisabled} + /> + </span> + )} + <span className="uic-paged-selector__option-content"> + <SelectorOption + icon={option.icon} + label={option.labelContent ?? option.label} + description={option.description} + endContent={option.endContent} + /> + </span> + {/* Rendered with the state passed down: a theme that swaps + the check for a radio draws its empty circle too. */} + {selectionIndicator === "check" && ( + <span className="uic-paged-selector__option-mark"> + <CheckMark + state={selected ? "checked" : "unchecked"} + size="sm" + isDisabled={isDisabled} + {...themeProps("selector-check")} + /> + </span> + )} + </div> + ); + }) + )} + </div> + {(typeof footer === "function" ? footer(close) : footer) ?? + (typeof totalCount === "number" && totalCount > 0 ? ( + <HStack + gap={1} + vAlign="center" + hAlign="end" + className="uic-paged-selector__foot" + > + {isLoadingMore ? <Spinner size="sm" /> : null} + <Text color="secondary" size="sm"> + {totalText(totalCount)} + </Text> + </HStack> + ) : null)} + <VisuallyHidden as="div" aria-live="polite"> + {options.length === 0 && isLoading + ? loadingText + : totalText(options.length)} + </VisuallyHidden> + </div> + )} + </ComplexSelector> + ); +} diff --git a/src/components/PagedSelector/PanelSearchInput.tsx b/src/components/PagedSelector/PanelSearchInput.tsx new file mode 100644 index 0000000..b92ef60 --- /dev/null +++ b/src/components/PagedSelector/PanelSearchInput.tsx @@ -0,0 +1,181 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. +// Modifications copyright (c) Lablup Inc. +// +// Adapted from @astryxdesign/core 0.6.2, dist/Field/PanelSearchInput.js and +// dist/utils/interactionModality.js (MIT; see NOTICE). Neither is exported by +// the package; Selector and MultiSelector use them for their search row. + +"use client"; + +/** + * The search row at the top of PagedSelector's panel: magnifier, borderless + * input, and a clear button once a query is typed, in a rounded box shaped + * like the option rows beneath it. Astryx's own, so the row reads exactly as + * `Selector`'s does. + * + * It renders with the class names Astryx compiled for this version (their + * rules arrive with `astryx.css`), so it needs no stylesheet and no StyleX + * compiler. The keyboard-only focus ring is Astryx's too. The test suite + * fails when an Astryx bump drops one of these classes. + */ +import { + useCallback, + useEffect, + useState, + type KeyboardEventHandler, + type Ref, +} from "react"; +import { InputClearButton } from "@astryxdesign/core/Field"; +import { Icon } from "@astryxdesign/core/Icon"; + +import { compiledStyles } from "../../forks/compiled"; + +/** Astryx's compiled classes, from dist/Field/PanelSearchInput.js and Selector's `searchRowInput`. */ +export const PANEL_SEARCH_CLASS_NAMES = { + /** `styles.wrapper` with Selector's popover `searchRowInput` padding. */ + wrapper: "xu0wf1k x2hg6jq", + field: + "x9f619 x78zum5 x6s0dn4 x1txdalj xh8yej3 x1vofgu7 xf314gf xh6dtrn xkdsq27 xuedmi6 x12w9bfk xlr8y92", + /** Added to `field` while focus arrived by keyboard: draws the inset ring. */ + fieldKeyboardFocus: "x1gnnqk1 x1btxeh5", + input: + "x1iyjqo2 xs83m0k xeuugli x1717udv x1ghz6dp xc342km xng3xce xjbqb8w x1tgivj0 x9ynric xcr08ib xdk3u5w x1kq96og x1a2a7pz xeyghm5", +} as const; + +const iconStyles = compiledStyles({ + icon: { k1xSpc: "x78zum5", kGNEyG: "x6s0dn4", kmuXW: "x2lah0s", $$css: true }, +}); + +// Astryx's interaction-modality store, shared through the same document +// symbol, so this copy and Astryx's agree on how the user last interacted. +// `:focus-visible` matches a text input focused by pointer too, so the ring +// is gated on the last modality being the keyboard. +type Modality = "keyboard" | "pointer"; +type ModalityStore = { + modality: Modality; + isListening: boolean; + onPointerDown: () => void; + onKeyDown: (event: KeyboardEvent) => void; +}; +const MODALITY_STORE_KEY = Symbol.for("@astryxdesign/core/interaction-modality/v1"); + +function getModalityStore(doc: Document): ModalityStore { + const holder = doc as unknown as Record<symbol, ModalityStore | undefined>; + const existing = holder[MODALITY_STORE_KEY]; + if (existing != null) return existing; + const store: ModalityStore = { + modality: "keyboard", + isListening: false, + onPointerDown: () => { + store.modality = "pointer"; + }, + onKeyDown: (event) => { + if (event.metaKey || event.altKey || event.ctrlKey) return; + store.modality = "keyboard"; + }, + }; + Object.defineProperty(doc, MODALITY_STORE_KEY, { value: store }); + return store; +} + +function trackInteractionModality(): void { + if (typeof document === "undefined") return; + const store = getModalityStore(document); + if (store.isListening) return; + store.isListening = true; + document.addEventListener("pointerdown", store.onPointerDown, { + capture: true, + passive: true, + }); + document.addEventListener("keydown", store.onKeyDown, { + capture: true, + passive: true, + }); +} + +function getInteractionModality(): Modality { + return typeof document === "undefined" + ? "keyboard" + : getModalityStore(document).modality; +} + +export interface PanelSearchInputProps { + ref?: Ref<HTMLInputElement>; + label: string; + clearLabel: string; + placeholder?: string; + value: string; + onValueChange: (value: string) => void; + onKeyDown?: KeyboardEventHandler<HTMLInputElement>; + "aria-controls"?: string; + "aria-activedescendant"?: string; +} + +export function PanelSearchInput({ + ref, + label, + clearLabel, + placeholder, + value, + onValueChange, + onKeyDown, + ...ariaProps +}: PanelSearchInputProps) { + const [isKeyboardFocus, setIsKeyboardFocus] = useState(false); + const [input, setInput] = useState<HTMLInputElement | null>(null); + + useEffect(() => trackInteractionModality(), []); + + const attachInput = useCallback( + (node: HTMLInputElement | null) => { + setInput(node); + if (typeof ref === "function") ref(node); + else if (ref != null) + (ref as { current: HTMLInputElement | null }).current = node; + }, + [ref], + ); + + const handleClear = useCallback( + (event?: React.MouseEvent) => { + onValueChange(""); + // Upstream: keyboard keeps focus synchronously; a tap defers it past the + // button's unmount so touch browsers do not jump the page. + if (!event || event.detail === 0) input?.focus(); + else requestAnimationFrame(() => input?.focus({ preventScroll: true })); + }, + [input, onValueChange], + ); + + return ( + <div className={PANEL_SEARCH_CLASS_NAMES.wrapper}> + <div + data-keyboard-focus={isKeyboardFocus ? "true" : undefined} + className={ + isKeyboardFocus + ? `${PANEL_SEARCH_CLASS_NAMES.field} ${PANEL_SEARCH_CLASS_NAMES.fieldKeyboardFocus}` + : PANEL_SEARCH_CLASS_NAMES.field + } + > + <Icon icon="search" size="sm" color="secondary" xstyle={iconStyles.icon} /> + <input + ref={attachInput} + type="text" + aria-label={label} + placeholder={placeholder} + value={value} + onChange={(event) => onValueChange(event.target.value)} + onKeyDown={onKeyDown} + onFocus={() => setIsKeyboardFocus(getInteractionModality() === "keyboard")} + onBlur={() => setIsKeyboardFocus(false)} + className={PANEL_SEARCH_CLASS_NAMES.input} + role="combobox" + aria-expanded + aria-autocomplete="list" + {...ariaProps} + /> + {value !== "" && <InputClearButton label={clearLabel} onClick={handleClear} />} + </div> + </div> + ); +} diff --git a/src/components/PagedSelector/index.ts b/src/components/PagedSelector/index.ts new file mode 100644 index 0000000..c84eedd --- /dev/null +++ b/src/components/PagedSelector/index.ts @@ -0,0 +1,8 @@ +export { PagedSelector } from "./PagedSelector"; +export type { + PagedSelectorMultipleProps, + PagedSelectorOption, + PagedSelectorProps, + PagedSelectorSelectedItem, + PagedSelectorSingleProps, +} from "./PagedSelector"; diff --git a/src/i18n/catalog.ts b/src/i18n/catalog.ts index 2ff5e10..ace096b 100644 --- a/src/i18n/catalog.ts +++ b/src/i18n/catalog.ts @@ -33,6 +33,7 @@ import { deleteConfirmModalMessages } from "../components/DeleteConfirmModal/Del import { formMessages } from "../components/Form/Form.messages"; import { notificationStackMessages } from "../components/NotificationStack/NotificationStack.messages"; import { pageHeaderMessages } from "../components/PageHeader/PageHeader.messages"; +import { pagedSelectorMessages } from "../components/PagedSelector/PagedSelector.messages"; import { selectionLabelMessages } from "../components/SelectionLabel/SelectionLabel.messages"; import { skeletonMessages } from "../components/Skeleton/Skeleton.messages"; import { statisticMessages } from "../components/Statistic/Statistic.messages"; @@ -70,6 +71,7 @@ export const uiCommonCatalog: Catalog = { ...notificationStackMessages, ...numberStepperMessages, ...pageHeaderMessages, + ...pagedSelectorMessages, ...selectionLabelMessages, ...skeletonMessages, ...statisticMessages, diff --git a/src/i18n/locales/de-DE.json b/src/i18n/locales/de-DE.json index c9a93a0..ac45fa0 100644 --- a/src/i18n/locales/de-DE.json +++ b/src/i18n/locales/de-DE.json @@ -294,5 +294,33 @@ "uic.DataGrid.export": { "defaultMessage": "Exportieren", "description": "Primary button of a data grid's CSV export dialog" + }, + "uic.PagedSelector.placeholder": { + "defaultMessage": "{label} auswählen", + "description": "Placeholder of a selector with nothing selected; {label} is the field's label" + }, + "uic.PagedSelector.searchOptions": { + "defaultMessage": "Optionen durchsuchen", + "description": "Accessible name of the search box at the top of a selector's option list" + }, + "uic.PagedSelector.searchPlaceholder": { + "defaultMessage": "Suchen", + "description": "Placeholder of the search box at the top of a selector's option list" + }, + "uic.PagedSelector.clearSearch": { + "defaultMessage": "Optionen durchsuchen löschen", + "description": "Accessible name of the button that empties a selector's search box" + }, + "uic.PagedSelector.noResults": { + "defaultMessage": "Keine Ergebnisse", + "description": "Shown in a selector's option list when no option matches" + }, + "uic.PagedSelector.loading": { + "defaultMessage": "Wird geladen...", + "description": "Shown in a selector's empty option list while options are being fetched" + }, + "uic.PagedSelector.totalItems": { + "defaultMessage": "Total {total} Elemente", + "description": "Foot of a selector's option list; {total} is how many options exist in all pages" } } diff --git a/src/i18n/locales/el-GR.json b/src/i18n/locales/el-GR.json index 92dfcb4..4ee6c48 100644 --- a/src/i18n/locales/el-GR.json +++ b/src/i18n/locales/el-GR.json @@ -294,5 +294,33 @@ "uic.DataGrid.export": { "defaultMessage": "Εξαγωγή", "description": "Primary button of a data grid's CSV export dialog" + }, + "uic.PagedSelector.placeholder": { + "defaultMessage": "Επιλέξτε {label}", + "description": "Placeholder of a selector with nothing selected; {label} is the field's label" + }, + "uic.PagedSelector.searchOptions": { + "defaultMessage": "Αναζήτηση επιλογών", + "description": "Accessible name of the search box at the top of a selector's option list" + }, + "uic.PagedSelector.searchPlaceholder": { + "defaultMessage": "Αναζήτηση", + "description": "Placeholder of the search box at the top of a selector's option list" + }, + "uic.PagedSelector.clearSearch": { + "defaultMessage": "Εκκαθάριση Αναζήτηση επιλογών", + "description": "Accessible name of the button that empties a selector's search box" + }, + "uic.PagedSelector.noResults": { + "defaultMessage": "Δεν βρέθηκαν αποτελέσματα", + "description": "Shown in a selector's option list when no option matches" + }, + "uic.PagedSelector.loading": { + "defaultMessage": "Φόρτωση...", + "description": "Shown in a selector's empty option list while options are being fetched" + }, + "uic.PagedSelector.totalItems": { + "defaultMessage": "Σύνολο {total} στοιχεία", + "description": "Foot of a selector's option list; {total} is how many options exist in all pages" } } diff --git a/src/i18n/locales/es-ES.json b/src/i18n/locales/es-ES.json index 7fa645f..47b8e97 100644 --- a/src/i18n/locales/es-ES.json +++ b/src/i18n/locales/es-ES.json @@ -294,5 +294,33 @@ "uic.DataGrid.export": { "defaultMessage": "Exportar", "description": "Primary button of a data grid's CSV export dialog" + }, + "uic.PagedSelector.placeholder": { + "defaultMessage": "Seleccione {label}", + "description": "Placeholder of a selector with nothing selected; {label} is the field's label" + }, + "uic.PagedSelector.searchOptions": { + "defaultMessage": "Buscar opciones", + "description": "Accessible name of the search box at the top of a selector's option list" + }, + "uic.PagedSelector.searchPlaceholder": { + "defaultMessage": "Buscar", + "description": "Placeholder of the search box at the top of a selector's option list" + }, + "uic.PagedSelector.clearSearch": { + "defaultMessage": "Borrar Buscar opciones", + "description": "Accessible name of the button that empties a selector's search box" + }, + "uic.PagedSelector.noResults": { + "defaultMessage": "Sin resultados", + "description": "Shown in a selector's option list when no option matches" + }, + "uic.PagedSelector.loading": { + "defaultMessage": "Cargando...", + "description": "Shown in a selector's empty option list while options are being fetched" + }, + "uic.PagedSelector.totalItems": { + "defaultMessage": "Total {total} elementos", + "description": "Foot of a selector's option list; {total} is how many options exist in all pages" } } diff --git a/src/i18n/locales/fi-FI.json b/src/i18n/locales/fi-FI.json index 982f4f8..a55c948 100644 --- a/src/i18n/locales/fi-FI.json +++ b/src/i18n/locales/fi-FI.json @@ -294,5 +294,33 @@ "uic.DataGrid.export": { "defaultMessage": "Vie", "description": "Primary button of a data grid's CSV export dialog" + }, + "uic.PagedSelector.placeholder": { + "defaultMessage": "Valitse {label}", + "description": "Placeholder of a selector with nothing selected; {label} is the field's label" + }, + "uic.PagedSelector.searchOptions": { + "defaultMessage": "Hae vaihtoehdoista", + "description": "Accessible name of the search box at the top of a selector's option list" + }, + "uic.PagedSelector.searchPlaceholder": { + "defaultMessage": "Hae", + "description": "Placeholder of the search box at the top of a selector's option list" + }, + "uic.PagedSelector.clearSearch": { + "defaultMessage": "Tyhjennä Hae vaihtoehdoista", + "description": "Accessible name of the button that empties a selector's search box" + }, + "uic.PagedSelector.noResults": { + "defaultMessage": "Ei tuloksia", + "description": "Shown in a selector's option list when no option matches" + }, + "uic.PagedSelector.loading": { + "defaultMessage": "Ladataan...", + "description": "Shown in a selector's empty option list while options are being fetched" + }, + "uic.PagedSelector.totalItems": { + "defaultMessage": "Yhteensä {total} kohteet", + "description": "Foot of a selector's option list; {total} is how many options exist in all pages" } } diff --git a/src/i18n/locales/fr-FR.json b/src/i18n/locales/fr-FR.json index acf5812..3e47ea0 100644 --- a/src/i18n/locales/fr-FR.json +++ b/src/i18n/locales/fr-FR.json @@ -294,5 +294,33 @@ "uic.DataGrid.export": { "defaultMessage": "Exporter", "description": "Primary button of a data grid's CSV export dialog" + }, + "uic.PagedSelector.placeholder": { + "defaultMessage": "Sélectionnez {label}", + "description": "Placeholder of a selector with nothing selected; {label} is the field's label" + }, + "uic.PagedSelector.searchOptions": { + "defaultMessage": "Rechercher parmi les options", + "description": "Accessible name of the search box at the top of a selector's option list" + }, + "uic.PagedSelector.searchPlaceholder": { + "defaultMessage": "Rechercher", + "description": "Placeholder of the search box at the top of a selector's option list" + }, + "uic.PagedSelector.clearSearch": { + "defaultMessage": "Effacer Rechercher parmi les options", + "description": "Accessible name of the button that empties a selector's search box" + }, + "uic.PagedSelector.noResults": { + "defaultMessage": "Aucun résultat", + "description": "Shown in a selector's option list when no option matches" + }, + "uic.PagedSelector.loading": { + "defaultMessage": "Chargement...", + "description": "Shown in a selector's empty option list while options are being fetched" + }, + "uic.PagedSelector.totalItems": { + "defaultMessage": "Éléments totaux {total}", + "description": "Foot of a selector's option list; {total} is how many options exist in all pages" } } diff --git a/src/i18n/locales/id-ID.json b/src/i18n/locales/id-ID.json index 3dd8a27..dcd8dec 100644 --- a/src/i18n/locales/id-ID.json +++ b/src/i18n/locales/id-ID.json @@ -294,5 +294,33 @@ "uic.DataGrid.export": { "defaultMessage": "Ekspor", "description": "Primary button of a data grid's CSV export dialog" + }, + "uic.PagedSelector.placeholder": { + "defaultMessage": "Pilih {label}", + "description": "Placeholder of a selector with nothing selected; {label} is the field's label" + }, + "uic.PagedSelector.searchOptions": { + "defaultMessage": "Cari opsi", + "description": "Accessible name of the search box at the top of a selector's option list" + }, + "uic.PagedSelector.searchPlaceholder": { + "defaultMessage": "Cari", + "description": "Placeholder of the search box at the top of a selector's option list" + }, + "uic.PagedSelector.clearSearch": { + "defaultMessage": "Hapus Cari opsi", + "description": "Accessible name of the button that empties a selector's search box" + }, + "uic.PagedSelector.noResults": { + "defaultMessage": "Tidak ada hasil", + "description": "Shown in a selector's option list when no option matches" + }, + "uic.PagedSelector.loading": { + "defaultMessage": "Memuat...", + "description": "Shown in a selector's empty option list while options are being fetched" + }, + "uic.PagedSelector.totalItems": { + "defaultMessage": "Total {total} item", + "description": "Foot of a selector's option list; {total} is how many options exist in all pages" } } diff --git a/src/i18n/locales/it-IT.json b/src/i18n/locales/it-IT.json index 89f8565..9ce8255 100644 --- a/src/i18n/locales/it-IT.json +++ b/src/i18n/locales/it-IT.json @@ -294,5 +294,33 @@ "uic.DataGrid.export": { "defaultMessage": "Esporta", "description": "Primary button of a data grid's CSV export dialog" + }, + "uic.PagedSelector.placeholder": { + "defaultMessage": "Seleziona {label}", + "description": "Placeholder of a selector with nothing selected; {label} is the field's label" + }, + "uic.PagedSelector.searchOptions": { + "defaultMessage": "Cerca tra le opzioni", + "description": "Accessible name of the search box at the top of a selector's option list" + }, + "uic.PagedSelector.searchPlaceholder": { + "defaultMessage": "Cerca", + "description": "Placeholder of the search box at the top of a selector's option list" + }, + "uic.PagedSelector.clearSearch": { + "defaultMessage": "Cancella Cerca tra le opzioni", + "description": "Accessible name of the button that empties a selector's search box" + }, + "uic.PagedSelector.noResults": { + "defaultMessage": "Nessun risultato", + "description": "Shown in a selector's option list when no option matches" + }, + "uic.PagedSelector.loading": { + "defaultMessage": "Caricamento...", + "description": "Shown in a selector's empty option list while options are being fetched" + }, + "uic.PagedSelector.totalItems": { + "defaultMessage": "Totali {total} elementi", + "description": "Foot of a selector's option list; {total} is how many options exist in all pages" } } diff --git a/src/i18n/locales/ja-JP.json b/src/i18n/locales/ja-JP.json index a6584c9..7eb07ff 100644 --- a/src/i18n/locales/ja-JP.json +++ b/src/i18n/locales/ja-JP.json @@ -91,6 +91,34 @@ "defaultMessage": "エラーを閉じる", "description": "Accessible name of the icon button that closes the error banner" }, + "uic.PagedSelector.placeholder": { + "defaultMessage": "{label}を選択してください", + "description": "Placeholder of a selector with nothing selected; {label} is the field's label" + }, + "uic.PagedSelector.searchOptions": { + "defaultMessage": "オプションを検索", + "description": "Accessible name of the search box at the top of a selector's option list" + }, + "uic.PagedSelector.searchPlaceholder": { + "defaultMessage": "検索", + "description": "Placeholder of the search box at the top of a selector's option list" + }, + "uic.PagedSelector.clearSearch": { + "defaultMessage": "オプションを検索 をクリア", + "description": "Accessible name of the button that empties a selector's search box" + }, + "uic.PagedSelector.noResults": { + "defaultMessage": "結果がありません", + "description": "Shown in a selector's option list when no option matches" + }, + "uic.PagedSelector.loading": { + "defaultMessage": "読み込み中...", + "description": "Shown in a selector's empty option list while options are being fetched" + }, + "uic.PagedSelector.totalItems": { + "defaultMessage": "合計 {total}アイテム", + "description": "Foot of a selector's option list; {total} is how many options exist in all pages" + }, "uic.SelectionLabel.selectedCount": { "defaultMessage": "{count}選択", "description": "How many items are selected, e.g. rows of a table; {count} is the number" diff --git a/src/i18n/locales/ko-KR.json b/src/i18n/locales/ko-KR.json index 02cbcbf..61ab544 100644 --- a/src/i18n/locales/ko-KR.json +++ b/src/i18n/locales/ko-KR.json @@ -91,6 +91,34 @@ "defaultMessage": "오류 닫기", "description": "Accessible name of the icon button that closes the error banner" }, + "uic.PagedSelector.placeholder": { + "defaultMessage": "{label}을(를) 선택해주세요", + "description": "Placeholder of a selector with nothing selected; {label} is the field's label" + }, + "uic.PagedSelector.searchOptions": { + "defaultMessage": "옵션 검색", + "description": "Accessible name of the search box at the top of a selector's option list" + }, + "uic.PagedSelector.searchPlaceholder": { + "defaultMessage": "검색", + "description": "Placeholder of the search box at the top of a selector's option list" + }, + "uic.PagedSelector.clearSearch": { + "defaultMessage": "옵션 검색 지우기", + "description": "Accessible name of the button that empties a selector's search box" + }, + "uic.PagedSelector.noResults": { + "defaultMessage": "결과 없음", + "description": "Shown in a selector's option list when no option matches" + }, + "uic.PagedSelector.loading": { + "defaultMessage": "로딩 중...", + "description": "Shown in a selector's empty option list while options are being fetched" + }, + "uic.PagedSelector.totalItems": { + "defaultMessage": "총 {total} 항목", + "description": "Foot of a selector's option list; {total} is how many options exist in all pages" + }, "uic.SelectionLabel.selectedCount": { "defaultMessage": "{count}개 선택됨", "description": "How many items are selected, e.g. rows of a table; {count} is the number" diff --git a/src/i18n/locales/mn-MN.json b/src/i18n/locales/mn-MN.json index 6101c74..0a28e39 100644 --- a/src/i18n/locales/mn-MN.json +++ b/src/i18n/locales/mn-MN.json @@ -290,5 +290,33 @@ "uic.DataGrid.export": { "defaultMessage": "Экспорт", "description": "Primary button of a data grid's CSV export dialog" + }, + "uic.PagedSelector.placeholder": { + "defaultMessage": "{label} сонгоно уу", + "description": "Placeholder of a selector with nothing selected; {label} is the field's label" + }, + "uic.PagedSelector.searchOptions": { + "defaultMessage": "Сонголтуудаас хайх", + "description": "Accessible name of the search box at the top of a selector's option list" + }, + "uic.PagedSelector.searchPlaceholder": { + "defaultMessage": "Хайх", + "description": "Placeholder of the search box at the top of a selector's option list" + }, + "uic.PagedSelector.clearSearch": { + "defaultMessage": "Сонголтуудаас хайх-г цэвэрлэх", + "description": "Accessible name of the button that empties a selector's search box" + }, + "uic.PagedSelector.noResults": { + "defaultMessage": "Үр дүн олдсонгүй", + "description": "Shown in a selector's option list when no option matches" + }, + "uic.PagedSelector.loading": { + "defaultMessage": "Ачаалж байна...", + "description": "Shown in a selector's empty option list while options are being fetched" + }, + "uic.PagedSelector.totalItems": { + "defaultMessage": "Нийт {total} зүйлүүд", + "description": "Foot of a selector's option list; {total} is how many options exist in all pages" } } diff --git a/src/i18n/locales/ms-MY.json b/src/i18n/locales/ms-MY.json index a627237..2bb9445 100644 --- a/src/i18n/locales/ms-MY.json +++ b/src/i18n/locales/ms-MY.json @@ -294,5 +294,33 @@ "uic.DataGrid.export": { "defaultMessage": "Eksport", "description": "Primary button of a data grid's CSV export dialog" + }, + "uic.PagedSelector.placeholder": { + "defaultMessage": "Pilih {label}", + "description": "Placeholder of a selector with nothing selected; {label} is the field's label" + }, + "uic.PagedSelector.searchOptions": { + "defaultMessage": "Cari pilihan", + "description": "Accessible name of the search box at the top of a selector's option list" + }, + "uic.PagedSelector.searchPlaceholder": { + "defaultMessage": "Cari", + "description": "Placeholder of the search box at the top of a selector's option list" + }, + "uic.PagedSelector.clearSearch": { + "defaultMessage": "Kosongkan Cari pilihan", + "description": "Accessible name of the button that empties a selector's search box" + }, + "uic.PagedSelector.noResults": { + "defaultMessage": "Tiada keputusan", + "description": "Shown in a selector's option list when no option matches" + }, + "uic.PagedSelector.loading": { + "defaultMessage": "Memuatkan...", + "description": "Shown in a selector's empty option list while options are being fetched" + }, + "uic.PagedSelector.totalItems": { + "defaultMessage": "Jumlah {total} item", + "description": "Foot of a selector's option list; {total} is how many options exist in all pages" } } diff --git a/src/i18n/locales/pl-PL.json b/src/i18n/locales/pl-PL.json index 9bf5676..f77131a 100644 --- a/src/i18n/locales/pl-PL.json +++ b/src/i18n/locales/pl-PL.json @@ -294,5 +294,33 @@ "uic.DataGrid.export": { "defaultMessage": "Eksportuj", "description": "Primary button of a data grid's CSV export dialog" + }, + "uic.PagedSelector.placeholder": { + "defaultMessage": "Wybierz {label}", + "description": "Placeholder of a selector with nothing selected; {label} is the field's label" + }, + "uic.PagedSelector.searchOptions": { + "defaultMessage": "Szukaj wśród opcji", + "description": "Accessible name of the search box at the top of a selector's option list" + }, + "uic.PagedSelector.searchPlaceholder": { + "defaultMessage": "Szukaj", + "description": "Placeholder of the search box at the top of a selector's option list" + }, + "uic.PagedSelector.clearSearch": { + "defaultMessage": "Wyczyść Szukaj wśród opcji", + "description": "Accessible name of the button that empties a selector's search box" + }, + "uic.PagedSelector.noResults": { + "defaultMessage": "Brak wyników", + "description": "Shown in a selector's option list when no option matches" + }, + "uic.PagedSelector.loading": { + "defaultMessage": "Ładowanie...", + "description": "Shown in a selector's empty option list while options are being fetched" + }, + "uic.PagedSelector.totalItems": { + "defaultMessage": "Total {total}", + "description": "Foot of a selector's option list; {total} is how many options exist in all pages" } } diff --git a/src/i18n/locales/pt-BR.json b/src/i18n/locales/pt-BR.json index 463f794..2529225 100644 --- a/src/i18n/locales/pt-BR.json +++ b/src/i18n/locales/pt-BR.json @@ -294,5 +294,33 @@ "uic.DataGrid.export": { "defaultMessage": "Exportar", "description": "Primary button of a data grid's CSV export dialog" + }, + "uic.PagedSelector.placeholder": { + "defaultMessage": "Selecione {label}", + "description": "Placeholder of a selector with nothing selected; {label} is the field's label" + }, + "uic.PagedSelector.searchOptions": { + "defaultMessage": "Pesquisar opções", + "description": "Accessible name of the search box at the top of a selector's option list" + }, + "uic.PagedSelector.searchPlaceholder": { + "defaultMessage": "Pesquisar", + "description": "Placeholder of the search box at the top of a selector's option list" + }, + "uic.PagedSelector.clearSearch": { + "defaultMessage": "Limpar Pesquisar opções", + "description": "Accessible name of the button that empties a selector's search box" + }, + "uic.PagedSelector.noResults": { + "defaultMessage": "Sem resultados", + "description": "Shown in a selector's option list when no option matches" + }, + "uic.PagedSelector.loading": { + "defaultMessage": "Carregando...", + "description": "Shown in a selector's empty option list while options are being fetched" + }, + "uic.PagedSelector.totalItems": { + "defaultMessage": "Total {total} itens", + "description": "Foot of a selector's option list; {total} is how many options exist in all pages" } } diff --git a/src/i18n/locales/pt-PT.json b/src/i18n/locales/pt-PT.json index c0564f8..1824b8f 100644 --- a/src/i18n/locales/pt-PT.json +++ b/src/i18n/locales/pt-PT.json @@ -294,5 +294,33 @@ "uic.DataGrid.export": { "defaultMessage": "Exportar", "description": "Primary button of a data grid's CSV export dialog" + }, + "uic.PagedSelector.placeholder": { + "defaultMessage": "Selecione {label}", + "description": "Placeholder of a selector with nothing selected; {label} is the field's label" + }, + "uic.PagedSelector.searchOptions": { + "defaultMessage": "Pesquisar opções", + "description": "Accessible name of the search box at the top of a selector's option list" + }, + "uic.PagedSelector.searchPlaceholder": { + "defaultMessage": "Pesquisar", + "description": "Placeholder of the search box at the top of a selector's option list" + }, + "uic.PagedSelector.clearSearch": { + "defaultMessage": "Limpar Pesquisar opções", + "description": "Accessible name of the button that empties a selector's search box" + }, + "uic.PagedSelector.noResults": { + "defaultMessage": "Sem resultados", + "description": "Shown in a selector's option list when no option matches" + }, + "uic.PagedSelector.loading": { + "defaultMessage": "A carregar...", + "description": "Shown in a selector's empty option list while options are being fetched" + }, + "uic.PagedSelector.totalItems": { + "defaultMessage": "Total {total} itens", + "description": "Foot of a selector's option list; {total} is how many options exist in all pages" } } diff --git a/src/i18n/locales/ru-RU.json b/src/i18n/locales/ru-RU.json index 9ac34dd..1cf2079 100644 --- a/src/i18n/locales/ru-RU.json +++ b/src/i18n/locales/ru-RU.json @@ -294,5 +294,33 @@ "uic.DataGrid.export": { "defaultMessage": "Экспорт", "description": "Primary button of a data grid's CSV export dialog" + }, + "uic.PagedSelector.placeholder": { + "defaultMessage": "Выберите {label}", + "description": "Placeholder of a selector with nothing selected; {label} is the field's label" + }, + "uic.PagedSelector.searchOptions": { + "defaultMessage": "Поиск по вариантам", + "description": "Accessible name of the search box at the top of a selector's option list" + }, + "uic.PagedSelector.searchPlaceholder": { + "defaultMessage": "Поиск", + "description": "Placeholder of the search box at the top of a selector's option list" + }, + "uic.PagedSelector.clearSearch": { + "defaultMessage": "Очистить Поиск по вариантам", + "description": "Accessible name of the button that empties a selector's search box" + }, + "uic.PagedSelector.noResults": { + "defaultMessage": "Нет результатов", + "description": "Shown in a selector's option list when no option matches" + }, + "uic.PagedSelector.loading": { + "defaultMessage": "Загрузка...", + "description": "Shown in a selector's empty option list while options are being fetched" + }, + "uic.PagedSelector.totalItems": { + "defaultMessage": "Total {total} элементы", + "description": "Foot of a selector's option list; {total} is how many options exist in all pages" } } diff --git a/src/i18n/locales/th-TH.json b/src/i18n/locales/th-TH.json index 85f7b6e..33455f5 100644 --- a/src/i18n/locales/th-TH.json +++ b/src/i18n/locales/th-TH.json @@ -294,5 +294,33 @@ "uic.DataGrid.export": { "defaultMessage": "ส่งออก", "description": "Primary button of a data grid's CSV export dialog" + }, + "uic.PagedSelector.placeholder": { + "defaultMessage": "เลือก{label}", + "description": "Placeholder of a selector with nothing selected; {label} is the field's label" + }, + "uic.PagedSelector.searchOptions": { + "defaultMessage": "ค้นหาตัวเลือก", + "description": "Accessible name of the search box at the top of a selector's option list" + }, + "uic.PagedSelector.searchPlaceholder": { + "defaultMessage": "ค้นหา", + "description": "Placeholder of the search box at the top of a selector's option list" + }, + "uic.PagedSelector.clearSearch": { + "defaultMessage": "ล้าง ค้นหาตัวเลือก", + "description": "Accessible name of the button that empties a selector's search box" + }, + "uic.PagedSelector.noResults": { + "defaultMessage": "ไม่พบผลลัพธ์", + "description": "Shown in a selector's option list when no option matches" + }, + "uic.PagedSelector.loading": { + "defaultMessage": "กำลังโหลด...", + "description": "Shown in a selector's empty option list while options are being fetched" + }, + "uic.PagedSelector.totalItems": { + "defaultMessage": "รวม {total} รายการ", + "description": "Foot of a selector's option list; {total} is how many options exist in all pages" } } diff --git a/src/i18n/locales/tr-TR.json b/src/i18n/locales/tr-TR.json index 9eff7d4..b65164d 100644 --- a/src/i18n/locales/tr-TR.json +++ b/src/i18n/locales/tr-TR.json @@ -294,5 +294,33 @@ "uic.DataGrid.export": { "defaultMessage": "Dışa aktar", "description": "Primary button of a data grid's CSV export dialog" + }, + "uic.PagedSelector.placeholder": { + "defaultMessage": "{label} seçin", + "description": "Placeholder of a selector with nothing selected; {label} is the field's label" + }, + "uic.PagedSelector.searchOptions": { + "defaultMessage": "Seçenekleri ara", + "description": "Accessible name of the search box at the top of a selector's option list" + }, + "uic.PagedSelector.searchPlaceholder": { + "defaultMessage": "Ara", + "description": "Placeholder of the search box at the top of a selector's option list" + }, + "uic.PagedSelector.clearSearch": { + "defaultMessage": "Seçenekleri ara alanını temizle", + "description": "Accessible name of the button that empties a selector's search box" + }, + "uic.PagedSelector.noResults": { + "defaultMessage": "Sonuç yok", + "description": "Shown in a selector's option list when no option matches" + }, + "uic.PagedSelector.loading": { + "defaultMessage": "Yükleniyor...", + "description": "Shown in a selector's empty option list while options are being fetched" + }, + "uic.PagedSelector.totalItems": { + "defaultMessage": "Total {total} öğeleri", + "description": "Foot of a selector's option list; {total} is how many options exist in all pages" } } diff --git a/src/i18n/locales/vi-VN.json b/src/i18n/locales/vi-VN.json index 52f2d1a..4853dac 100644 --- a/src/i18n/locales/vi-VN.json +++ b/src/i18n/locales/vi-VN.json @@ -294,5 +294,33 @@ "uic.DataGrid.export": { "defaultMessage": "Xuất", "description": "Primary button of a data grid's CSV export dialog" + }, + "uic.PagedSelector.placeholder": { + "defaultMessage": "Chọn {label}", + "description": "Placeholder of a selector with nothing selected; {label} is the field's label" + }, + "uic.PagedSelector.searchOptions": { + "defaultMessage": "Tìm trong các tùy chọn", + "description": "Accessible name of the search box at the top of a selector's option list" + }, + "uic.PagedSelector.searchPlaceholder": { + "defaultMessage": "Tìm kiếm", + "description": "Placeholder of the search box at the top of a selector's option list" + }, + "uic.PagedSelector.clearSearch": { + "defaultMessage": "Xóa Tìm trong các tùy chọn", + "description": "Accessible name of the button that empties a selector's search box" + }, + "uic.PagedSelector.noResults": { + "defaultMessage": "Không có kết quả", + "description": "Shown in a selector's option list when no option matches" + }, + "uic.PagedSelector.loading": { + "defaultMessage": "Đang tải...", + "description": "Shown in a selector's empty option list while options are being fetched" + }, + "uic.PagedSelector.totalItems": { + "defaultMessage": "Tổng số {total} các mục", + "description": "Foot of a selector's option list; {total} is how many options exist in all pages" } } diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index c3aa9ad..51bcc90 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -294,5 +294,33 @@ "uic.DataGrid.export": { "defaultMessage": "导出", "description": "Primary button of a data grid's CSV export dialog" + }, + "uic.PagedSelector.placeholder": { + "defaultMessage": "请选择{label}", + "description": "Placeholder of a selector with nothing selected; {label} is the field's label" + }, + "uic.PagedSelector.searchOptions": { + "defaultMessage": "搜索选项", + "description": "Accessible name of the search box at the top of a selector's option list" + }, + "uic.PagedSelector.searchPlaceholder": { + "defaultMessage": "搜索", + "description": "Placeholder of the search box at the top of a selector's option list" + }, + "uic.PagedSelector.clearSearch": { + "defaultMessage": "清除 搜索选项", + "description": "Accessible name of the button that empties a selector's search box" + }, + "uic.PagedSelector.noResults": { + "defaultMessage": "无结果", + "description": "Shown in a selector's option list when no option matches" + }, + "uic.PagedSelector.loading": { + "defaultMessage": "加载中...", + "description": "Shown in a selector's empty option list while options are being fetched" + }, + "uic.PagedSelector.totalItems": { + "defaultMessage": "总计{total}项目", + "description": "Foot of a selector's option list; {total} is how many options exist in all pages" } } diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index b8e6191..7bb25f6 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -294,5 +294,33 @@ "uic.DataGrid.export": { "defaultMessage": "匯出", "description": "Primary button of a data grid's CSV export dialog" + }, + "uic.PagedSelector.placeholder": { + "defaultMessage": "請選擇{label}", + "description": "Placeholder of a selector with nothing selected; {label} is the field's label" + }, + "uic.PagedSelector.searchOptions": { + "defaultMessage": "搜尋選項", + "description": "Accessible name of the search box at the top of a selector's option list" + }, + "uic.PagedSelector.searchPlaceholder": { + "defaultMessage": "搜尋", + "description": "Placeholder of the search box at the top of a selector's option list" + }, + "uic.PagedSelector.clearSearch": { + "defaultMessage": "清除 搜尋選項", + "description": "Accessible name of the button that empties a selector's search box" + }, + "uic.PagedSelector.noResults": { + "defaultMessage": "無結果", + "description": "Shown in a selector's option list when no option matches" + }, + "uic.PagedSelector.loading": { + "defaultMessage": "載入中...", + "description": "Shown in a selector's empty option list while options are being fetched" + }, + "uic.PagedSelector.totalItems": { + "defaultMessage": "總計{total}項目", + "description": "Foot of a selector's option list; {total} is how many options exist in all pages" } } diff --git a/src/index.ts b/src/index.ts index 86b3d5a..5bcf9aa 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1526,6 +1526,16 @@ export type { PageHeaderProps } from "./components/PageHeader"; export { PageLayout } from "./components/PageLayout"; export type { PageLayoutProps, PageLayoutVariant } from "./components/PageLayout"; +// PagedSelector +export { PagedSelector } from "./components/PagedSelector"; +export type { + PagedSelectorMultipleProps, + PagedSelectorOption, + PagedSelectorProps, + PagedSelectorSelectedItem, + PagedSelectorSingleProps, +} from "./components/PagedSelector"; + // ProgressWithLabel export { ProgressWithLabel } from "./components/ProgressWithLabel"; export type { ProgressWithLabelProps } from "./components/ProgressWithLabel"; From 95f178abb5c11e46905370e8f74e2a890db91139 Mon Sep 17 00:00:00 2001 From: Jongeun <jongeun@lablup.com> Date: Tue, 29 Sep 2026 11:22:08 +0000 Subject: [PATCH 71/87] chore: prepare 0.2.0-alpha.14 --- CHANGELOG.md | 29 +++++++++++++++++++++++++++++ package.json | 2 +- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b5c37d9..fac4b77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,35 @@ Versioning follows the policy in [CONTRIBUTING.md](CONTRIBUTING.md#versioning). ## [Unreleased] +## [0.2.0-alpha.14] + +A paged selector moves in from a product, and `Drawer` hands Escape to the +layer on top. + +### Added + +- `PagedSelector`: a searchable selector, single or multiple + (`isMultiple`), over options loaded a page at a time. Scrolling the panel + within `endReachedThreshold` px (30) of its end calls `onEndReached` once + per arrival; `onSearchChange` reports each keystroke; `totalCount` and + `isLoadingMore` fill the foot. `value` holds option values (`string | +null`, or `string[]`), `onChange` also hands over each chosen value's + `{ value, label }`, and a selected value missing from `options` is named + from `labels`. Built on `ComplexSelector`, with Astryx's own panel search + row and Selector-shaped option rows. Strings are `uic.PagedSelector.*`, + translated in every shipped locale. + +### Changed + +- `Drawer` from `@lablup/ui-common/lab` routes Escape through Astryx core's + layer-dismissal stack, as core's `Dialog` does, instead of handling it on + its own element. An Escape in a popover, selector or modal opened inside + the drawer now closes that layer only, and a second Escape closes the + drawer; a drawer opened inside another closes first. **Behaviour change:** + a scrimless drawer closes on Escape wherever focus is, as the top-most + layer, not only while focus is inside it. The native `cancel` closes it + only while it is on top and no IME composition runs. + ## [0.2.0-alpha.13] Astryx fixes a product used to carry as pnpm patches now ship in ui-common, diff --git a/package.json b/package.json index c15ac50..be81bd3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@lablup/ui-common", - "version": "0.2.0-alpha.13", + "version": "0.2.0-alpha.14", "description": "Lablup's UI layer on Astryx: the Astryx surface re-exported 1:1, the Lablup theme, and shared components", "license": "Apache-2.0", "author": "Lablup Inc.", From d71a81fbaa5b117dc1ca2cfbe98256acde985e72 Mon Sep 17 00:00:00 2001 From: Jongeun <jongeun@lablup.com> Date: Wed, 30 Sep 2026 05:46:53 +0000 Subject: [PATCH 72/87] docs: add a migration guide from the first app that moved onto 0.2 Records the problems the Backend.AI WebUI hit moving onto the Astryx-based ui-common (duplicate core from the lab canary peer, patches that do not travel, the Vite cold-start hang, Vitest and theme-build CSS imports, layer order, inert background under Modal, Escape through the layer stack, i18n wiring) with the fix for each and a pre-merge checklist. Linked from the README's upgrade section. --- README.md | 3 + docs/migrating-to-0.2.md | 238 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 241 insertions(+) create mode 100644 docs/migrating-to-0.2.md diff --git a/README.md b/README.md index 5694eb8..604d2c6 100644 --- a/README.md +++ b/README.md @@ -378,6 +378,9 @@ names need updating. [`migration/0.1-to-0.2.json`](migration/0.1-to-0.2.json) lists every import, prop, class and stylesheet change in a form the upgrade tool reads. +Before you start, read [docs/migrating-to-0.2.md](docs/migrating-to-0.2.md): +the problems the first app hit when it moved onto 0.2, and a checklist. + Let the upgrade tool do the mechanical part. After bumping the dependency: ``` diff --git a/docs/migrating-to-0.2.md b/docs/migrating-to-0.2.md new file mode 100644 index 0000000..6c62b2b --- /dev/null +++ b/docs/migrating-to-0.2.md @@ -0,0 +1,238 @@ +# Moving an app onto ui-common 0.2: what went wrong the first time + +The README's [Upgrading from 0.1](../README.md#upgrading-from-01) section says +what changed. This page says what broke when the first app (the Backend.AI +WebUI) moved onto 0.2, and how to avoid each problem. Every item below +happened at least once. Read it before you start, and use the checklist at the +end before you merge. + +## 1. Dependencies and install + +**Two copies of Astryx core, silently.** The lab canary peers on an exact +core canary, not on the core ui-common pins. Without the override in the +README (`@astryxdesign/lab>@astryxdesign/core`), pnpm installs a second core +and `--frozen-lockfile` and `pnpm peers check` both stay green. Components +from `@lablup/ui-common/lab` then run on a different core than everything +else, with their own theme and layer contexts. + +- Put the pnpm override in `pnpm-workspace.yaml`. pnpm 10 and later ignore + `pnpm.overrides` in `package.json`. +- Check with `pnpm why -r @astryxdesign/core`: it must say one version. Make + that a CI check that reads the lockfile, not a one-off. A lockfile with the + same core version under two peer sets is also a second copy. +- Keep `@astryxdesign/core` out of your own `dependencies` unless you pin the + exact version ui-common pins. If you keep it (for example so the Astryx CLI + resolves in your package), pin it to that version. + +**Local patches on Astryx do not travel.** pnpm `patchedDependencies` apply +only in the project that declares them. A patch shipped inside a library +reaches nobody. If you carry a patch on Astryx, either drop it (ui-common +0.2 ships fixed copies of `ComplexSelector`, lab `Drawer` and lab `Tour`) or +keep it in your own workspace and know that ui-common's copies do not see it. + +**Trying an unpublished ui-common.** A `workspace:` or `link:` dependency +hides the duplicate-core problem above, because your app resolves Astryx from +its own tree. Test with a packed tarball (`pnpm pack`) instead. + +- pnpm's `catalog:` rejects `file:` specs. Pin the tarball with an `overrides` + entry and keep the catalog on the version number. +- pnpm does not re-read a changed tarball under the same file name. Bump the + version (and the file name) every time you repack. + +## 2. Imports + +**Import Astryx only through ui-common.** `@lablup/ui-common/<X>` mirrors +`@astryxdesign/core/<X>` one to one, and `@lablup/ui-common/lab` mirrors lab. +Rewrite the specifiers with a script, then ban `@astryxdesign/*` so nothing +comes back: + +- `no-restricted-imports` does not see `import('…')`, `require('…')` or + `typeof import('…')`. Add a `no-restricted-syntax` rule for those. +- Cover every file type your source uses (`.js`, `.jsx`, `.mjs`), Storybook + config directories and test files, not only `src/**/*.tsx`. +- CSS `@import`s of `@astryxdesign/core/reset.css`, `astryx.css`, + `lab/lab.css` and `theme-neutral/theme.css` need rewriting too; ESLint does + not look there. +- Keep the rewrite script and run it in `--check` mode in CI. Every merge from + your main branch can bring new `@astryxdesign/*` imports back. + +**Hidden names.** `Dialog` and `AlertDialog` are not mirrored; use `Modal` and +`AlertModal`. A plain rename works for almost every call site. The one +difference: a `Modal` `ref` points at the element with `role="dialog"`, not at +an `HTMLDialogElement`. + +## 3. Build and test tooling + +**Vite dev server hangs on a cold start.** `@stylexjs/unplugin` removes from +`optimizeDeps` every dependency that peers on `@stylexjs/stylex`, and +ui-common does. ui-common and the Astryx modules behind it are then served +as source through the StyleX transform, which deadlocks against the +optimizer on the first start: `index.html` loads, every +`/node_modules/.vite/deps/*` request stays pending, and the app never renders. +A warm cache hides it. Put ui-common back into pre-bundling: + +```ts +// vite.config.ts, after the StyleX plugin +{ + name: 'prebundle-ui-common', + enforce: 'post', + config(config) { + const exclude = config.optimizeDeps?.exclude; + if (exclude) { + config.optimizeDeps!.exclude = exclude.filter( + (name) => name !== '@lablup/ui-common', + ); + } + }, +}, +``` + +Test it with the optimizer cache cleared (`rm -rf node_modules/.vite`). + +**Vitest fails with "Unknown file extension .css".** ui-common components +import their own stylesheets. Inline the package in every Vitest config that +renders them: + +```ts +test: { + server: { + deps: { + inline: [/@lablup\/ui-common/]; + } + } +} +``` + +**`astryx theme build` fails with "Unknown file extension .css".** A theme +recipe that imports anything reaching ui-common components hits the same +problem in plain Node. Run the build with an import hook that loads `.css` as +an empty module: + +```js +// astryx-css-stub.mjs — run with NODE_OPTIONS="--import=./astryx-css-stub.mjs" +import { register } from "node:module"; +register( + "data:text/javascript," + + encodeURIComponent(`export async function load(url, context, next) { + if (url.endsWith('.css')) return { format: 'module', source: '', shortCircuit: true }; + return next(url, context); +}`), +); +``` + +## 4. CSS + +**Declare the layer order before any stylesheet, everywhere.** Layer +precedence is fixed by first appearance. If your bundle evaluates one layered +sheet before your entry stylesheet's `@layer` statement, the order changes, +and it can differ between the dev server and the production build. Put the +statement in an inline `<style>` in `index.html` before any `<link>`, and keep +every other copy (entry CSS, Storybook, a library's own sheet) identical. A +test that compares the copies is cheap. + +**`ui-common.css` is optional.** It holds only global scrollbar rules. Each +ui-common component imports its own CSS. Leave it out if your app styles its +own scrollbars; importing it changes their look. + +**Old class names are gone.** Kept components moved from `page-header` to +`uic-page-header` and so on; removed components' classes no longer exist. CSS +overrides, `querySelector` calls, e2e selectors and test queries that name +them break silently. `ui-common upgrade` lists them in its report; fix every +line of that list. + +**Custom properties.** ui-common's own knobs are named `--<component>-<name>` +(`--modal-z`, `--data-grid-max-height`), the way Astryx names its own. Check +that none of your custom properties uses one of those names or an Astryx +token name (`--color-border`, `--color-error`, …). A collision is resolved by +the cascade, not by an error, so it shows up as a wrong colour. + +**`--token-*` is retired.** `legacy-tokens.css` keeps old reads working for one +release. Move them to Astryx tokens rather than relying on it. + +## 5. Theme + +- The app needs exactly one theme provider at the root. A nested `<Theme>` + without `mode` falls back to the system preference, not the parent's mode, + so pass `mode` to every nested one. +- Theme tokens a product adds (such as `--color-info`) are declared in the + product's theme. ui-common components read them with an Astryx fallback, so + a component can look different under your theme than under `lablupTheme`. + That is intended; compare against your own theme, not the demo. +- Brand colours belong in `defineTheme`, never in `:root` overrides of + `--color-*`. + +## 6. Behaviour that changed under you + +**An open `Modal` makes the rest of the page inert.** Every other child of +`<body>` becomes `inert` and hidden from assistive technology until the modal +closes. Anything that must stay usable while a modal is open needs +`MODAL_LIVE_ATTRIBUTE` (exported from `@lablup/ui-common/Modal`) on its root: +toast viewports, notification stacks, review or debug overlays. If it mounts +while a modal is already open, call `refreshModalBackground()` after mounting. +ui-common's `NotificationStack` does this itself; Astryx's toast viewport does +not. Check this in a real browser: jsdom ignores `inert`. + +**Escape goes through one stack.** `Modal`, the lab `Drawer` and Astryx's +popovers close through Astryx's layer-dismissal stack: one Escape closes the +top-most layer only. Two consequences: + +- A drawer without a scrim is a layer too, so Escape closes it even when focus + is elsewhere on the page. +- Your own Escape handlers (inline rename, a title editor) now race the stack. + Call `event.preventDefault()` when your handler consumed the key, so the + stack leaves it alone. + +**Global hotkeys over a modal.** A command palette or other global shortcut +still fires while a modal is open unless you check for it. Skip the shortcut +while an element with `MODAL_OPEN_ATTRIBUTE` that is not `inert` exists. + +## 7. Strings + +- Pass `uiCommonMessages` (from `@lablup/ui-common/i18n-catalog`) into + Astryx's `InternationalizationProvider` for every locale you support, merged + with Astryx's own catalog. Without it, ui-common's built-in strings stay + English. +- Map your language codes to Astryx's locale file names (`ko` → `ko-KR`). + Locales Astryx has no file for fall back to English; list them explicitly + so the fallback is a decision, not an accident. +- Catalog messages are ICU (`{count}`), not i18next (`{{count}}`). Convert any + string you move into the catalog, and use an ICU plural for counts. +- Short labels are buttons, not sentences. "Cancel" is "Abbrechen", not + "Stornieren". Check the shared `uic.common.*` labels in your languages. + +## 8. Wrapping ui-common in your own components + +- Keep your existing component names as thin adapters over the ui-common + component, so call sites do not change. Do not keep a second + implementation next to ui-common's. +- Forward what the old component forwarded. Two regressions from the first + migration: a row index passed as a constant `0` to a per-cell callback, and + a prop that leaked onto a DOM element (`titleStyle` on a `<div>`), which + React only reports in the console. +- Diff each adapter's behaviour at real call sites, not only its types: event + arguments, disabled states, when `afterClose`-style callbacks fire, and what + a cleared value is (`null` or `[]`). + +## 9. Merging and rebasing a long migration branch + +- Never hand-merge the lockfile, generated GraphQL output, search indexes or + built theme artifacts. Take one side and regenerate. +- After every merge from main, run the import rewrite again, then its + `--check`. +- If a large change lands on main while your branch holds an older copy of + it, resolve against the final version on main, not your copy. + +## Checklist before you merge + +- [ ] `pnpm why -r @astryxdesign/core` shows one version, and CI checks it. +- [ ] No `@astryxdesign/*` import anywhere (lint + rewrite script `--check`). +- [ ] The layer order is declared first, and every copy is identical. +- [ ] A cold dev-server start (optimizer cache cleared) renders the app. +- [ ] Vitest and the theme build run with ui-common inlined / CSS stubbed. +- [ ] `ui-common upgrade` report: every line resolved. +- [ ] Toasts and overlays are usable while a modal is open (real browser). +- [ ] Escape closes one layer at a time; your own Escape handlers call + `preventDefault()`. +- [ ] Built-in strings appear translated in each supported language. +- [ ] Screenshots of the main screens, light and dark, compared with the + version before the migration. From 51807d4e9f3ec8e1ee91bb0b9d2922684a83d674 Mon Sep 17 00:00:00 2001 From: Jongeun <jongeun@lablup.com> Date: Wed, 30 Sep 2026 08:57:16 +0000 Subject: [PATCH 73/87] chore: keep lint, format and tests out of agent worktrees A git worktree under .claude/worktrees is another checkout of this repository. ESLint walked into its dist, Prettier into its sources, and Vitest ran its test files a second time, so `pnpm run verify` failed or double-counted whenever one existed. --- .prettierignore | 1 + eslint.config.js | 2 ++ vite.config.ts | 5 +++-- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.prettierignore b/.prettierignore index 4876889..529117b 100644 --- a/.prettierignore +++ b/.prettierignore @@ -4,3 +4,4 @@ pnpm-lock.yaml LICENSE src/theme/*/built/ test/upgrade/fixtures/ +.claude/ diff --git a/eslint.config.js b/eslint.config.js index f748fa6..20281ea 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -14,6 +14,8 @@ export default tseslint.config( "src/theme/*/built/**", // Consumer code the upgrade codemods run on, and their expected output. "test/upgrade/fixtures/**", + // Agent worktrees: other checkouts of this repository, with their own dist. + ".claude/**", ], }, js.configs.recommended, diff --git a/vite.config.ts b/vite.config.ts index 6584a80..33c7cc7 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -256,7 +256,8 @@ export default defineConfig({ globals: true, setupFiles: ["./src/test/setup.ts"], css: true, - // Consumer source the upgrade codemods run on, not tests of this package. - exclude: [...configDefaults.exclude, "test/upgrade/fixtures/**"], + // Consumer source the upgrade codemods run on, not tests of this package; + // and agent worktrees, which are other checkouts of this repository. + exclude: [...configDefaults.exclude, "test/upgrade/fixtures/**", ".claude/**"], }, }); From 8440fbafd54b8ba80107763e29720427198e1c7f Mon Sep 17 00:00:00 2001 From: Jongeun <jongeun@lablup.com> Date: Wed, 30 Sep 2026 08:57:16 +0000 Subject: [PATCH 74/87] test(Modal): pin Escape routing around a tooltip inside a modal A tooltip is a layer on core's dismissal stack while it shows, so one Escape closes it and the next closes the modal. A trigger that unmounts while hovered (an inline rename's pencil) takes its tooltip's layer with it: the modal owes that tooltip no press afterwards. A consumer report read as a leaked layer turned out to be this routing: the trigger remounted under a resting pointer, its tooltip showed again, and took the press. These two cases keep the distinction checkable. --- src/components/Modal/Modal.test.tsx | 103 +++++++++++++++++++++++++++- 1 file changed, 102 insertions(+), 1 deletion(-) diff --git a/src/components/Modal/Modal.test.tsx b/src/components/Modal/Modal.test.tsx index d007d56..85aed09 100644 --- a/src/components/Modal/Modal.test.tsx +++ b/src/components/Modal/Modal.test.tsx @@ -9,8 +9,9 @@ import { StrictMode, useState, type ComponentProps } from "react"; import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { DialogHeader as CoreDialogHeader } from "@astryxdesign/core/Dialog"; +import { IconButton } from "@astryxdesign/core/IconButton"; import { InternationalizationProvider } from "@astryxdesign/core/i18n"; import { Layout, LayoutContent } from "@astryxdesign/core/Layout"; import { Theme, defineTheme } from "@astryxdesign/core/theme"; @@ -248,6 +249,106 @@ describe("Modal dismissal", () => { }); }); +/** + * A tooltip is a layer on Astryx's dismissal stack while it shows, so Escape + * closes it before the modal. These pin that the modal is never left behind a + * tooltip that is gone. jsdom has no Popover API; the stub stands in for it. + */ +describe("Modal dismissal with a tooltip inside", () => { + const openTooltip = () => document.querySelector('[role="tooltip"][popover-open]'); + + beforeEach(() => { + const originalMatches = HTMLElement.prototype.matches; + HTMLElement.prototype.showPopover = function (this: HTMLElement) { + this.setAttribute("popover-open", ""); + }; + HTMLElement.prototype.hidePopover = function (this: HTMLElement) { + this.removeAttribute("popover-open"); + }; + vi.spyOn(HTMLElement.prototype, "matches").mockImplementation(function ( + this: HTMLElement, + selector: string, + ) { + if (selector === ":popover-open") return this.hasAttribute("popover-open"); + return originalMatches.call(this, selector); + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + Reflect.deleteProperty(HTMLElement.prototype, "showPopover"); + Reflect.deleteProperty(HTMLElement.prototype, "hidePopover"); + }); + + // A control that swaps itself for an editor on click, as an inline rename's + // pencil does: the trigger unmounts, and its tooltip with it. + function InlineEdit() { + const [isEditing, setIsEditing] = useState(false); + return isEditing ? ( + <input + aria-label="name" + autoFocus + onKeyDown={(event) => { + if (event.key !== "Escape") return; + event.stopPropagation(); + setIsEditing(false); + }} + /> + ) : ( + <IconButton + label="Edit" + tooltip="Edit" + icon={<span />} + onClick={() => setIsEditing(true)} + /> + ); + } + + function renderInlineEdit() { + const onOpenChange = vi.fn(); + render( + <Modal isOpen onOpenChange={onOpenChange} title="Folder"> + <InlineEdit /> + </Modal>, + ); + return onOpenChange; + } + + it("gives one Escape to an open tooltip and the next to the modal", async () => { + const user = userEvent.setup(); + const onOpenChange = renderInlineEdit(); + + await user.hover(screen.getByRole("button", { name: "Edit" })); + await waitFor(() => expect(openTooltip()).toHaveTextContent("Edit")); + + await user.keyboard("{Escape}"); + expect(openTooltip()).toBeNull(); + expect(onOpenChange).not.toHaveBeenCalled(); + + await user.keyboard("{Escape}"); + expect(onOpenChange).toHaveBeenCalledExactlyOnceWith(false); + }); + + it("owes nothing to the tooltip of a trigger that unmounted while hovered", async () => { + const user = userEvent.setup(); + const onOpenChange = renderInlineEdit(); + + await user.hover(screen.getByRole("button", { name: "Edit" })); + await waitFor(() => expect(openTooltip()).toHaveTextContent("Edit")); + await user.click(screen.getByRole("button", { name: "Edit" })); + expect(openTooltip()).toBeNull(); + + // The editor claims this press for itself and puts the trigger back. + await user.keyboard("{Escape}"); + expect(onOpenChange).not.toHaveBeenCalled(); + expect(screen.getByRole("button", { name: "Edit" })).toBeInTheDocument(); + expect(openTooltip()).toBeNull(); + + await user.keyboard("{Escape}"); + expect(onOpenChange).toHaveBeenCalledExactlyOnceWith(false); + }); +}); + describe("Modal nesting", () => { function Nested() { const [isInnerOpen, setIsInnerOpen] = useState(false); From 6ede03e398b2c1ac7b6613805170533b50efb345 Mon Sep 17 00:00:00 2001 From: Jongeun <jongeun@lablup.com> Date: Fri, 2 Oct 2026 04:21:17 +0000 Subject: [PATCH 75/87] fix(codemods): add a library's StyleX and lab peers when only a devDependency has them For a library, the presence check looked at every dependency field, so a devDependency on @stylexjs/stylex (or on the lab canary) suppressed the peerDependency the package needs. Check the peer and the devDependency independently. --- codemods/0.2/package-json.mjs | 70 +++++++++++++++------------ test/upgrade/package-json.test.ts | 80 +++++++++++++++++++++++++++++++ 2 files changed, 119 insertions(+), 31 deletions(-) create mode 100644 test/upgrade/package-json.test.ts diff --git a/codemods/0.2/package-json.mjs b/codemods/0.2/package-json.mjs index 003cd05..9571c18 100644 --- a/codemods/0.2/package-json.mjs +++ b/codemods/0.2/package-json.mjs @@ -113,41 +113,49 @@ export function transformPackageJson(text, ctx) { // an application depends on it. const library = fields.includes("peerDependencies"); + /** + * Add `name` where this project needs it, and return the fields it went + * into. A library needs it as a peer and, when it develops against + * ui-common, as a devDependency: each is checked on its own, since one does + * not stand in for the other. An application needs it anywhere. + * + * @param {string} name + * @param {string} range + */ + const ensure = (name, range) => { + /** @type {string[]} */ + const wanted = library + ? [ + ...(pkg.peerDependencies?.[name] == null && pkg.dependencies?.[name] == null + ? ["peerDependencies"] + : []), + ...(fields.includes("devDependencies") && pkg.devDependencies?.[name] == null + ? ["devDependencies"] + : []), + ] + : has(name) + ? [] + : [fields.includes("dependencies") ? "dependencies" : fields[0]]; + for (const field of wanted) addDependency(pkg, field, name, range); + return wanted; + }; + const stylex = stylexPeer(); - if (!has(stylex.name)) { - if (library) { - addDependency(pkg, "peerDependencies", stylex.name, stylex.range); - if (fields.includes("devDependencies")) - addDependency(pkg, "devDependencies", stylex.name, stylex.range); - ctx.note( - `added ${stylex.name} ${stylex.range} to peerDependencies${fields.includes("devDependencies") ? " and devDependencies" : ""}.`, - ); - } else { - const field = fields.includes("dependencies") ? "dependencies" : fields[0]; - addDependency(pkg, field, stylex.name, stylex.range); - ctx.note(`added ${stylex.name} ${stylex.range} to ${field}.`); - } - } + const stylexAdded = ensure(stylex.name, stylex.range); + if (stylexAdded.length > 0) + ctx.note(`added ${stylex.name} ${stylex.range} to ${stylexAdded.join(" and ")}.`); // Packages the map says a moved component needs (the lab Drawer). for (const [lab, range] of ctx.flags.packages) { - if (has(lab)) continue; - { - const field = library - ? "peerDependencies" - : fields.includes("dependencies") - ? "dependencies" - : fields[0]; - addDependency(pkg, field, lab, range); - if (library && fields.includes("devDependencies")) - addDependency(pkg, "devDependencies", lab, range); - ctx.note( - lab === LAB_PACKAGE - ? `added ${lab} ${range} to ${field}: a Drawer moved to ${UIC}/lab, and ui-common pins the lab canary exactly.` - : `added ${lab} ${range} to ${field}.`, - ); - if (lab === LAB_PACKAGE) addLabOverride(pkg, ctx); - } + const added = ensure(lab, range); + if (added.length === 0) continue; + const field = added.join(" and "); + ctx.note( + lab === LAB_PACKAGE + ? `added ${lab} ${range} to ${field}: a Drawer moved to ${UIC}/lab, and ui-common pins the lab canary exactly.` + : `added ${lab} ${range} to ${field}.`, + ); + if (lab === LAB_PACKAGE) addLabOverride(pkg, ctx); } const out = `${JSON.stringify(pkg, null, indent)}${text.endsWith("\n") ? "\n" : ""}`; diff --git a/test/upgrade/package-json.test.ts b/test/upgrade/package-json.test.ts new file mode 100644 index 0000000..520d8bb --- /dev/null +++ b/test/upgrade/package-json.test.ts @@ -0,0 +1,80 @@ +/** + * `transformPackageJson` on its own: the dependency edits a fixture project + * does not pin down. + */ +import { describe, expect, it } from "vitest"; + +import { transformPackageJson } from "../../codemods/0.2/package-json.mjs"; +import { LAB_PACKAGE, stylexPeer } from "../../codemods/0.2/map.mjs"; + +const UIC = "@lablup/ui-common"; +const stylex = stylexPeer(); + +function run(pkg: object, packages = new Map<string, string>()) { + const notes: string[] = []; + const out = transformPackageJson(`${JSON.stringify(pkg, null, 2)}\n`, { + to: "0.2.0", + flags: { packages }, + note: (m: string) => notes.push(m), + }); + return { pkg: out === undefined ? pkg : JSON.parse(out), notes }; +} + +describe("a library (ui-common as a peer)", () => { + it("adds the StyleX peer although StyleX is already a devDependency", () => { + const { pkg } = run({ + peerDependencies: { [UIC]: "^0.1.0" }, + devDependencies: { [UIC]: "0.1.0", [stylex.name]: "0.19.0" }, + }); + expect(pkg.peerDependencies[stylex.name]).toBe(stylex.range); + expect(pkg.devDependencies[stylex.name]).toBe("0.19.0"); + }); + + it("adds the StyleX devDependency although StyleX is already a peer", () => { + const { pkg } = run({ + peerDependencies: { [UIC]: "^0.1.0", [stylex.name]: "^0.19.0" }, + devDependencies: { [UIC]: "0.1.0" }, + }); + expect(pkg.peerDependencies[stylex.name]).toBe("^0.19.0"); + expect(pkg.devDependencies[stylex.name]).toBe(stylex.range); + }); + + it("leaves both alone when both are declared", () => { + const input = { + peerDependencies: { [UIC]: "^0.2.0", [stylex.name]: "^0.19.0" }, + devDependencies: { [UIC]: "0.2.0", [stylex.name]: "0.19.0" }, + }; + expect( + transformPackageJson(`${JSON.stringify(input, null, 2)}\n`, { + to: "0.2.0", + flags: { packages: new Map() }, + note: () => {}, + }), + ).toBeUndefined(); + }); + + it("adds the lab peer although lab is already a devDependency", () => { + const { pkg } = run( + { + peerDependencies: { [UIC]: "^0.1.0", [stylex.name]: "^0.19.0" }, + devDependencies: { + [UIC]: "0.1.0", + [stylex.name]: "0.19.0", + [LAB_PACKAGE]: "1.0.0", + }, + }, + new Map([[LAB_PACKAGE, "1.0.0"]]), + ); + expect(pkg.peerDependencies[LAB_PACKAGE]).toBe("1.0.0"); + }); +}); + +describe("an application", () => { + it("does not add StyleX when any field already has it", () => { + const { pkg } = run({ + dependencies: { [UIC]: "^0.1.0" }, + devDependencies: { [stylex.name]: "0.19.0" }, + }); + expect(pkg.dependencies[stylex.name]).toBeUndefined(); + }); +}); From 120c6b8acf285a72c7886ea33a1cd86adc50ad49 Mon Sep 17 00:00:00 2001 From: Jongeun <jongeun@lablup.com> Date: Fri, 2 Oct 2026 04:22:35 +0000 Subject: [PATCH 76/87] fix: raise the react and react-dom peers to ^19.2.0 Modal, NotificationStack, UnitGrid and the Form's useWatch import useEffectEvent, which React made stable in 19.2. The ^19.0.0 peer let a 19.0 or 19.1 install resolve, and those modules then failed to import. A test now holds the peer floor to the React APIs the source imports. --- README.md | 3 ++- package.json | 4 +-- test/package/peers.test.ts | 55 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 3 deletions(-) create mode 100644 test/package/peers.test.ts diff --git a/README.md b/README.md index 604d2c6..b37d51c 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,8 @@ That is npmjs, which needs no authentication. Peer dependencies: -- `react` and `react-dom` 19. +- `react` and `react-dom` ^19.2. Components use `useEffectEvent`, which + React 19.2 made stable. - `@stylexjs/stylex` ^0.19. It is the one runtime copy that Astryx, ui-common and your own StyleX code share. - `@astryxdesign/lab`, optional. Install it only if you use diff --git a/package.json b/package.json index be81bd3..91c390b 100644 --- a/package.json +++ b/package.json @@ -605,8 +605,8 @@ "peerDependencies": { "@astryxdesign/lab": "0.6.2-canary.c9fb1ad", "@stylexjs/stylex": "^0.19.0", - "react": "^19.0.0", - "react-dom": "^19.0.0" + "react": "^19.2.0", + "react-dom": "^19.2.0" }, "peerDependenciesMeta": { "@astryxdesign/lab": { diff --git a/test/package/peers.test.ts b/test/package/peers.test.ts new file mode 100644 index 0000000..1ed23e7 --- /dev/null +++ b/test/package/peers.test.ts @@ -0,0 +1,55 @@ +/** + * The React peer floor covers every React API the source calls. + */ +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { globSync } from "tinyglobby"; +import { describe, expect, it } from "vitest"; + +const root = join(dirname(fileURLToPath(import.meta.url)), "../.."); +const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8")) as { + peerDependencies: Record<string, string>; +}; + +/** React APIs newer than 19.0, and the release that made them stable. */ +const SINCE: Record<string, [number, number]> = { + useEffectEvent: [19, 2], + Activity: [19, 2], +}; + +/** The lowest version a `^x.y.z` / `>=x.y.z` range admits. */ +function floor(range: string): [number, number] { + const match = /^(?:\^|>=)?\s*(\d+)\.(\d+)/.exec(range.trim()); + if (!match) throw new Error(`unsupported range "${range}"`); + return [Number(match[1]), Number(match[2])]; +} + +const atLeast = (a: [number, number], b: [number, number]) => + a[0] > b[0] || (a[0] === b[0] && a[1] >= b[1]); + +const sources = globSync(["src/**/*.{ts,tsx}"], { + cwd: root, + ignore: ["**/*.test.*", "src/astryx/**", "src/test/**"], +}); + +describe("react and react-dom peers", () => { + for (const [api, since] of Object.entries(SINCE)) { + const imported = new RegExp( + `import\\s*(?:type\\s*)?\\{[^}]*\\b${api}\\b[^}]*\\}\\s*from\\s*["']react["']|\\bReact\\.${api}\\b`, + ); + const users = sources.filter((file) => + imported.test(readFileSync(join(root, file), "utf8")), + ); + if (users.length === 0) continue; + it(`admit no React without ${api} (${since.join(".")}), which ${users.length} module(s) use`, () => { + for (const name of ["react", "react-dom"]) { + expect( + atLeast(floor(pkg.peerDependencies[name]), since), + `${name} ${pkg.peerDependencies[name]}; used by ${users.join(", ")}`, + ).toBe(true); + } + }); + } +}); From aea5d960b55344c12a2bb499d0f22713298b1570 Mon Sep 17 00:00:00 2001 From: Jongeun <jongeun@lablup.com> Date: Fri, 2 Oct 2026 04:23:44 +0000 Subject: [PATCH 77/87] fix(NotificationStack): unmount a closed notice once its exit has played The exit-prune effect depended on `visible`, a fresh slice every render when maxVisible is set. Its own setExiting re-ran it, the cleanup cancelled the 200 ms prune timer, and the re-run found nothing newly removed, so the notice stayed mounted in its exiting state for good, its buttons still in the tab order. Without maxVisible, any new notifications array within the 200 ms did the same. Memoize `visible`, and give each exiting key its own prune timer that a re-render does not cancel; only unmount clears them. --- .../NotificationStack.test.tsx | 32 ++++++++++++++++ .../NotificationStack/NotificationStack.tsx | 37 +++++++++++++++---- 2 files changed, 62 insertions(+), 7 deletions(-) diff --git a/src/components/NotificationStack/NotificationStack.test.tsx b/src/components/NotificationStack/NotificationStack.test.tsx index 40c9d15..a2bb035 100644 --- a/src/components/NotificationStack/NotificationStack.test.tsx +++ b/src/components/NotificationStack/NotificationStack.test.tsx @@ -99,6 +99,38 @@ describe("NotificationStack", () => { expect(renderedKeys()).toEqual(["n0"]); }); + it("unmounts a removed notice after its exit when maxVisible is set", () => { + vi.useFakeTimers(); + const { rerender } = render( + <NotificationStack data-testid="stack" notifications={items(2)} maxVisible={3} />, + ); + rerender( + <NotificationStack data-testid="stack" notifications={items(1)} maxVisible={3} />, + ); + expect(renderedKeys()).toEqual(["n1", "n0"]); + act(() => { + vi.advanceTimersByTime(5000); + }); + expect(renderedKeys()).toEqual(["n0"]); + }); + + it("unmounts a removed notice although the list changes during its exit", () => { + vi.useFakeTimers(); + const { rerender } = render( + <NotificationStack data-testid="stack" notifications={items(2)} />, + ); + rerender(<NotificationStack data-testid="stack" notifications={items(1)} />); + act(() => { + vi.advanceTimersByTime(100); + }); + // A running task rebuilds the array without changing what is in it. + rerender(<NotificationStack data-testid="stack" notifications={[...items(1)]} />); + act(() => { + vi.advanceTimersByTime(5000); + }); + expect(renderedKeys()).toEqual(["n0"]); + }); + it("closes through the dismiss button", async () => { const onClose = vi.fn(); render( diff --git a/src/components/NotificationStack/NotificationStack.tsx b/src/components/NotificationStack/NotificationStack.tsx index 0f047ae..98ff6a5 100644 --- a/src/components/NotificationStack/NotificationStack.tsx +++ b/src/components/NotificationStack/NotificationStack.tsx @@ -40,6 +40,7 @@ import { useEffect, useEffectEvent, useLayoutEffect, + useMemo, useRef, useState, type Key, @@ -296,9 +297,16 @@ export function NotificationStack({ // Notices that left `notifications` but are still playing their exit. const [exiting, setExiting] = useState<Array<NotificationStackItem>>([]); const previousVisibleRef = useRef<Array<NotificationStackItem>>([]); + // One prune timer per exiting key. They outlive the effect that starts + // them: any re-render (a new array, the exit itself) re-runs that effect, + // and cancelling there would leave the notice mounted for good. + const exitTimersRef = useRef(new Map<Key, number>()); const stackRef = useRef<HTMLDivElement>(null); - const visible = maxVisible ? notifications.slice(-maxVisible) : notifications; + const visible = useMemo( + () => (maxVisible ? notifications.slice(-maxVisible) : notifications), + [notifications, maxVisible], + ); const newestKey = notifications.at(-1)?.key; // Once the stack is capped it scrolls, and the newest notice is at the @@ -316,14 +324,29 @@ export function NotificationStack({ const removed = previousVisibleRef.current.filter((n) => !currentKeys.has(n.key)); previousVisibleRef.current = visible; if (removed.length === 0) return; - setExiting((prev) => [...prev, ...removed]); - const timer = window.setTimeout(() => { - const removedKeys = new Set(removed.map((n) => n.key)); - setExiting((prev) => prev.filter((n) => !removedKeys.has(n.key))); - }, EXIT_ANIMATION_MS); - return () => window.clearTimeout(timer); + const removedKeys = new Set(removed.map((n) => n.key)); + setExiting((prev) => [...prev.filter((n) => !removedKeys.has(n.key)), ...removed]); + const timers = exitTimersRef.current; + for (const { key } of removed) { + window.clearTimeout(timers.get(key)); + timers.set( + key, + window.setTimeout(() => { + timers.delete(key); + setExiting((prev) => prev.filter((n) => n.key !== key)); + }, EXIT_ANIMATION_MS), + ); + } }, [notifications, visible]); + useEffect(() => { + const timers = exitTimersRef.current; + return () => { + for (const timer of timers.values()) window.clearTimeout(timer); + timers.clear(); + }; + }, []); + const visibleKeys = new Set(visible.map((n) => n.key)); const stillExiting = exiting.filter((n) => !visibleKeys.has(n.key)); From e14aafd3311842c03298485b6a957069da6d05ac Mon Sep 17 00:00:00 2001 From: Jongeun <jongeun@lablup.com> Date: Fri, 2 Oct 2026 04:24:05 +0000 Subject: [PATCH 78/87] fix(NotificationStack): keep the countdown paused while hover or focus still holds it Hover and focus shared one isPaused flag, so moving the pointer off a notice resumed its auto-close while focus was still inside it, and blurring resumed it under a hovering pointer. Track the two separately and pause while either holds. --- .../NotificationStack.test.tsx | 37 +++++++++++++++++++ .../NotificationStack/NotificationStack.tsx | 14 ++++--- 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/src/components/NotificationStack/NotificationStack.test.tsx b/src/components/NotificationStack/NotificationStack.test.tsx index a2bb035..1e37c6e 100644 --- a/src/components/NotificationStack/NotificationStack.test.tsx +++ b/src/components/NotificationStack/NotificationStack.test.tsx @@ -169,6 +169,43 @@ describe("NotificationStack", () => { expect(onClose).toHaveBeenCalledWith("t"); }); + it("stays paused while focused after the pointer leaves, and the other way round", () => { + vi.useFakeTimers(); + const onClose = vi.fn(); + render( + <NotificationStack + notifications={[{ key: "t", title: "Done", duration: 2, onRetry: () => {} }]} + onClose={onClose} + />, + ); + const notice = document.querySelector("[data-notification-key='t']") as HTMLElement; + const retry = screen.getByRole("button", { name: "Retry" }); + + fireEvent.mouseEnter(notice); + act(() => retry.focus()); + fireEvent.mouseLeave(notice); + expect(notice).toHaveAttribute("data-paused", "true"); + act(() => { + vi.advanceTimersByTime(5000); + }); + expect(onClose).not.toHaveBeenCalled(); + + fireEvent.mouseEnter(notice); + act(() => retry.blur()); + expect(notice).toHaveAttribute("data-paused", "true"); + act(() => { + vi.advanceTimersByTime(5000); + }); + expect(onClose).not.toHaveBeenCalled(); + + fireEvent.mouseLeave(notice); + expect(notice).toHaveAttribute("data-paused", "false"); + act(() => { + vi.advanceTimersByTime(2100); + }); + expect(onClose).toHaveBeenCalledWith("t"); + }); + it("keeps a notice with duration 0 open", () => { vi.useFakeTimers(); const onClose = vi.fn(); diff --git a/src/components/NotificationStack/NotificationStack.tsx b/src/components/NotificationStack/NotificationStack.tsx index 98ff6a5..4ac1ef7 100644 --- a/src/components/NotificationStack/NotificationStack.tsx +++ b/src/components/NotificationStack/NotificationStack.tsx @@ -129,7 +129,11 @@ function NotificationStackItemView({ // A notice that closes under the pointer the reader moved there to read it // is lost; focus counts too, so keyboard users get the same reprieve. - const [isPaused, setIsPaused] = useState(false); + // Each holds the countdown on its own: leaving one must not resume it while + // the other still holds. + const [isHovered, setIsHovered] = useState(false); + const [isFocused, setIsFocused] = useState(false); + const isPaused = isHovered || isFocused; // Derived, not a default: a task is updated in place under the same key // (pending, then failed), so the item never remounts. `null` means the @@ -208,12 +212,12 @@ function NotificationStackItemView({ data-notification-key={String(key)} data-status={item.status ?? "info"} data-paused={isPaused ? "true" : "false"} - onMouseEnter={() => setIsPaused(true)} - onMouseLeave={() => setIsPaused(false)} + onMouseEnter={() => setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} // React's onFocus/onBlur are the delegated focusin/focusout pair, so // focus anywhere inside the notice counts. - onFocus={() => setIsPaused(true)} - onBlur={() => setIsPaused(false)} + onFocus={() => setIsFocused(true)} + onBlur={() => setIsFocused(false)} > <Banner status={item.status ?? "info"} From 35b292634e6973c0a4bc5daa10ab3f0d6f852609 Mon Sep 17 00:00:00 2001 From: Jongeun <jongeun@lablup.com> Date: Fri, 2 Oct 2026 04:25:19 +0000 Subject: [PATCH 79/87] fix(DoubleBadge): accept shorthand and object values mixed in one list `values` was Array<string> | Array<DoubleBadgeValue>, so the documented example [{ label, variant }, "2m"] did not type-check although the component renders it. The element type is now the union. --- astryx/components/DoubleBadge.doc.mjs | 2 +- .../DoubleBadge/DoubleBadge.test.tsx | 21 ++++++++++++++++++- src/components/DoubleBadge/DoubleBadge.tsx | 7 +++---- 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/astryx/components/DoubleBadge.doc.mjs b/astryx/components/DoubleBadge.doc.mjs index 6783928..7487858 100644 --- a/astryx/components/DoubleBadge.doc.mjs +++ b/astryx/components/DoubleBadge.doc.mjs @@ -15,7 +15,7 @@ export default { props: [ { name: "values", - type: "Array<string> | Array<{ label: string; variant?: BadgeVariant }>", + type: "Array<string | { label: string; variant?: BadgeVariant }>", description: "The badges, in order. A string is a neutral Badge; an empty label is skipped.", }, diff --git a/src/components/DoubleBadge/DoubleBadge.test.tsx b/src/components/DoubleBadge/DoubleBadge.test.tsx index dea23cd..e8a3f09 100644 --- a/src/components/DoubleBadge/DoubleBadge.test.tsx +++ b/src/components/DoubleBadge/DoubleBadge.test.tsx @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { render } from "@testing-library/react"; -import { DoubleBadge } from "./DoubleBadge"; +import { DoubleBadge, type DoubleBadgeProps } from "./DoubleBadge"; const root = (c: HTMLElement) => c.querySelector(".uic-double-badge") as HTMLElement; @@ -43,4 +43,23 @@ describe("DoubleBadge", () => { const { container: empty } = render(<DoubleBadge values={[]} />); expect(empty).toBeEmptyDOMElement(); }); + + it("takes shorthand and object values mixed in one list", () => { + // Type-level too: `pnpm run typecheck` rejects this file if the element + // type is not a union. + const values: DoubleBadgeProps["values"] = [ + { label: "RUNNING", variant: "success" }, + "2m", + ]; + const { container } = render(<DoubleBadge values={values} />); + expect( + Array.from(root(container).children).map((el) => [ + el.textContent, + el.getAttribute("data-variant"), + ]), + ).toEqual([ + ["RUNNING", "success"], + ["2m", "neutral"], + ]); + }); }); diff --git a/src/components/DoubleBadge/DoubleBadge.tsx b/src/components/DoubleBadge/DoubleBadge.tsx index 5c8b61a..ba2287b 100644 --- a/src/components/DoubleBadge/DoubleBadge.tsx +++ b/src/components/DoubleBadge/DoubleBadge.tsx @@ -24,14 +24,13 @@ export interface DoubleBadgeValue { export interface DoubleBadgeProps { /** The badges, in order. */ - values?: Array<string> | Array<DoubleBadgeValue>; + values?: Array<string | DoubleBadgeValue>; } export function DoubleBadge({ values = [] }: DoubleBadgeProps) { if (values.length === 0) return null; - const objectValues = (values as Array<string | DoubleBadgeValue>).map( - (value): DoubleBadgeValue => - typeof value === "string" ? { label: value, variant: "neutral" } : value, + const objectValues = values.map((value): DoubleBadgeValue => + typeof value === "string" ? { label: value, variant: "neutral" } : value, ); return ( From 2ad79690e9dac8f154d582783e00af74a8dd2c91 Mon Sep 17 00:00:00 2001 From: Jongeun <jongeun@lablup.com> Date: Fri, 2 Oct 2026 04:25:19 +0000 Subject: [PATCH 80/87] fix(DoubleToken): accept shorthand and object values mixed in one list `values` was Array<string> | Array<DoubleTokenValue>, so the documented example [{ label, color }, "12.4"] did not type-check although the component renders it. The element type is now the union. --- astryx/components/DoubleToken.doc.mjs | 2 +- .../DoubleToken/DoubleToken.test.tsx | 21 ++++++++++++++++++- src/components/DoubleToken/DoubleToken.tsx | 7 +++---- 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/astryx/components/DoubleToken.doc.mjs b/astryx/components/DoubleToken.doc.mjs index 9a467be..1a47012 100644 --- a/astryx/components/DoubleToken.doc.mjs +++ b/astryx/components/DoubleToken.doc.mjs @@ -15,7 +15,7 @@ export default { props: [ { name: "values", - type: "Array<string> | Array<{ label: string; color?: TokenColor; endContent?: ReactNode }>", + type: "Array<string | { label: string; color?: TokenColor; endContent?: ReactNode }>", description: "The tokens, in order. A value's endContent replaces its visible label (the label stays the accessible name); the caller renders the label inside it, and highlightKeyword does not reach into it.", }, diff --git a/src/components/DoubleToken/DoubleToken.test.tsx b/src/components/DoubleToken/DoubleToken.test.tsx index c2cf15a..9cae08b 100644 --- a/src/components/DoubleToken/DoubleToken.test.tsx +++ b/src/components/DoubleToken/DoubleToken.test.tsx @@ -3,7 +3,7 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { render, screen } from "@testing-library/react"; -import { DoubleToken } from "./DoubleToken"; +import { DoubleToken, type DoubleTokenProps } from "./DoubleToken"; // The weld is CSS (jsdom does not lay it out), so these pin the DOM contract // DoubleToken.css is written against: the item class on direct-child tokens, @@ -38,6 +38,25 @@ describe("DoubleToken", () => { ]); }); + it("takes shorthand and object values mixed in one list", () => { + // Type-level too: `pnpm run typecheck` rejects this file if the element + // type is not a union. + const values: DoubleTokenProps["values"] = [ + { label: "CUDA", color: "green" }, + "12.4", + ]; + const { container } = render(<DoubleToken values={values} />); + expect( + Array.from(root(container).children).map((el) => [ + el.textContent, + el.getAttribute("data-color"), + ]), + ).toEqual([ + ["CUDA", "green"], + ["12.4", "blue"], + ]); + }); + it("colours the string shorthand blue", () => { const { container } = render(<DoubleToken values={["only"]} />); expect(root(container).children[0]).toHaveAttribute("data-color", "blue"); diff --git a/src/components/DoubleToken/DoubleToken.tsx b/src/components/DoubleToken/DoubleToken.tsx index bf72bf5..891037b 100644 --- a/src/components/DoubleToken/DoubleToken.tsx +++ b/src/components/DoubleToken/DoubleToken.tsx @@ -38,7 +38,7 @@ export interface DoubleTokenValue { export interface DoubleTokenProps { /** The tokens, in order. */ - values?: Array<string> | Array<DoubleTokenValue>; + values?: Array<string | DoubleTokenValue>; /** Marks this text in every label. */ highlightKeyword?: string; } @@ -48,9 +48,8 @@ export function DoubleToken({ highlightKeyword, }: DoubleTokenProps): ReactElement | null { if (values.length === 0) return null; - const objectValues = (values as Array<string | DoubleTokenValue>).map( - (value): DoubleTokenValue => - typeof value === "string" ? { label: value, color: "blue" } : value, + const objectValues = values.map((value): DoubleTokenValue => + typeof value === "string" ? { label: value, color: "blue" } : value, ); const isHighlighting = highlightKeyword !== undefined; From 13d9c6ee71a0c5e1e1364133d3dbf470f3cbfe0f Mon Sep 17 00:00:00 2001 From: Jongeun <jongeun@lablup.com> Date: Fri, 2 Oct 2026 04:26:08 +0000 Subject: [PATCH 81/87] fix(ImageWithFallback): keep the image's semantics on the fallback Once the image failed, the <img> and its alt were gone, so a meaningful image lost its name and an icon that names itself could be announced in place of a decorative one. The fallback now sits in a span that is an img named by alt, or aria-hidden when alt is empty. --- CONTRIBUTING.md | 2 +- astryx/components/ImageWithFallback.doc.mjs | 8 +++++++- .../ImageWithFallback/ImageWithFallback.css | 9 +++++++++ .../ImageWithFallback.test.tsx | 18 +++++++++++++++++- .../ImageWithFallback/ImageWithFallback.tsx | 15 ++++++++++++++- 5 files changed, 48 insertions(+), 4 deletions(-) create mode 100644 src/components/ImageWithFallback/ImageWithFallback.css diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3bd204b..b22264e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -170,7 +170,7 @@ the recorded version or file. On an Astryx bump: | `DoubleBadge` | `src/components/DoubleBadge/` | `Badge`, `HStack` | | `BooleanToken` | `src/components/BooleanToken/` | `Token` | | `IconWithTooltip` | `src/components/IconWithTooltip/` | `Tooltip`, `Text` | -| `ImageWithFallback` | `src/components/ImageWithFallback/` | plain `<img>` | +| `ImageWithFallback` | `src/components/ImageWithFallback/` | plain `<img>`, plain CSS | | `NotificationStack` | `src/components/NotificationStack/` | `Banner`, `Button`, `ProgressBar`, `Stack`, `Text` | | `OverlayScrollbar` | `src/components/OverlayScrollbar/` | plain CSS | | `ConfirmPopover` | `src/components/ConfirmPopover/` | `Popover`, `Button`, `Stack`, `Text` | diff --git a/astryx/components/ImageWithFallback.doc.mjs b/astryx/components/ImageWithFallback.doc.mjs index 1fde4e0..c778fe8 100644 --- a/astryx/components/ImageWithFallback.doc.mjs +++ b/astryx/components/ImageWithFallback.doc.mjs @@ -14,7 +14,13 @@ export default { "An img that renders a fallback node, typically an icon, once the image fails to load. The failure is remembered per src. Astryx Avatar and Thumbnail fall back to another image; this falls back to any node.", props: [ { name: "src", type: "string", description: "Image source.", required: true }, - { name: "alt", type: "string", description: "Alternative text.", required: true }, + { + name: "alt", + type: "string", + description: + "Alternative text. It also names the fallback; an empty alt hides the fallback from assistive technology.", + required: true, + }, { name: "fallbackIcon", type: "ReactNode", diff --git a/src/components/ImageWithFallback/ImageWithFallback.css b/src/components/ImageWithFallback/ImageWithFallback.css new file mode 100644 index 0000000..cacc382 --- /dev/null +++ b/src/components/ImageWithFallback/ImageWithFallback.css @@ -0,0 +1,9 @@ +/* + * ImageWithFallback: the wrapper that carries the image's semantics on the + * fallback. It sizes to the fallback, with no line box of its own. + */ +@layer ui-common { + .uic-image-with-fallback { + display: inline-flex; + } +} diff --git a/src/components/ImageWithFallback/ImageWithFallback.test.tsx b/src/components/ImageWithFallback/ImageWithFallback.test.tsx index d97a549..5d88ead 100644 --- a/src/components/ImageWithFallback/ImageWithFallback.test.tsx +++ b/src/components/ImageWithFallback/ImageWithFallback.test.tsx @@ -24,7 +24,7 @@ describe("ImageWithFallback", () => { it("swaps in the fallback once the image fails to load", () => { render(<ImageWithFallback src="/a.png" alt="Vendor" fallbackIcon={fallback} />); fireEvent.error(screen.getByRole("img")); - expect(screen.queryByRole("img")).not.toBeInTheDocument(); + expect(document.querySelector("img")).not.toBeInTheDocument(); expect(screen.getByTestId("fallback")).toBeInTheDocument(); }); @@ -37,4 +37,20 @@ describe("ImageWithFallback", () => { expect(screen.getByRole("img")).toHaveAttribute("src", "/b.png"); expect(screen.queryByTestId("fallback")).not.toBeInTheDocument(); }); + + it("keeps the image's accessible name on the fallback", () => { + render(<ImageWithFallback src="/a.png" alt="Vendor" fallbackIcon={fallback} />); + fireEvent.error(screen.getByRole("img")); + const named = screen.getByRole("img", { name: "Vendor" }); + expect(named).toContainElement(screen.getByTestId("fallback")); + }); + + it("keeps a decorative image's fallback out of the accessibility tree", () => { + render(<ImageWithFallback src="/a.png" alt="" fallbackIcon={fallback} />); + fireEvent.error(document.querySelector("img") as HTMLImageElement); + expect(screen.queryByRole("img")).not.toBeInTheDocument(); + expect( + screen.getByTestId("fallback").closest("[aria-hidden='true']"), + ).not.toBeNull(); + }); }); diff --git a/src/components/ImageWithFallback/ImageWithFallback.tsx b/src/components/ImageWithFallback/ImageWithFallback.tsx index 45c8185..beb4cc7 100644 --- a/src/components/ImageWithFallback/ImageWithFallback.tsx +++ b/src/components/ImageWithFallback/ImageWithFallback.tsx @@ -6,11 +6,16 @@ * Astryx's `Avatar` and `Thumbnail` fall back to another image; this falls * back to any node, typically an icon. * + * The fallback keeps the image's semantics: it is an `img` named by `alt`, or + * hidden from assistive technology when `alt` is empty (a decorative image). + * * @example * <ImageWithFallback src={logoUrl} alt="Vendor" fallbackIcon={<Cpu />} width={16} /> */ import { useState, type ImgHTMLAttributes, type ReactNode } from "react"; +import "./ImageWithFallback.css"; + export interface ImageWithFallbackProps extends Omit< ImgHTMLAttributes<HTMLImageElement>, "onError" @@ -30,7 +35,15 @@ export function ImageWithFallback({ const [errorSrc, setErrorSrc] = useState<string | null>(null); if (errorSrc === src) { - return <>{fallbackIcon}</>; + return alt === "" ? ( + <span className="uic-image-with-fallback" aria-hidden="true"> + {fallbackIcon} + </span> + ) : ( + <span className="uic-image-with-fallback" role="img" aria-label={alt}> + {fallbackIcon} + </span> + ); } return <img {...props} src={src} alt={alt} onError={() => setErrorSrc(src)} />; From 403ccbdbd2835bb857e2c4ad5f0cb6f4ab4952bb Mon Sep 17 00:00:00 2001 From: Jongeun <jongeun@lablup.com> Date: Fri, 2 Oct 2026 04:30:59 +0000 Subject: [PATCH 82/87] fix(codemods): keep JSX text whitespace when upgrade adds a TODO or wraps children Two rewrites made recast reprint a JSX parent: addTodo spliced a marker into the parent's children, and Badge's children-to-label move built a new fragment from them. Recast's reprint strips the leading whitespace of every text child, so `{n} items` became `{n}items` and the text around an element that got a TODO lost its spaces. addTodo now leaves JSX-child markers pending on the element, and printSource writes them into the printed text from a sentinel comment, which reprints the element alone: a marker line above an element that starts its line, or a marker right before one that does not, with no whitespace added. A new fragment moves a text's first-line leading whitespace into a {" "} child, which recast prints as is. --- codemods/0.2/components.mjs | 4 +- codemods/0.2/elements.mjs | 3 +- codemods/0.2/stylesheets.mjs | 4 +- codemods/lib/jsx.mjs | 22 +++++++++ codemods/lib/todo.mjs | 80 ++++++++++++++++++++++++-------- test/upgrade/codemods.test.ts | 86 +++++++++++++++++++++++++++++++++++ 6 files changed, 176 insertions(+), 23 deletions(-) diff --git a/codemods/0.2/components.mjs b/codemods/0.2/components.mjs index db4fb35..8d96035 100644 --- a/codemods/0.2/components.mjs +++ b/codemods/0.2/components.mjs @@ -16,7 +16,7 @@ import { renameElement, tagName, } from "../lib/jsx.mjs"; -import { addTodo } from "../lib/todo.mjs"; +import { addTodo, printSource } from "../lib/todo.mjs"; import { ELEMENT_TRANSFORMS } from "./elements.mjs"; import { MOVED, REMOVED, REMOVED_TYPES, UIC } from "./map.mjs"; @@ -645,6 +645,6 @@ export default function transform(file, api, ctx) { .node.body.unshift(...decls); } - const out = root.toSource({ quote: detectQuote(file.source) }); + const out = printSource(j, root, { quote: detectQuote(file.source) }); return file.source.endsWith("\n") && !out.endsWith("\n") ? `${out}\n` : out; } diff --git a/codemods/0.2/elements.mjs b/codemods/0.2/elements.mjs index 5e830d3..f359c69 100644 --- a/codemods/0.2/elements.mjs +++ b/codemods/0.2/elements.mjs @@ -14,6 +14,7 @@ import { isSimpleReference, isStringish, jsxTextValue, + keepJsxTextWhitespace, makeAttr, makeSelfClosing, meaningfulChildren, @@ -217,7 +218,7 @@ function childrenToProp(h, prop, mode) { const fragment = j.jsxFragment( j.jsxOpeningFragment(), j.jsxClosingFragment(), - el.children, + keepJsxTextWhitespace(j, el.children), ); setAttr(j, el, prop, fragment); makeSelfClosing(el); diff --git a/codemods/0.2/stylesheets.mjs b/codemods/0.2/stylesheets.mjs index 5404c7b..8af972c 100644 --- a/codemods/0.2/stylesheets.mjs +++ b/codemods/0.2/stylesheets.mjs @@ -18,7 +18,7 @@ import { basename, dirname, join } from "node:path"; import postcss from "postcss"; import { TODO_TAG } from "../lib/jsx.mjs"; -import { addTodo } from "../lib/todo.mjs"; +import { addTodo, printSource } from "../lib/todo.mjs"; import { LAB_CSS, LAB_PACKAGE, STYLESHEETS } from "./map.mjs"; const { base, layerOrder, imports: replacement, entryFile } = STYLESHEETS; @@ -298,7 +298,7 @@ export function transformScriptImports(file, api, ctx) { }); if (!changed) return undefined; - const out = root.toSource({ + const out = printSource(j, root, { quote: file.source.includes("from '") ? "single" : "double", }); return file.source.endsWith("\n") && !out.endsWith("\n") ? `${out}\n` : out; diff --git a/codemods/lib/jsx.mjs b/codemods/lib/jsx.mjs index b2ddcb2..44b90b7 100644 --- a/codemods/lib/jsx.mjs +++ b/codemods/lib/jsx.mjs @@ -187,6 +187,28 @@ export function makeSelfClosing(el) { el.children = []; } +/** + * `children` for a JSX element or fragment the codemod builds. Recast prints + * a new node's text children with their leading whitespace stripped, which + * changes the text when that whitespace is on the text's first line + * (`{n} items` -> `{n}items`); it is moved into a `{" "}` child of its own. + * Leading whitespace that holds a line break renders as nothing, so it may go. + * + * @param {any} j jscodeshift + * @param {any[]} children + */ +export function keepJsxTextWhitespace(j, children) { + return children.flatMap((/** @type {any} */ child) => { + if (child.type !== "JSXText" || !/\S/.test(child.value)) return [child]; + const lead = /^\s*/.exec(child.value)?.[0] ?? ""; + if (lead === "" || lead.includes("\n")) return [child]; + return [ + j.jsxExpressionContainer(j.stringLiteral(lead)), + j.jsxText(child.value.slice(lead.length)), + ]; + }); +} + /** Whitespace-collapsed JSX text, as React renders it. @param {string} text */ export function jsxTextValue(text) { return text diff --git a/codemods/lib/todo.mjs b/codemods/lib/todo.mjs index a328797..64366b7 100644 --- a/codemods/lib/todo.mjs +++ b/codemods/lib/todo.mjs @@ -1,15 +1,26 @@ /** * Leave `TODO(ui-common-upgrade)` markers where a codemod could not prove a * rewrite safe. Where the marker goes: - * - a JSX child gets a `{/* … *\/}` line right above it; + * - a JSX child gets a `{/* … *\/}` line right above it, or right before it + * when it shares its line with other children; * - an element that is a `return` value or a parenthesised expression gets a * `//` line right above it, inside the parentheses; * - anything else gets a `//` line above its statement. * A marker already there (from an earlier run) is not added twice. * `ui-common upgrade` lists every marker in its report, with its line. + * + * A file whose transform calls `addTodo` is printed with `printSource`: the + * JSX-child markers are written into the printed text, not into the parent's + * `children`. Adding a child makes recast reprint the parent, and recast's + * reprint drops the leading whitespace of every text child + * (`{n} items` -> `{n}items`). */ import { TODO_TAG } from "./jsx.mjs"; +/** The markers waiting on a JSX child, written by `printSource`. */ +const PENDING = Symbol("ui-common-upgrade.todo"); +const SENTINEL = "__ui_common_upgrade_todo_"; + /** @param {any} node */ function isStatement(node) { return ( @@ -66,23 +77,8 @@ export function addTodo(j, path, message) { } break; } - const previous = parent.children[index - 1]; - let whitespace = " "; - if ( - previous?.type === "JSXText" && - previous.value.trim() === "" && - previous.value.includes("\n") - ) { - whitespace = previous.value.slice(previous.value.lastIndexOf("\n")); - } - const empty = j.jsxEmptyExpression(); - empty.comments = [j.commentBlock(` ${text} `, false, true)]; - parent.children.splice( - index, - 0, - j.jsxExpressionContainer(empty), - j.jsxText(whitespace), - ); + const pending = (node[PENDING] ??= []); + if (!pending.includes(text)) pending.push(text); return; } @@ -105,3 +101,51 @@ export function addTodo(j, path, message) { j.commentLine(` ${text}`, true, false), ]; } + +/** + * `root.toSource(options)`, with the JSX-child markers `addTodo` left + * pending written in. Each marked element carries a sentinel block comment + * while it prints: a comment changes the element alone, so recast reprints + * neither the parent nor its text. The sentinel then becomes the marker: + * a `{/* … *\/}` line above an element that starts its line, or a + * `{/* … *\/}` right before one that follows other children on its line, + * with no whitespace added (the text renders as it did). + * + * @param {any} j jscodeshift + * @param {any} root the file's Collection + * @param {object} [options] recast print options + * @returns {string} + */ +export function printSource(j, root, options) { + /** @type {Map<string, string[]>} */ + const markers = new Map(); + for (const type of [j.JSXElement, j.JSXFragment]) { + root.find(type).forEach((/** @type {any} */ path) => { + const texts = path.node[PENDING]; + if (!texts?.length) return; + const id = `${SENTINEL}${markers.size}__`; + markers.set(id, texts); + path.node.comments = [ + ...(path.node.comments ?? []), + j.commentBlock(id, true, false), + ]; + }); + } + let out = root.toSource(options); + for (const [id, texts] of markers) { + const comment = `/*${id}*/`; + const at = out.indexOf(comment); + if (at === -1) throw new Error(`ui-common upgrade: lost the TODO marker ${id}`); + // Recast breaks the line after a leading comment; the element follows. + const after = at + comment.length; + const gap = /^\s*/.exec(out.slice(after))?.[0] ?? ""; + const lineStart = out.lastIndexOf("\n", at - 1) + 1; + const indent = out.slice(lineStart, at); + const blocks = texts.map((text) => `{/* ${text} */}`); + const insert = /^[ \t]*$/.test(indent) + ? blocks.map((b) => `${b}\n${indent}`).join("") + : blocks.join(""); + out = out.slice(0, at) + insert + out.slice(after + gap.length); + } + return out; +} diff --git a/test/upgrade/codemods.test.ts b/test/upgrade/codemods.test.ts index 981a891..7d9d0b7 100644 --- a/test/upgrade/codemods.test.ts +++ b/test/upgrade/codemods.test.ts @@ -179,3 +179,89 @@ export function D({ StatusTag }: { StatusTag: any }) { expect(out).not.toContain("is used as a value here"); }); }); + +/** The output with each JSX TODO marker's message elided. */ +const todos = (out: string) => + out.replace(/\{\/\* TODO\(ui-common-upgrade\): [^*]*\*\/\}/g, "{/* TODO */}"); + +describe("JSX text keeps its whitespace", () => { + it("moves Badge children into label with the space after an expression", () => { + const out = upgrade( + `import { Badge } from "@lablup/ui-common"; +export const A = ({ n }: { n: number }) => <Badge variant="success">{n} items</Badge>; +`, + ); + expect(attributeSource(out, "label")).toBe('<>{n}{" "}items</>'); + }); + + it("keeps a whitespace-only text between expressions", () => { + const out = upgrade( + `import { Badge } from "@lablup/ui-common"; +export const A = ({ a, b }: { a: number; b: number }) => <Badge>{a} {b}</Badge>; +`, + ); + expect(attributeSource(out, "label")).toBe("<>{a} {b}</>"); + }); + + it("keeps multi-line children's words apart", () => { + const out = upgrade( + `import { Badge } from "@lablup/ui-common"; +export const A = ({ n }: { n: number }) => ( + <Badge> + {n} items + in total + </Badge> +); +`, + ); + const label = attributeSource(out, "label"); + expect(label).toContain('{n}{" "}items'); + expect(label).toMatch(/items\n\s*in total/); + }); + + it("leaves text around an element that gets a TODO alone, inline", () => { + const source = `import { Button } from "@lablup/ui-common"; +export const A = ({ n }: { n: number }) => ( + <p> + {n} sessions running <Button shape="round">Go</Button> and {n} more + </p> +); +`; + const out = upgrade(source); + expect(todos(out).split("\n").slice(2)).toEqual([ + " <p>", + ' {n} sessions running {/* TODO */}<Button shape="round" label="Go" /> and {n} more', + " </p>", + ");", + "", + ]); + // Idempotent: a second run adds nothing. + expect(upgrade(out.replace("@lablup/ui-common/Button", "@lablup/ui-common"))).toBe( + out, + ); + }); + + it("puts a TODO on its own line above an element on its own line", () => { + const out = upgrade( + `import { Button } from "@lablup/ui-common"; +export const A = ({ n }: { n: number }) => ( + <p> + {n} sessions running + <Button shape="round">Go</Button> + {n} more + </p> +); +`, + ); + expect(todos(out).split("\n").slice(2)).toEqual([ + " <p>", + " {n} sessions running", + " {/* TODO */}", + ' <Button shape="round" label="Go" />', + " {n} more", + " </p>", + ");", + "", + ]); + }); +}); From 68749248936956e2c5fc68e7dd73b80f6eb361be Mon Sep 17 00:00:00 2001 From: Jongeun <jongeun@lablup.com> Date: Fri, 2 Oct 2026 04:34:16 +0000 Subject: [PATCH 83/87] fix(DataGrid): let column settings undo what defaultOverrides set defaultOverrides stays merged under the user's overrides on every render, and a key the user's record holds replaces the default's entry whole. The settings dialog wrote `hidden` and `order` only where they differed from the column's natural default, so re-showing a column the defaults hide wrote {} and the default {hidden: true} came back, and restoring the natural order over a reordering default did the same. Compare against the effective default (natural, then defaultOverrides): leave a column out only while its default already renders the user's choice, write an explicit hidden where it differs from either, and state every position while a default order is in play. --- src/components/DataGrid/DataGrid.test.tsx | 72 ++++++++++++++++++- src/components/DataGrid/DataGrid.tsx | 87 +++++++++++++++++------ 2 files changed, 137 insertions(+), 22 deletions(-) diff --git a/src/components/DataGrid/DataGrid.test.tsx b/src/components/DataGrid/DataGrid.test.tsx index 898c2cc..7fd4f0b 100644 --- a/src/components/DataGrid/DataGrid.test.tsx +++ b/src/components/DataGrid/DataGrid.test.tsx @@ -7,7 +7,7 @@ import userEvent from "@testing-library/user-event"; import { InternationalizationProvider } from "@astryxdesign/core/i18n"; import { uiCommonMessages } from "../../i18n/messages"; -import { DataGrid, type DataGridColumn } from "./DataGrid"; +import { DataGrid, dataGridSettingsToOverrides, type DataGridColumn } from "./DataGrid"; interface Row { id: string; @@ -636,6 +636,76 @@ describe("DataGrid column settings", () => { }); }); +describe("DataGrid column settings over defaultOverrides", () => { + const TWO: DataGridColumn<Row>[] = [ + { + key: "name", + header: "Name", + renderCell: (row) => row.name, + isAlwaysVisible: true, + }, + { key: "size", header: "Size", renderCell: (row) => String(row.size) }, + ]; + const headers = () => screen.getAllByRole("columnheader").map((th) => th.textContent); + + it("shows a column the defaults hide once the user checks it", async () => { + const onOverridesChange = vi.fn(); + renderGrid({ + data: makeRows(1), + columns: TWO, + columnSettings: { + defaultOverrides: { size: { hidden: true } }, + onOverridesChange, + }, + }); + expect(headers()).toEqual(["Name"]); + + await userEvent.click(screen.getByRole("button", { name: "Table Settings" })); + const dialog = await screen.findByRole("dialog"); + await userEvent.click(within(dialog).getByRole("checkbox", { name: "Size" })); + await userEvent.click(within(dialog).getByRole("button", { name: "Apply" })); + + expect(onOverridesChange).toHaveBeenLastCalledWith({ size: { hidden: false } }); + expect(headers()).toEqual(["Name", "Size"]); + }); + + it("restores the natural order over a reordering default", () => { + const columns = [ + { key: "a", isHiddenByDefault: false }, + { key: "b", isHiddenByDefault: false }, + { key: "c", isHiddenByDefault: true }, + ]; + const defaults = { b: { order: 0 }, a: { order: 1 } }; + const next = dataGridSettingsToOverrides({ + columns, + result: { selectedColumnKeys: ["a", "b"], columnOrder: ["a", "b", "c"] }, + defaultOverrides: defaults, + overrides: defaults, + isReorderable: true, + }); + const merged = { ...defaults, ...next }; + const order = (key: string) => + merged[key as keyof typeof merged]?.order ?? Number.MAX_SAFE_INTEGER; + expect(["c", "b", "a"].sort((x, y) => order(x) - order(y))).toEqual([ + "a", + "b", + "c", + ]); + }); + + it("writes nothing for a column that matches its default", () => { + expect( + dataGridSettingsToOverrides({ + columns: [{ key: "a" }, { key: "b" }], + result: { selectedColumnKeys: ["a"], columnOrder: ["a", "b"] }, + defaultOverrides: { b: { hidden: true } }, + overrides: { b: { hidden: true } }, + isReorderable: true, + }), + ).toEqual({}); + }); +}); + describe("DataGrid CSV export", () => { it("opens the export dialog with its notice and closes after the export", async () => { const onExport = vi.fn(async () => {}); diff --git a/src/components/DataGrid/DataGrid.tsx b/src/components/DataGrid/DataGrid.tsx index af35f7a..f38a6e3 100644 --- a/src/components/DataGrid/DataGrid.tsx +++ b/src/components/DataGrid/DataGrid.tsx @@ -359,6 +359,63 @@ export function dataGridColumnLabel<T>(column: DataGridColumn<T>): string { } /** Whether a column shows, given the user's overrides. */ +/** + * The overrides record a settings dialog result writes. `defaultOverrides` + * stays merged under it key by key, and a key it holds replaces the default's + * entry whole. So a column is left out only while its default (natural, then + * `defaultOverrides`) already renders what the user chose, and an entry that + * is written states everything that differs from the natural column: + * otherwise re-showing a column the defaults hide, or restoring the natural + * order over a reordering default, would bring the default back. + * + * @internal Exported for tests. + */ +export function dataGridSettingsToOverrides({ + columns, + result, + defaultOverrides = {}, + overrides, + isReorderable, +}: { + columns: ReadonlyArray<Pick<DataGridColumn<never>, "key" | "isHiddenByDefault">>; + result: DataGridSettingsResult; + defaultOverrides?: DataGridColumnOverrides; + /** The overrides in effect, defaults merged; their widths are kept. */ + overrides: DataGridColumnOverrides; + isReorderable: boolean; +}): DataGridColumnOverrides { + const naturalOrder = columns.map((column) => column.key); + // A default order is only undone by stating every column's position. + const writesOrder = + isReorderable && + (result.columnOrder.length !== naturalOrder.length || + result.columnOrder.some((key, index) => key !== naturalOrder[index]) || + columns.some((column) => defaultOverrides[column.key]?.order !== undefined)); + const next: DataGridColumnOverrides = {}; + for (const column of columns) { + const fallback = defaultOverrides[column.key]; + const naturalHidden = !!column.isHiddenByDefault; + const defaultHidden = fallback?.hidden ?? naturalHidden; + const hidden = !result.selectedColumnKeys.includes(column.key); + const override: DataGridColumnOverride = {}; + if (hidden !== naturalHidden || hidden !== defaultHidden) override.hidden = hidden; + if (writesOrder) { + const orderIndex = result.columnOrder.indexOf(column.key); + if (orderIndex !== -1) override.order = orderIndex; + } + // A settings change must not reset resized widths. + const width = overrides[column.key]?.width; + if (typeof width === "number") override.width = width; + const matchesDefault = + hidden === defaultHidden && + override.order === fallback?.order && + override.width === fallback?.width; + if (!matchesDefault && Object.keys(override).length > 0) + next[column.key] = override; + } + return next; +} + export function isDataGridColumnVisible<T>( column: Pick<DataGridColumn<T>, "key" | "isAlwaysVisible" | "isHiddenByDefault">, overrides?: DataGridColumnOverrides, @@ -935,27 +992,15 @@ export function DataGrid<T extends object = AnyRow>({ const applySettings = (result: DataGridSettingsResult) => { setIsSettingsOpen(false); - const naturalOrder = columns.map((column) => column.key); - const isReordered = - isReorderable && - (result.columnOrder.length !== naturalOrder.length || - result.columnOrder.some((key, index) => key !== naturalOrder[index])); - const next: DataGridColumnOverrides = {}; - for (const column of columns) { - const override: DataGridColumnOverride = {}; - const shouldBeVisible = result.selectedColumnKeys.includes(column.key); - if (shouldBeVisible === !!column.isHiddenByDefault) - override.hidden = !shouldBeVisible; - if (isReordered) { - const orderIndex = result.columnOrder.indexOf(column.key); - if (orderIndex !== -1) override.order = orderIndex; - } - // A settings change must not reset resized widths. - const persistedWidth = overrides[column.key]?.width; - if (typeof persistedWidth === "number") override.width = persistedWidth; - if (Object.keys(override).length > 0) next[column.key] = override; - } - setOverrides(next); + setOverrides( + dataGridSettingsToOverrides({ + columns, + result, + defaultOverrides: columnSettings?.defaultOverrides, + overrides, + isReorderable, + }), + ); }; return ( From ff325e2e1bc92fa1fdf68dc97a6a4497faf3d9c9 Mon Sep 17 00:00:00 2001 From: Jongeun <jongeun@lablup.com> Date: Fri, 2 Oct 2026 04:41:58 +0000 Subject: [PATCH 84/87] fix(Drawer): open ui-common's Modal above a scrimmed drawer A scrimmed drawer opens its <dialog> with showModal(), which puts it in the top layer and makes everything outside it inert. Modal portals to document.body and never enters the top layer, so a Modal opened from inside the drawer painted behind it and could not be clicked, focused or typed into. jsdom stubs showModal and inert, so the fork's test passed. The drawer now provides its dialog through ModalPortalContext once showModal() has run, while it is open and modal. A Modal inside renders into that dialog and enters the top layer as a manual popover, so it paints above the drawer and escapes the panel's transform and clipping; the drawer's content goes inert under it through the modal stack. A new host remounts the Modal's surface, so its level claim, background and popover follow the root element. Escape still closes the Modal first, then the drawer, and focus returns to each opener. Checked in Chromium: the Modal takes the hit test and typing, also when both open together. --- CONTRIBUTING.md | 10 ++-- src/components/Modal/Modal.css | 15 ++++++ src/components/Modal/Modal.tsx | 48 ++++++++++++++++-- src/components/Modal/modalStack.ts | 19 ++++++- src/forks/Drawer/Drawer.fork.test.tsx | 73 +++++++++++++++++++++++++-- src/forks/Drawer/Drawer.tsx | 25 +++++++-- src/forks/provenance.json | 2 +- 7 files changed, 172 insertions(+), 20 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b22264e..47e7bba 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -100,11 +100,11 @@ under Astryx's own name and import path until Astryx ships the fix. It exists because a product's pnpm `patchedDependencies` never reach that product's consumers, and ui-common's consumers import Astryx through ui-common. -| Fork | Where | Fix | Upstream | -| ----------------- | ----------------------------------- | ----------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | -| `ComplexSelector` | `@lablup/ui-common/ComplexSelector` | `hasClear` / `onClear` | [facebook/astryx#6362](https://github.com/facebook/astryx/pull/6362) | -| `Drawer` | `@lablup/ui-common/lab` | Escape goes through core's layer-dismissal stack (see below); `aria-modal` passes through | not filed | -| `Tour` | `@lablup/ui-common/lab` | a step's highlight is promoted once (StrictMode) | not filed | +| Fork | Where | Fix | Upstream | +| ----------------- | ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | +| `ComplexSelector` | `@lablup/ui-common/ComplexSelector` | `hasClear` / `onClear` | [facebook/astryx#6362](https://github.com/facebook/astryx/pull/6362) | +| `Drawer` | `@lablup/ui-common/lab` | Escape goes through core's layer-dismissal stack (see below); `aria-modal` passes through; a `Modal` inside a scrimmed drawer opens above it | not filed | +| `Tour` | `@lablup/ui-common/lab` | a step's highlight is promoted once (StrictMode) | not filed | `Drawer` is more than a fix: lab's drawer handles Escape itself, ahead of core's layer-dismissal stack, so an Escape in a popover, selector or modal diff --git a/src/components/Modal/Modal.css b/src/components/Modal/Modal.css index 54a0991..a2ef37d 100644 --- a/src/components/Modal/Modal.css +++ b/src/components/Modal/Modal.css @@ -15,6 +15,21 @@ justify-content: center; } + /* Inside a modal <dialog> the root is a top-layer popover (Modal.tsx): + undo the UA popover box, so it covers the viewport like the portal. */ + .uic-modal[popover] { + width: auto; + height: auto; + max-width: none; + max-height: none; + margin: 0; + padding: 0; + border: 0; + overflow: visible; + color: inherit; + background: none; + } + /* A closed modal keeps its children mounted, as a native `<dialog>` does. */ .uic-modal--closed { display: none; diff --git a/src/components/Modal/Modal.tsx b/src/components/Modal/Modal.tsx index 853dd9e..1a0de7c 100644 --- a/src/components/Modal/Modal.tsx +++ b/src/components/Modal/Modal.tsx @@ -13,7 +13,9 @@ * `aria-modal`, as `showModal()` would make them, but an element marked * `MODAL_LIVE_ATTRIBUTE` (`NotificationStack` is) stays reachable, so * notices stay visible and clickable over an open modal. Covered modal - * roots are inert too; see modalStack.ts. + * roots are inert too; see modalStack.ts. Inside a modal `<dialog>` (a + * scrimmed `Drawer`) it renders into that dialog and enters the top layer + * as a manual popover instead, since the rest of the page is inert there. * - **Nesting.** A modal opened from inside another paints above it, and * only the topmost one traps focus and answers Escape. * - **Content lifecycle.** Children mount on first open and stay mounted @@ -39,6 +41,7 @@ * </Modal> */ import { + useContext, useEffect, useEffectEvent, useId, @@ -70,7 +73,7 @@ import { devWarn, isFocusDetached, mergeRefs } from "@astryxdesign/core/utils"; import { useUicTranslator } from "../../i18n/useUicTranslator"; import { SkeletonText } from "../Skeleton/SkeletonText"; -import { MODAL_OPEN_ATTRIBUTE, useModalLevel } from "./modalStack"; +import { MODAL_OPEN_ATTRIBUTE, ModalPortalContext, useModalLevel } from "./modalStack"; import "./Modal.css"; const HEADING_SELECTOR = '[role="heading"], h1, h2, h3, h4, h5, h6'; @@ -189,7 +192,31 @@ function resolvePosition(position: Readonly<DialogPosition>): CSSProperties { }; } -export function Modal({ +/** One key per portal host, so a Modal remounts when its host changes. */ +const hostKeys = new WeakMap<HTMLElement, string>(); +let hostCount = 0; +function hostKey(host: HTMLElement | null): string { + if (!host) return "body"; + let key = hostKeys.get(host); + if (!key) { + hostCount += 1; + key = `dialog-${hostCount}`; + hostKeys.set(host, key); + } + return key; +} + +export function Modal(props: ModalProps) { + // Inside a modal <dialog> (a scrimmed Drawer) the page outside it is inert, + // so the surface renders into that dialog and enters the top layer. A new + // host remounts the surface: its level claim, inert background and popover + // all belong to one root element. + const dialogHost = useContext(ModalPortalContext); + return <ModalSurface key={hostKey(dialogHost)} {...props} dialogHost={dialogHost} />; +} + +function ModalSurface({ + dialogHost, isOpen, onOpenChange, isInline = false, @@ -226,7 +253,7 @@ export function Modal({ style, ref, ...rest -}: ModalProps) { +}: ModalProps & { dialogHost: HTMLElement | null }) { const t = useUicTranslator(); // Content mounts on the first open and then follows `unmountOnClose`. @@ -272,6 +299,16 @@ export function Modal({ const isActive = isOpen && !isInline; const isTopmost = useModalLevel(rootRef, isActive, zIndex); + // Inside a modal <dialog>, the top layer is the only way above it. + useLayoutEffect(() => { + const root = rootRef.current; + if (!isActive || !dialogHost || typeof root?.showPopover !== "function") return; + root.showPopover(); + return () => { + if (root.matches(":popover-open")) root.hidePopover(); + }; + }, [isActive, dialogHost]); + const { containerRef, focusFirst } = useFocusTrap<HTMLDivElement>({ isActive: isActive && isTopmost, }); @@ -455,6 +492,7 @@ export function Modal({ return createPortal( <div ref={rootRef} + popover={dialogHost ? "manual" : undefined} className={["uic-modal", !isOpen && "uic-modal--closed"] .filter(Boolean) .join(" ")} @@ -504,7 +542,7 @@ export function Modal({ </Dialog> </div> </div>, - document.body, + dialogHost ?? document.body, ); } diff --git a/src/components/Modal/modalStack.ts b/src/components/Modal/modalStack.ts index d478e50..06c6432 100644 --- a/src/components/Modal/modalStack.ts +++ b/src/components/Modal/modalStack.ts @@ -11,7 +11,13 @@ * Other portalled modal surfaces (a scrimmed drawer, say) claim a level with * `useModalLevel` so they stack on the same order as `Modal`. */ -import { useEffectEvent, useLayoutEffect, useState, type RefObject } from "react"; +import { + createContext, + useEffectEvent, + useLayoutEffect, + useState, + type RefObject, +} from "react"; import { devWarn } from "@astryxdesign/core/utils"; /** @@ -20,6 +26,17 @@ import { devWarn } from "@astryxdesign/core/utils"; */ export const MODAL_OPEN_ATTRIBUTE = "data-uic-modal-open"; +/** + * The open modal `<dialog>` a surface inside it renders into, instead of + * `document.body`. `showModal()` makes everything outside the dialog inert, + * so a `Modal` portalled to the body from inside a scrimmed drawer would sit + * behind the drawer, unreachable. `Drawer` (src/forks/Drawer) provides its + * dialog while it is modal; `Modal` renders there and enters the top layer + * itself, as a manual popover, so it paints above the drawer and escapes the + * panel's clipping and transform. Internal: not exported from the package. + */ +export const ModalPortalContext = createContext<HTMLElement | null>(null); + /** Nesting depth ceiling. Two surfaces past it share a level. */ export const MAX_MODAL_LEVEL = 80; diff --git a/src/forks/Drawer/Drawer.fork.test.tsx b/src/forks/Drawer/Drawer.fork.test.tsx index e838e12..df7c6d2 100644 --- a/src/forks/Drawer/Drawer.fork.test.tsx +++ b/src/forks/Drawer/Drawer.fork.test.tsx @@ -1,6 +1,7 @@ /** * ui-common's tests for its Drawer fork: the changes it carries (Escape - * through core's layer-dismissal stack, `aria-modal` passthrough), and that everything else renders + * through core's layer-dismissal stack, `aria-modal` passthrough, a `Modal` + * inside rendering into its dialog), and that everything else renders * exactly as lab's does. */ import { useState } from "react"; @@ -198,7 +199,7 @@ describe("Drawer fork: Escape goes through core's layer-dismissal stack", () => expect(closeOuter).not.toHaveBeenCalled(); }); - it("leaves an Escape in a modal portalled out of it to that modal", async () => { + it("leaves an Escape in a modal opened inside it to that modal", async () => { const closeDrawer = vi.fn(); const closeModal = vi.fn(); render( @@ -209,9 +210,6 @@ describe("Drawer fork: Escape goes through core's layer-dismissal stack", () => </Drawer>, ); const field = await screen.findByLabelText("Modal field"); - expect( - screen.getByRole("dialog", { name: "Details", hidden: true }).contains(field), - ).toBe(false); fireEvent.keyDown(field, { key: "Escape" }); expect(closeModal).toHaveBeenCalledWith(false); expect(closeDrawer).not.toHaveBeenCalled(); @@ -229,6 +227,71 @@ describe("Drawer fork: Escape goes through core's layer-dismissal stack", () => }); }); +// jsdom stubs showModal and has no top layer, so these pin the wiring; the +// stacking itself was checked in Chromium (a Modal inside a scrimmed drawer +// takes the hit test and typing, Escape closes it alone). +describe("Drawer fork: a Modal inside", () => { + it("renders into a scrimmed drawer's dialog and enters the top layer", async () => { + mockPopoverApi(); + render( + <Drawer isOpen onOpenChange={() => {}} label="Details"> + <input aria-label="Drawer field" /> + <Modal isOpen onOpenChange={() => {}} title="Edit"> + <input aria-label="Modal field" /> + </Modal> + </Drawer>, + ); + const field = await screen.findByLabelText("Modal field"); + const root = field.closest(".uic-modal") as HTMLElement; + const drawer = screen.getByRole("dialog", { name: "Details", hidden: true }); + expect(root.parentElement).toBe(drawer); + expect(root).toHaveAttribute("popover", "manual"); + expect(HTMLElement.prototype.showPopover).toHaveBeenCalled(); + expect(root.matches(":popover-open")).toBe(true); + // The drawer's own content is covered; the modal is not. + expect(screen.getByLabelText("Drawer field").closest("[inert]")).not.toBeNull(); + expect(field.closest("[inert]")).toBeNull(); + }); + + it("leaves the top layer when it closes", async () => { + mockPopoverApi(); + const { rerender } = render( + <Drawer isOpen onOpenChange={() => {}} label="Details"> + <Modal isOpen onOpenChange={() => {}} title="Edit"> + <input aria-label="Modal field" /> + </Modal> + </Drawer>, + ); + const root = (await screen.findByLabelText("Modal field")).closest( + ".uic-modal", + ) as HTMLElement; + rerender( + <Drawer isOpen onOpenChange={() => {}} label="Details"> + <Modal isOpen={false} onOpenChange={() => {}} title="Edit"> + <input aria-label="Modal field" /> + </Modal> + </Drawer>, + ); + expect(root.matches(":popover-open")).toBe(false); + }); + + it("stays in the body portal under a scrimless drawer, which inerts nothing", async () => { + mockPopoverApi(); + render( + <Drawer isOpen onOpenChange={() => {}} label="Details" hasScrim={false}> + <Modal isOpen onOpenChange={() => {}} title="Edit"> + <input aria-label="Modal field" /> + </Modal> + </Drawer>, + ); + const root = (await screen.findByLabelText("Modal field")).closest( + ".uic-modal", + ) as HTMLElement; + expect(root.parentElement).toBe(document.body); + expect(root).not.toHaveAttribute("popover"); + }); +}); + describe("Drawer fork: aria-modal", () => { it("keeps upstream's default: modal with a scrim, unset without", () => { const { rerender } = render( diff --git a/src/forks/Drawer/Drawer.tsx b/src/forks/Drawer/Drawer.tsx index 221de9a..1c37955 100644 --- a/src/forks/Drawer/Drawer.tsx +++ b/src/forks/Drawer/Drawer.tsx @@ -7,7 +7,7 @@ "use client"; /** - * Drawer, ui-common's copy of Astryx lab's, with two changes (not upstream): + * Drawer, ui-common's copy of Astryx lab's, with three changes (not upstream): * * - **Escape goes through core's layer-dismissal stack.** Lab's drawer runs * its own element-level Escape handler, which claims the press before the @@ -23,6 +23,13 @@ * - **`aria-modal` passes through.** A scrimless drawer is non-modal, but a * consumer that restores modality by hand (its own mask and focus trap) can * now say so; the default is unchanged. + * - **ui-common's `Modal` opens above it.** A scrimmed drawer is a modal + * `<dialog>`, which makes everything outside it inert, so a `Modal` + * portalled to the body sat behind the drawer, unreachable. The drawer + * provides its dialog through `ModalPortalContext` while it is open and + * modal, and a `Modal` inside renders there, in the top layer + * (modalStack.ts). Core's `Dialog` needs none of this: it is top layer + * itself, and ui-common's `Modal` is not. * * Everything else is upstream's. Its style namespaces are Astryx's compiled * output (src/forks/compiled.ts). The LIFO drawer registry is module-level, @@ -30,7 +37,8 @@ * `Drawer` is not exported by ui-common. Escape no longer consults it: it * only assigns non-modal z-indexes. * - * Delete this fork, and its exports.exclude.json entry, once lab ships both + * Delete this fork, and its exports.exclude.json entry, once lab ships the + * first two and `Modal` no longer needs the third * (CONTRIBUTING, "Forks of Astryx components"). */ @@ -84,6 +92,7 @@ import { overlayPaddingReset } from "@astryxdesign/core/Layout"; import { compiledStyles } from "../compiled"; import * as compiled from "./Drawer.styles"; +import { ModalPortalContext } from "../../components/Modal/modalStack"; import { useDrawerDialogPresence } from "./useDrawerDialogPresence"; // ============================================================================= @@ -301,6 +310,14 @@ export function Drawer({ setIsRendered, }); + // ui-common: the dialog a Modal inside renders into, set only once + // showModal() has run (the effect above), so the Modal's popover enters the + // top layer after the dialog and paints above it. + const [modalHost, setModalHost] = useState<HTMLDialogElement | null>(null); + useEffect(() => { + setModalHost(isOpen && hasScrim ? dialogRef.current : null); + }, [isOpen, hasScrim]); + // LIFO registry membership: register on open, unregister on close or // unmount. The returned z-index stacks non-modal siblings in open order. useEffect(() => { @@ -407,7 +424,9 @@ export function Drawer({ {/* Scrollable content area — tabIndex so the dialog's focusing steps land on the panel body rather than the first button inside. */} <div tabIndex={-1} className={CONTENT_CLASS_NAME}> - <LayerDepthProvider>{children}</LayerDepthProvider> + <LayerDepthProvider> + <ModalPortalContext value={modalHost}>{children}</ModalPortalContext> + </LayerDepthProvider> </div> {hasCloseButton && ( <div className={CONTROLS_CLASS_NAME}> diff --git a/src/forks/provenance.json b/src/forks/provenance.json index 72df54f..3b5ad38 100644 --- a/src/forks/provenance.json +++ b/src/forks/provenance.json @@ -26,7 +26,7 @@ "package": "@astryxdesign/lab", "version": "0.6.2-canary.c9fb1ad", "upstream": null, - "notes": "Deliberate divergence beyond a fix: Escape goes through core's layer-dismissal stack (useLayerDismissal + LayerDepthProvider, as core Dialog does) instead of lab's element-level handler and LIFO registry, so a popover, selector or modal inside the drawer closes first and a non-modal drawer closes on Escape wherever focus is. Also aria-modal passes through. On an Astryx bump, keep both unless lab's Drawer registers with the stack itself.", + "notes": "Deliberate divergence beyond a fix: Escape goes through core's layer-dismissal stack (useLayerDismissal + LayerDepthProvider, as core Dialog does) instead of lab's element-level handler and LIFO registry, so a popover, selector or modal inside the drawer closes first and a non-modal drawer closes on Escape wherever focus is. Also aria-modal passes through, and a scrimmed (modal) drawer provides its dialog through ModalPortalContext so ui-common's Modal opened inside renders there, in the top layer. On an Astryx bump, keep these unless lab's Drawer registers with the stack itself.", "files": { "src/Drawer/Drawer.tsx": "f72daf426b140474e79dfc79218238f70310e41893694f06459950ea69cabad3", "src/Drawer/useDrawerDialogPresence.ts": "46f7452950f7a771d14b65fffad8eeb7081a22caac13b6a5d66e7274f3607f6f", From 598eddf061597b5e00196d494c00f4472775a86e Mon Sep 17 00:00:00 2001 From: Jongeun <jongeun@lablup.com> Date: Fri, 2 Oct 2026 04:42:16 +0000 Subject: [PATCH 85/87] docs(changelog): list the pre-merge review fixes under Unreleased --- CHANGELOG.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fac4b77..8ffd2e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,29 @@ Versioning follows the policy in [CONTRIBUTING.md](CONTRIBUTING.md#versioning). ## [Unreleased] +### Fixed + +- **The `react` and `react-dom` peers are `^19.2.0`.** `Modal`, + `NotificationStack`, `UnitGrid` and `Form` import `useEffectEvent`, stable + since React 19.2; `^19.0.0` let 19.0 and 19.1 install and then fail. +- A `Modal` opened inside a scrimmed `Drawer` from `@lablup/ui-common/lab` + opens above it. It rendered behind the drawer's modal `<dialog>`, inert: + it could not be clicked, focused or typed into. +- `NotificationStack` unmounts a closed notice once its exit has played. With + `maxVisible`, or a new `notifications` array during the exit, it stayed + mounted for good, buttons still tabbable. Its countdown stays paused while + either hover or focus holds it, not until the first of them leaves. +- `DataGrid`'s settings dialog can re-show a column `defaultOverrides` hides + and restore the natural order over a default order; both came back. +- `ImageWithFallback`'s fallback keeps the image's semantics: an `img` named + by `alt`, or hidden when `alt` is empty. +- `DoubleBadge` and `DoubleToken` take string and object values mixed in one + `values` list, as documented. +- `ui-common upgrade` keeps JSX text whitespace: Badge children moved into + `label` (`{n} items`), and text next to an element given a TODO, lost their + leading spaces. For a library it adds the StyleX (and lab) peer even when + a devDependency already names the package. + ## [0.2.0-alpha.14] A paged selector moves in from a product, and `Drawer` hands Escape to the From fcf5df0bf6862db7218bf7935986e22f2def60fd Mon Sep 17 00:00:00 2001 From: Jong Eun Lee <jongeun@lablup.com> Date: Fri, 2 Oct 2026 12:52:39 +0800 Subject: [PATCH 86/87] fix: harden 0.2 for existing 0.1 consumers (layer order, CLI package, upgrade gaps) (#54) Cascade layer order emitted by every shipped stylesheet; the CLI split into @lablup/ui-common-cli (packages/cli, lockstep, prereleases under next); pnpm fixture install in CI; dev-only duplicate-copy warning; upgrade codemod gaps found on six 0.1 consumers; docs. --- .github/workflows/ci.yml | 34 +- .github/workflows/publish.yml | 122 ++++- .gitignore | 1 + .prettierignore | 2 +- CHANGELOG.md | 143 ++++- CONTRIBUTING.md | 28 +- README.md | 154 +++++- astryx.integration.mjs | 2 +- astryx/docs/ui-common.doc.mjs | 2 +- cli/paths.mjs | 180 ------- cli/semver.mjs | 103 ---- codemods/0.2/package-json.mjs | 163 ------ docs/astryx.md | 35 +- eslint.config.js | 6 +- fixture/pnpm-workspace.yaml | 12 + package.json | 14 +- packages/cli/LICENSE | 202 +++++++ packages/cli/NOTICE | 8 + packages/cli/README.md | 28 + {bin => packages/cli/bin}/ui-common.mjs | 0 {cli => packages/cli/cli}/agents.mjs | 7 +- {cli => packages/cli/cli}/diff.mjs | 0 {cli => packages/cli/cli}/lab-peer.mjs | 0 {cli => packages/cli/cli}/main.mjs | 32 +- {cli => packages/cli/cli}/passthrough.mjs | 0 packages/cli/cli/paths.mjs | 290 ++++++++++ {cli => packages/cli/cli}/report.mjs | 16 +- packages/cli/cli/resolve.mjs | 257 +++++++++ {cli => packages/cli/cli}/rewrite.mjs | 0 packages/cli/cli/semver.mjs | 278 ++++++++++ {cli => packages/cli/cli}/shadow.mjs | 0 {cli => packages/cli/cli}/sync-astryx.mjs | 92 +++- {cli => packages/cli/cli}/upgrade.mjs | 260 ++++++++- .../cli/codemods}/0.2/components.mjs | 269 ++++++++- .../cli/codemods}/0.2/elements.mjs | 0 .../cli/codemods}/0.2/index.mjs | 11 +- .../cli/codemods}/0.2/legacy-classes.json | 0 packages/cli/codemods/0.2/local-modules.mjs | 510 ++++++++++++++++++ .../cli/codemods}/0.2/map.mjs | 16 +- packages/cli/codemods/0.2/package-json.mjs | 333 ++++++++++++ .../cli/codemods}/0.2/scan.mjs | 103 +++- .../cli/codemods}/0.2/stylesheets.mjs | 178 +++++- packages/cli/codemods/0.2/theme.mjs | 325 +++++++++++ .../cli/codemods}/lib/jsx.mjs | 0 .../cli/codemods}/lib/todo.mjs | 0 .../cli/codemods}/registry.mjs | 5 +- .../cli/codemods}/upstream.mjs | 0 .../cli/migration}/0.1-to-0.2.json | 0 packages/cli/package.json | 50 ++ .../cli/scripts}/extract-legacy-classes.mjs | 3 +- {test => packages/cli/test}/cli/cli.test.ts | 10 +- .../cli/test}/cli/lab-peer.test.ts | 7 +- .../cli/test}/upgrade/codemods.test.ts | 77 +++ .../fixtures/adapter/expected/package.json | 3 + .../src/design-system/common-adapters.tsx | 0 .../src/design-system/common-components.css | 0 .../fixtures/adapter/expected/src/index.scss | 0 .../fixtures/adapter/expected/src/main.tsx | 10 + .../adapter/expected/src/ui-common-entry.css | 0 .../expected/ui-common-upgrade-report.md | 20 +- .../fixtures/adapter/input/package.json | 0 .../src/design-system/common-adapters.tsx | 0 .../src/design-system/common-components.css | 0 .../fixtures/adapter/input/src/index.scss | 0 .../fixtures/adapter/input/src/main.tsx | 0 .../fixtures/css-entry/expected/package.json | 3 + .../fixtures/css-entry/expected/src/index.css | 0 .../fixtures/css-entry/expected/src/print.css | 0 .../expected/ui-common-upgrade-report.md | 21 +- .../fixtures/css-entry/input/package.json | 0 .../fixtures/css-entry/input/src/index.css | 0 .../fixtures/css-entry/input/src/print.css | 0 .../fixtures/library/expected/package.json | 4 +- .../expected/src/chat/ReasoningBlock.tsx | 0 .../expected/ui-common-upgrade-report.md | 19 +- .../fixtures/library/input/package.json | 3 +- .../library/input/src/chat/ReasoningBlock.tsx | 0 .../local-barrel/expected/package.json | 13 + .../src/components/common/Select/index.tsx | 11 + .../expected/src/components/common/index.ts | 9 + .../expected/src/components/common/status.ts | 6 + .../expected/src/components/common/table.ts | 3 + .../expected/src/pages/Overview.tsx | 24 + .../expected/src/pages/Settings.tsx | 13 + .../local-barrel/expected/tsconfig.json | 10 + .../expected/ui-common-upgrade-report.md | 109 ++++ .../fixtures/local-barrel/input/package.json | 9 + .../src/components/common/Select/index.tsx | 7 + .../input/src/components/common/index.ts | 7 + .../input/src/components/common/status.ts | 4 + .../input/src/components/common/table.ts | 3 + .../local-barrel/input/src/pages/Overview.tsx | 18 + .../local-barrel/input/src/pages/Settings.tsx | 12 + .../fixtures/local-barrel/input/tsconfig.json | 10 + .../root-barrel/expected/package.json | 3 + .../expected/src/chat/InputPopup.tsx | 0 .../expected/src/pages/ModelsPage.test.tsx | 0 .../expected/src/pages/ModelsPage.tsx | 0 .../expected/src/themes/violet.css | 0 .../expected/ui-common-upgrade-report.md | 28 +- .../fixtures/root-barrel/input/package.json | 0 .../root-barrel/input/src/chat/InputPopup.tsx | 0 .../input/src/pages/ModelsPage.test.tsx | 0 .../input/src/pages/ModelsPage.tsx | 0 .../root-barrel/input/src/themes/violet.css | 0 .../subpath-barrel/expected/package.json | 1 + .../components/common/DataTableWrapper.tsx | 0 .../src/components/common/Select.test.tsx | 1 + .../expected/src/components/common/index.ts | 8 +- .../src/reports/ReportPreviewFrame.tsx | 0 .../expected/src/styles/families.css | 0 .../expected/src/styles/theme.ts | 0 .../expected/src/styles/ui-common-entry.css | 0 .../expected/ui-common-upgrade-report.md | 47 +- .../subpath-barrel/input/package.json | 0 .../components/common/DataTableWrapper.tsx | 0 .../src/components/common/Select.test.tsx | 0 .../input/src/components/common/index.ts | 0 .../input/src/reports/ReportPreviewFrame.tsx | 0 .../input/src/styles/families.css | 0 .../subpath-barrel/input/src/styles/theme.ts | 0 .../fixtures/unwired/expected/index.html | 11 + .../fixtures/unwired/expected/package.json | 14 + .../fixtures/unwired/expected/src/App.tsx | 5 + .../fixtures/unwired/expected/src/index.css | 13 + .../fixtures/unwired/expected/src/main.tsx | 17 + .../unwired/expected/src/themeToggle.ts | 9 + .../unwired/expected/src/ui-common-entry.css | 14 + .../expected/ui-common-upgrade-report.md | 84 +++ .../upgrade/fixtures/unwired/input/index.html | 11 + .../fixtures/unwired/input/package.json | 10 + .../fixtures/unwired/input/src/App.tsx | 9 + .../fixtures/unwired/input/src/index.css | 13 + .../fixtures/unwired/input/src/main.tsx | 12 + .../fixtures/unwired/input/src/themeToggle.ts | 9 + .../cli/test/upgrade/local-modules.test.ts | 186 +++++++ .../cli/test}/upgrade/package-json.test.ts | 8 +- .../cli/test}/upgrade/stylesheets.test.ts | 51 +- .../cli/test}/upgrade/upgrade.test.ts | 196 ++++++- pnpm-lock.yaml | 34 +- pnpm-workspace.yaml | 6 + scripts/check-boundary.mjs | 33 +- scripts/check-fixture-styles.mjs | 36 +- scripts/check-pack.mjs | 170 +++++- scripts/layer-order.d.mts | 11 + scripts/layer-order.mjs | 83 +++ src/components/Modal/modalStack.ts | 3 + src/i18n/useUicTranslator.ts | 2 + src/instance.test.ts | 96 ++++ src/instance.ts | 111 ++++ src/layerOrder.test.ts | 126 +++++ src/migrationMap.test.ts | 5 +- src/types/build-constants.d.ts | 9 + src/ui-common.css | 4 +- .../fixtures/adapter/expected/src/main.tsx | 6 - vite.config.ts | 55 +- 156 files changed, 5543 insertions(+), 726 deletions(-) delete mode 100644 cli/paths.mjs delete mode 100644 cli/semver.mjs delete mode 100644 codemods/0.2/package-json.mjs create mode 100644 fixture/pnpm-workspace.yaml create mode 100644 packages/cli/LICENSE create mode 100644 packages/cli/NOTICE create mode 100644 packages/cli/README.md rename {bin => packages/cli/bin}/ui-common.mjs (100%) rename {cli => packages/cli/cli}/agents.mjs (95%) rename {cli => packages/cli/cli}/diff.mjs (100%) rename {cli => packages/cli/cli}/lab-peer.mjs (100%) rename {cli => packages/cli/cli}/main.mjs (66%) rename {cli => packages/cli/cli}/passthrough.mjs (100%) create mode 100644 packages/cli/cli/paths.mjs rename {cli => packages/cli/cli}/report.mjs (85%) create mode 100644 packages/cli/cli/resolve.mjs rename {cli => packages/cli/cli}/rewrite.mjs (100%) create mode 100644 packages/cli/cli/semver.mjs rename {cli => packages/cli/cli}/shadow.mjs (100%) rename {cli => packages/cli/cli}/sync-astryx.mjs (81%) rename {cli => packages/cli/cli}/upgrade.mjs (65%) rename {codemods => packages/cli/codemods}/0.2/components.mjs (67%) rename {codemods => packages/cli/codemods}/0.2/elements.mjs (100%) rename {codemods => packages/cli/codemods}/0.2/index.mjs (86%) rename {codemods => packages/cli/codemods}/0.2/legacy-classes.json (100%) create mode 100644 packages/cli/codemods/0.2/local-modules.mjs rename {codemods => packages/cli/codemods}/0.2/map.mjs (94%) create mode 100644 packages/cli/codemods/0.2/package-json.mjs rename {codemods => packages/cli/codemods}/0.2/scan.mjs (68%) rename {codemods => packages/cli/codemods}/0.2/stylesheets.mjs (60%) create mode 100644 packages/cli/codemods/0.2/theme.mjs rename {codemods => packages/cli/codemods}/lib/jsx.mjs (100%) rename {codemods => packages/cli/codemods}/lib/todo.mjs (100%) rename {codemods => packages/cli/codemods}/registry.mjs (83%) rename {codemods => packages/cli/codemods}/upstream.mjs (100%) rename {migration => packages/cli/migration}/0.1-to-0.2.json (100%) create mode 100644 packages/cli/package.json rename {scripts => packages/cli/scripts}/extract-legacy-classes.mjs (94%) rename {test => packages/cli/test}/cli/cli.test.ts (97%) rename {test => packages/cli/test}/cli/lab-peer.test.ts (96%) rename {test => packages/cli/test}/upgrade/codemods.test.ts (76%) rename {test => packages/cli/test}/upgrade/fixtures/adapter/expected/package.json (76%) rename {test => packages/cli/test}/upgrade/fixtures/adapter/expected/src/design-system/common-adapters.tsx (100%) rename {test => packages/cli/test}/upgrade/fixtures/adapter/expected/src/design-system/common-components.css (100%) rename {test => packages/cli/test}/upgrade/fixtures/adapter/expected/src/index.scss (100%) create mode 100644 packages/cli/test/upgrade/fixtures/adapter/expected/src/main.tsx rename {test => packages/cli/test}/upgrade/fixtures/adapter/expected/src/ui-common-entry.css (100%) rename {test => packages/cli/test}/upgrade/fixtures/adapter/expected/ui-common-upgrade-report.md (88%) rename {test => packages/cli/test}/upgrade/fixtures/adapter/input/package.json (100%) rename {test => packages/cli/test}/upgrade/fixtures/adapter/input/src/design-system/common-adapters.tsx (100%) rename {test => packages/cli/test}/upgrade/fixtures/adapter/input/src/design-system/common-components.css (100%) rename {test => packages/cli/test}/upgrade/fixtures/adapter/input/src/index.scss (100%) rename {test => packages/cli/test}/upgrade/fixtures/adapter/input/src/main.tsx (100%) rename {test => packages/cli/test}/upgrade/fixtures/css-entry/expected/package.json (67%) rename {test => packages/cli/test}/upgrade/fixtures/css-entry/expected/src/index.css (100%) rename {test => packages/cli/test}/upgrade/fixtures/css-entry/expected/src/print.css (100%) rename {test => packages/cli/test}/upgrade/fixtures/css-entry/expected/ui-common-upgrade-report.md (65%) rename {test => packages/cli/test}/upgrade/fixtures/css-entry/input/package.json (100%) rename {test => packages/cli/test}/upgrade/fixtures/css-entry/input/src/index.css (100%) rename {test => packages/cli/test}/upgrade/fixtures/css-entry/input/src/print.css (100%) rename {test => packages/cli/test}/upgrade/fixtures/library/expected/package.json (76%) rename {test => packages/cli/test}/upgrade/fixtures/library/expected/src/chat/ReasoningBlock.tsx (100%) rename {test => packages/cli/test}/upgrade/fixtures/library/expected/ui-common-upgrade-report.md (74%) rename {test => packages/cli/test}/upgrade/fixtures/library/input/package.json (77%) rename {test => packages/cli/test}/upgrade/fixtures/library/input/src/chat/ReasoningBlock.tsx (100%) create mode 100644 packages/cli/test/upgrade/fixtures/local-barrel/expected/package.json create mode 100644 packages/cli/test/upgrade/fixtures/local-barrel/expected/src/components/common/Select/index.tsx create mode 100644 packages/cli/test/upgrade/fixtures/local-barrel/expected/src/components/common/index.ts create mode 100644 packages/cli/test/upgrade/fixtures/local-barrel/expected/src/components/common/status.ts create mode 100644 packages/cli/test/upgrade/fixtures/local-barrel/expected/src/components/common/table.ts create mode 100644 packages/cli/test/upgrade/fixtures/local-barrel/expected/src/pages/Overview.tsx create mode 100644 packages/cli/test/upgrade/fixtures/local-barrel/expected/src/pages/Settings.tsx create mode 100644 packages/cli/test/upgrade/fixtures/local-barrel/expected/tsconfig.json create mode 100644 packages/cli/test/upgrade/fixtures/local-barrel/expected/ui-common-upgrade-report.md create mode 100644 packages/cli/test/upgrade/fixtures/local-barrel/input/package.json create mode 100644 packages/cli/test/upgrade/fixtures/local-barrel/input/src/components/common/Select/index.tsx create mode 100644 packages/cli/test/upgrade/fixtures/local-barrel/input/src/components/common/index.ts create mode 100644 packages/cli/test/upgrade/fixtures/local-barrel/input/src/components/common/status.ts create mode 100644 packages/cli/test/upgrade/fixtures/local-barrel/input/src/components/common/table.ts create mode 100644 packages/cli/test/upgrade/fixtures/local-barrel/input/src/pages/Overview.tsx create mode 100644 packages/cli/test/upgrade/fixtures/local-barrel/input/src/pages/Settings.tsx create mode 100644 packages/cli/test/upgrade/fixtures/local-barrel/input/tsconfig.json rename {test => packages/cli/test}/upgrade/fixtures/root-barrel/expected/package.json (75%) rename {test => packages/cli/test}/upgrade/fixtures/root-barrel/expected/src/chat/InputPopup.tsx (100%) rename {test => packages/cli/test}/upgrade/fixtures/root-barrel/expected/src/pages/ModelsPage.test.tsx (100%) rename {test => packages/cli/test}/upgrade/fixtures/root-barrel/expected/src/pages/ModelsPage.tsx (100%) rename {test => packages/cli/test}/upgrade/fixtures/root-barrel/expected/src/themes/violet.css (100%) rename {test => packages/cli/test}/upgrade/fixtures/root-barrel/expected/ui-common-upgrade-report.md (76%) rename {test => packages/cli/test}/upgrade/fixtures/root-barrel/input/package.json (100%) rename {test => packages/cli/test}/upgrade/fixtures/root-barrel/input/src/chat/InputPopup.tsx (100%) rename {test => packages/cli/test}/upgrade/fixtures/root-barrel/input/src/pages/ModelsPage.test.tsx (100%) rename {test => packages/cli/test}/upgrade/fixtures/root-barrel/input/src/pages/ModelsPage.tsx (100%) rename {test => packages/cli/test}/upgrade/fixtures/root-barrel/input/src/themes/violet.css (100%) rename {test => packages/cli/test}/upgrade/fixtures/subpath-barrel/expected/package.json (86%) rename {test => packages/cli/test}/upgrade/fixtures/subpath-barrel/expected/src/components/common/DataTableWrapper.tsx (100%) rename {test/upgrade/fixtures/subpath-barrel/input => packages/cli/test/upgrade/fixtures/subpath-barrel/expected}/src/components/common/Select.test.tsx (70%) rename {test => packages/cli/test}/upgrade/fixtures/subpath-barrel/expected/src/components/common/index.ts (58%) rename {test => packages/cli/test}/upgrade/fixtures/subpath-barrel/expected/src/reports/ReportPreviewFrame.tsx (100%) rename {test => packages/cli/test}/upgrade/fixtures/subpath-barrel/expected/src/styles/families.css (100%) rename {test => packages/cli/test}/upgrade/fixtures/subpath-barrel/expected/src/styles/theme.ts (100%) rename {test => packages/cli/test}/upgrade/fixtures/subpath-barrel/expected/src/styles/ui-common-entry.css (100%) rename {test => packages/cli/test}/upgrade/fixtures/subpath-barrel/expected/ui-common-upgrade-report.md (69%) rename {test => packages/cli/test}/upgrade/fixtures/subpath-barrel/input/package.json (100%) rename {test => packages/cli/test}/upgrade/fixtures/subpath-barrel/input/src/components/common/DataTableWrapper.tsx (100%) rename {test/upgrade/fixtures/subpath-barrel/expected => packages/cli/test/upgrade/fixtures/subpath-barrel/input}/src/components/common/Select.test.tsx (100%) rename {test => packages/cli/test}/upgrade/fixtures/subpath-barrel/input/src/components/common/index.ts (100%) rename {test => packages/cli/test}/upgrade/fixtures/subpath-barrel/input/src/reports/ReportPreviewFrame.tsx (100%) rename {test => packages/cli/test}/upgrade/fixtures/subpath-barrel/input/src/styles/families.css (100%) rename {test => packages/cli/test}/upgrade/fixtures/subpath-barrel/input/src/styles/theme.ts (100%) create mode 100644 packages/cli/test/upgrade/fixtures/unwired/expected/index.html create mode 100644 packages/cli/test/upgrade/fixtures/unwired/expected/package.json create mode 100644 packages/cli/test/upgrade/fixtures/unwired/expected/src/App.tsx create mode 100644 packages/cli/test/upgrade/fixtures/unwired/expected/src/index.css create mode 100644 packages/cli/test/upgrade/fixtures/unwired/expected/src/main.tsx create mode 100644 packages/cli/test/upgrade/fixtures/unwired/expected/src/themeToggle.ts create mode 100644 packages/cli/test/upgrade/fixtures/unwired/expected/src/ui-common-entry.css create mode 100644 packages/cli/test/upgrade/fixtures/unwired/expected/ui-common-upgrade-report.md create mode 100644 packages/cli/test/upgrade/fixtures/unwired/input/index.html create mode 100644 packages/cli/test/upgrade/fixtures/unwired/input/package.json create mode 100644 packages/cli/test/upgrade/fixtures/unwired/input/src/App.tsx create mode 100644 packages/cli/test/upgrade/fixtures/unwired/input/src/index.css create mode 100644 packages/cli/test/upgrade/fixtures/unwired/input/src/main.tsx create mode 100644 packages/cli/test/upgrade/fixtures/unwired/input/src/themeToggle.ts create mode 100644 packages/cli/test/upgrade/local-modules.test.ts rename {test => packages/cli/test}/upgrade/package-json.test.ts (92%) rename {test => packages/cli/test}/upgrade/stylesheets.test.ts (64%) rename {test => packages/cli/test}/upgrade/upgrade.test.ts (56%) create mode 100644 scripts/layer-order.d.mts create mode 100644 scripts/layer-order.mjs create mode 100644 src/instance.test.ts create mode 100644 src/instance.ts create mode 100644 src/layerOrder.test.ts create mode 100644 src/types/build-constants.d.ts delete mode 100644 test/upgrade/fixtures/adapter/expected/src/main.tsx diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fe235a1..ead0a27 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,20 +78,38 @@ jobs: run: | pnpm run build pnpm pack --pack-destination "$RUNNER_TEMP" - - - name: Install the tarball into a clean React project + pnpm --filter @lablup/ui-common-cli pack --pack-destination "$RUNNER_TEMP" + + # pnpm, as every consumer installs: pnpm 11 fails the install with + # ERR_PNPM_IGNORED_BUILDS when a dependency's build script is neither + # allowed nor declined, which npm never reports. fixture/pnpm-workspace.yaml + # holds the allowBuilds block a consumer needs, and makes the fixture its + # own workspace, apart from this repository's. + - name: Install the tarballs into a clean React project working-directory: fixture run: | - TARBALL=$(find "$RUNNER_TEMP" -name 'lablup-ui-common-*.tgz' | head -1) - echo "Installing $TARBALL" - npm install --no-package-lock "$TARBALL" - npm install --no-package-lock + LIB=$(find "$RUNNER_TEMP" -name 'lablup-ui-common-[0-9]*.tgz' | head -1) + CLI=$(find "$RUNNER_TEMP" -name 'lablup-ui-common-cli-*.tgz' | head -1) + echo "Installing $LIB and $CLI" + pnpm add "$LIB" + pnpm add --save-dev "$CLI" - name: Type-check and build the fixture against the packed artifact working-directory: fixture run: | - npx tsc --noEmit - npx vite build + pnpm exec tsc --noEmit + pnpm exec vite build + + # The CLI resolves the project's @lablup/ui-common and the Astryx CLI it + # pins from two different packages; this runs it where a consumer would. + - name: Run the ui-common bin from the fixture + working-directory: fixture + run: | + pnpm exec ui-common --version --verbose + pnpm exec ui-common --help > /dev/null + pnpm exec ui-common agents > /dev/null + pnpm exec ui-common component Button --json > /dev/null + pnpm exec ui-common upgrade --from 0.1 --dry-run > /dev/null # Building is not the same as being styled. 0.1.0-alpha.0 built here # green while shipping every component's CSS as an asset nothing could diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 1734c47..6b59761 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -56,39 +56,71 @@ jobs: - name: Verify before publishing run: pnpm run verify - - name: Confirm the release tag matches the package version - if: github.event_name == 'release' + # @lablup/ui-common (the root) and @lablup/ui-common-cli (packages/cli) + # are released together at one version: the CLI takes the library as an + # exact peer. + - name: Confirm both packages are at one version run: | - PKG_VERSION=$(node -p "require('./package.json').version") - TAG="${GITHUB_REF_NAME#v}" - if [ "$PKG_VERSION" != "$TAG" ]; then - echo "package.json is $PKG_VERSION but the release tag is $TAG" >&2 + LIB=$(node -p "require('./package.json').version") + CLI=$(node -p "require('./packages/cli/package.json').version") + if [ "$LIB" != "$CLI" ]; then + echo "@lablup/ui-common is $LIB but @lablup/ui-common-cli is $CLI" >&2 exit 1 fi + - name: Confirm the release tag matches the package versions + if: github.event_name == 'release' + run: | + TAG="${GITHUB_REF_NAME#v}" + for PKG in package.json packages/cli/package.json; do + PKG_VERSION=$(node -p "require('./$PKG').version") + if [ "$PKG_VERSION" != "$TAG" ]; then + echo "$PKG is $PKG_VERSION but the release tag is $TAG" >&2 + exit 1 + fi + done + + # Any prerelease goes to `next`, a plain version to `latest`. 0.1's + # prereleases went out under `alpha`; that tag is no longer moved. + # The registry still sets `latest` on a package's first publish, so + # @lablup/ui-common-cli's `latest` is its first alpha until 0.2.0; the + # docs say `@next` until then. Do not move `latest` here. - name: Choose the dist-tag from the version id: tag run: | VERSION=$(node -p "require('./package.json').version") case "$VERSION" in - *-alpha*) TAG=alpha ;; - *-beta*) TAG=beta ;; - *-rc*) TAG=rc ;; - *-*) TAG=next ;; - *) TAG=latest ;; + *-*) TAG=next ;; + *) TAG=latest ;; esac echo "Publishing $VERSION under dist-tag $TAG" echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + # pnpm packs both, rewriting the CLI's `workspace:*` peer to the exact + # version; the library goes first so the CLI's peer exists when it lands. + - name: Pack both packages + run: | + mkdir -p "$RUNNER_TEMP/packed" + pnpm pack --pack-destination "$RUNNER_TEMP/packed" + pnpm --filter @lablup/ui-common-cli pack --pack-destination "$RUNNER_TEMP/packed" + ls -la "$RUNNER_TEMP/packed" - name: Publish if: github.event_name == 'release' || inputs.dry_run == false - run: pnpm publish --no-git-checks --access public --tag ${{ steps.tag.outputs.tag }} --registry https://npm.pkg.github.com + run: | + for PKG in lablup-ui-common lablup-ui-common-cli; do + npm publish "$RUNNER_TEMP/packed/$PKG-${{ steps.tag.outputs.version }}.tgz" --access public --tag ${{ steps.tag.outputs.tag }} --registry https://npm.pkg.github.com + done env: NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Dry run if: github.event_name == 'workflow_dispatch' && inputs.dry_run - run: pnpm pack && ls -la *.tgz + run: | + for PKG in lablup-ui-common lablup-ui-common-cli; do + npm publish "$RUNNER_TEMP/packed/$PKG-${{ steps.tag.outputs.version }}.tgz" --dry-run --access public --tag ${{ steps.tag.outputs.tag }} --registry https://npm.pkg.github.com + done # GitHub Packages requires authentication even for public packages, so an # open-source consumer cannot install from it without every contributor @@ -130,46 +162,78 @@ jobs: - name: Verify before publishing run: pnpm run verify - - name: Confirm the release tag matches the package version - if: github.event_name == 'release' + # @lablup/ui-common (the root) and @lablup/ui-common-cli (packages/cli) + # are released together at one version: the CLI takes the library as an + # exact peer. + - name: Confirm both packages are at one version run: | - PKG_VERSION=$(node -p "require('./package.json').version") - TAG="${GITHUB_REF_NAME#v}" - if [ "$PKG_VERSION" != "$TAG" ]; then - echo "package.json is $PKG_VERSION but the release tag is $TAG" >&2 + LIB=$(node -p "require('./package.json').version") + CLI=$(node -p "require('./packages/cli/package.json').version") + if [ "$LIB" != "$CLI" ]; then + echo "@lablup/ui-common is $LIB but @lablup/ui-common-cli is $CLI" >&2 exit 1 fi + - name: Confirm the release tag matches the package versions + if: github.event_name == 'release' + run: | + TAG="${GITHUB_REF_NAME#v}" + for PKG in package.json packages/cli/package.json; do + PKG_VERSION=$(node -p "require('./$PKG').version") + if [ "$PKG_VERSION" != "$TAG" ]; then + echo "$PKG is $PKG_VERSION but the release tag is $TAG" >&2 + exit 1 + fi + done + + # Any prerelease goes to `next`, a plain version to `latest`. 0.1's + # prereleases went out under `alpha`; that tag is no longer moved. + # The registry still sets `latest` on a package's first publish, so + # @lablup/ui-common-cli's `latest` is its first alpha until 0.2.0; the + # docs say `@next` until then. Do not move `latest` here. - name: Choose the dist-tag from the version id: tag run: | VERSION=$(node -p "require('./package.json').version") case "$VERSION" in - *-alpha*) TAG=alpha ;; - *-beta*) TAG=beta ;; - *-rc*) TAG=rc ;; - *-*) TAG=next ;; - *) TAG=latest ;; + *-*) TAG=next ;; + *) TAG=latest ;; esac echo "Publishing $VERSION under dist-tag $TAG" echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + # pnpm packs both, rewriting the CLI's `workspace:*` peer to the exact + # version; the library goes first so the CLI's peer exists when it lands. + - name: Pack both packages + run: | + mkdir -p "$RUNNER_TEMP/packed" + pnpm pack --pack-destination "$RUNNER_TEMP/packed" + pnpm --filter @lablup/ui-common-cli pack --pack-destination "$RUNNER_TEMP/packed" + ls -la "$RUNNER_TEMP/packed" - name: Confirm the credential is valid for npmjs run: npm whoami --registry https://registry.npmjs.org env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - # npm rather than pnpm here: provenance is an npm CLI feature and this - # is the one step that needs it. The tarball is identical either way, - # since both pack from the same files allowlist. + # npm publishes the tarballs pnpm packed: provenance is an npm CLI + # feature, and npm would leave the CLI's `workspace:*` peer unrewritten + # if it packed the directory itself. - name: Publish if: github.event_name == 'release' || inputs.dry_run == false - run: npm publish --provenance --access public --tag ${{ steps.tag.outputs.tag }} --registry https://registry.npmjs.org + run: | + for PKG in lablup-ui-common lablup-ui-common-cli; do + npm publish "$RUNNER_TEMP/packed/$PKG-${{ steps.tag.outputs.version }}.tgz" --provenance --access public --tag ${{ steps.tag.outputs.tag }} --registry https://registry.npmjs.org + done env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - name: Dry run if: github.event_name == 'workflow_dispatch' && inputs.dry_run - run: npm publish --dry-run --access public --tag ${{ steps.tag.outputs.tag }} --registry https://registry.npmjs.org + run: | + for PKG in lablup-ui-common lablup-ui-common-cli; do + npm publish "$RUNNER_TEMP/packed/$PKG-${{ steps.tag.outputs.version }}.tgz" --dry-run --access public --tag ${{ steps.tag.outputs.tag }} --registry https://registry.npmjs.org + done env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.gitignore b/.gitignore index 2d577cb..1e30d3e 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ coverage/ .eslintcache fixture/node_modules/ fixture/dist/ +fixture/pnpm-lock.yaml diff --git a/.prettierignore b/.prettierignore index 529117b..85289f4 100644 --- a/.prettierignore +++ b/.prettierignore @@ -3,5 +3,5 @@ coverage/ pnpm-lock.yaml LICENSE src/theme/*/built/ -test/upgrade/fixtures/ +packages/cli/test/upgrade/fixtures/ .claude/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ffd2e9..e0d24ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,8 +5,64 @@ Versioning follows the policy in [CONTRIBUTING.md](CONTRIBUTING.md#versioning). ## [Unreleased] +### Changed + +- **The `ui-common` CLI is its own package, `@lablup/ui-common-cli`**, in + this repository under `packages/cli` and released in lockstep with the + library, the way Astryx ships `@astryxdesign/cli` beside + `@astryxdesign/core`. `@lablup/ui-common` no longer has a `bin` and no + longer depends on `@astryxdesign/cli`, `jscodeshift` or `postcss`: a + production install of an app on the library alone drops from 196 MB (132 + packages) to 31 MB (25). Run the 0.1 upgrade with + `pnpm dlx @lablup/ui-common-cli@next upgrade --from 0.1` (`@next` until + 0.2.0 is published: npm points a new package's `latest` at its first + prerelease); after it, + `@lablup/ui-common-cli` is a devDependency and `pnpm exec ui-common` works + as before. The CLI needs Node 22.13 or later, as `@astryxdesign/cli` does. +- Prereleases publish under the `next` dist-tag; only a plain version moves + `latest`. The 0.1 line's `alpha` tag stays where it is. The registry sets + a new package's `latest` on its first publish regardless, so until 0.2.0 + `@lablup/ui-common-cli`'s `latest` is its first alpha: name `@next`. +- `ui-common upgrade --from 0.1`: + - migrates elements a project imports through its own barrels (relative + imports and tsconfig `paths`), and lists local wrapper components around + a 0.1 component for review instead of rewriting their call sites; + - drops a local re-export of a removed type together with its import (it + used to refuse to write such a file); + - wires the 0.2 stylesheets into the app entry when the project never + imported `styles/base.css`, and places that import before any + `@lablup/ui-common` import; + - adds `<Theme theme={lablupTheme}>` at a single clear root render and + reports 0.1 theme switches (`data-theme="orange-*"`, `[data-theme]` + selectors); + - searches the whole project for manual-review findings (tests, e2e, + scripts), not only `src/`; `--scan <path>` narrows it; + - narrows a library's `react` / `react-dom` peers to the React ui-common's + own peer starts at, alternative by alternative (`>=18 <21 || ^22` → + `>=19.2.0 <21 || ^22`), and reports a range with no such React; + - adds `allowBuilds` for `@astryxdesign/core` and `@astryxdesign/cli` to a + pnpm project's `pnpm-workspace.yaml`; + - adds `@lablup/ui-common-cli` as a devDependency; + - lists class names the project also defines and uses itself as lower + confidence. + ### Fixed +- **`ui-common` was the lowest cascade layer in consumer bundles.** Each + component module imports its own stylesheet, and each opened + `@layer ui-common{…}`. A product imports ui-common's modules before its + entry stylesheet, where the order statement lives, and a layer's position is + fixed by first appearance, so the bundle ranked `ui-common` below `reset` + and Astryx's `astryx-base` and `astryx-theme`: Astryx's rules beat + ui-common's composites (`StatCard`'s `overflow: hidden` lost to `Card`'s + `overflow: clip`). Every stylesheet the package ships (component sheets, + `ui-common.css`, `legacy-tokens.css`, `styles/`, the Lablup `theme.css` and + the Astryx `@import` mirrors) now opens with + `@layer reset, theme, base, astryx-base, astryx-theme, ui-common, components, utilities;`, + defined once in `scripts/layer-order.mjs` and prepended by the build. + Keep declaring it in the app's entry stylesheet too; repeating it is a + no-op. `check:pack` fails on a packed stylesheet without it, and CI's + fixture check fails when the consumer bundle establishes any other order. - **The `react` and `react-dom` peers are `^19.2.0`.** `Modal`, `NotificationStack`, `UnitGrid` and `Form` import `useEffectEvent`, stable since React 19.2; `^19.0.0` let 19.0 and 19.1 install and then fail. @@ -28,6 +84,70 @@ Versioning follows the policy in [CONTRIBUTING.md](CONTRIBUTING.md#versioning). leading spaces. For a library it adds the StyleX (and lab) peer even when a devDependency already names the package. +### Added + +- In development, a warning when a second copy of `@lablup/ui-common` is + loaded, naming each copy and saying whether they also run on separate + copies of `@astryxdesign/core`. Each copy registers under + `globalThis[Symbol.for("@lablup/ui-common/instance")]` on first use of its + string translator or modal stack; production builds drop it. + +### Documentation + +- README: `allowBuilds` for Astryx's postinstall scripts under pnpm 10 and + later (pnpm 11 fails with `ERR_PNPM_IGNORED_BUILDS` without it), the + shipped layer statement, why `<Theme theme={lablupTheme}>` is required and + how dark mode works (`<Theme mode>`; 0.1's `data-theme="orange-dark"` + toggles no longer work), and Vitest's `server.deps.inline` for jsdom tests. +- The Astryx integration's agent docs and doc page name + `@lablup/ui-common/theme/lablup/built`, the theme that pairs with + `theme.css`, instead of the source theme. + +## Upgrading from 0.1 + +The 0.2 alphas below add up to these changes for a 0.1 consumer. Run +`pnpm dlx @lablup/ui-common-cli@next upgrade --from 0.1 --dry-run` (plain +`@lablup/ui-common-cli` once 0.2.0 is published) for the +mechanical part; +[`packages/cli/migration/0.1-to-0.2.json`](packages/cli/migration/0.1-to-0.2.json) lists every import, +prop, class and stylesheet change it reads. + +- **Dependencies.** Astryx (`@astryxdesign/core`, `theme-neutral`) is an + exact-pinned dependency (the CLI moved to `@lablup/ui-common-cli`); import it only through `@lablup/ui-common`. + `@stylexjs/stylex` ^0.19 is a new peer, `@astryxdesign/lab` an optional + exact peer (with an `overrides` entry for its core), and React 19.2 or + later is required. (0.2.0-alpha.0; 19.2 since the release after + 0.2.0-alpha.14) +- **The root barrel is Astryx's**, and `@lablup/ui-common/hooks` is Astryx's + hooks; `usePrefersReducedMotion` moved to the root. (0.2.0-alpha.0, + 0.2.0-alpha.2) +- **Removed components, each replaced by Astryx:** `Badge`, `BaseCard`, + `Button`, `DataTable`, `Drawer` (lab), `EmptyState`, `ProgressBar`, + `Select` (`Selector`), `Skeleton`, `StatusTag` (`StatusDot`), `Tabs` + (`TabList`) and `Tooltip`, with their props renamed to Astryx's + (`children` → `label`, `disabled` → `isDisabled`, …). (0.2.0-alpha.1) +- **Kept components keep their props** but render Astryx, style in + `@layer ui-common` and take `uic-` class names (`page-header` → + `uic-page-header`). CSS, tests and DOM queries on the old names need + updating. (0.2.0-alpha.1) +- **Dialogs:** Astryx `Dialog` and `AlertDialog` are hidden; use `Modal` and + `AlertModal`. An open `Modal` makes the rest of the page `inert`; an + overlay of the app's own that must stay usable over it needs + `data-uic-modal-live`. (0.2.0-alpha.1, 0.2.0-alpha.5, 0.2.0-alpha.7) +- **Stylesheets and theme:** `styles/base.css` becomes the layer statement + plus `reset.css`, `astryx.css`, `theme/lablup/theme.css`, `ui-common.css` + (and `legacy-tokens.css` while `--token-*` names are still read); drop + `styles/themes/*.css`. Wrap the app in `<Theme theme={lablupTheme}>` from + `theme/lablup/built`. `--token-*`, `styles/base.css` and + `styles/themes/*.css` are deprecated and go in 0.3. (0.2.0-alpha.0) +- **Strings** resolve through Astryx's `InternationalizationProvider`; pass + `uiCommonMessages` from `@lablup/ui-common/i18n-catalog`. Shared keys are + `uic.common.*`. (0.2.0-alpha.0, 0.2.0-alpha.3) +- **Custom properties** follow Astryx's naming: the theme's info hue is + `--color-info`, component knobs are `--<component>-<property>` + (DigitPopIn's are their 0.1 names again), and no `--uic-*` name remains. + (0.2.0-alpha.12) + ## [0.2.0-alpha.14] A paged selector moves in from a product, and `Drawer` hands Escape to the @@ -540,7 +660,7 @@ upgrade tool. The component layer moves onto Astryx: the 0.1 look-alikes are gone, the components Astryx has no counterpart for are rebuilt on it with their 0.1 props, and `Modal` takes the place of the hidden `Dialog`. -[`migration/0.1-to-0.2.json`](migration/0.1-to-0.2.json) lists every change +[`migration/0.1-to-0.2.json`](packages/cli/migration/0.1-to-0.2.json) lists every change below in the form `ui-common upgrade` reads. ### Removed @@ -1309,7 +1429,26 @@ mid-migration. validation, and a clean external React install fixture. - Apache-2.0 license and the initial public boundary rules. -[Unreleased]: https://github.com/lablup/ui-common/compare/v0.1.0-alpha.19...HEAD +[Unreleased]: https://github.com/lablup/ui-common/compare/v0.2.0-alpha.14...HEAD +[0.2.0-alpha.14]: https://github.com/lablup/ui-common/compare/v0.2.0-alpha.13...v0.2.0-alpha.14 +[0.2.0-alpha.13]: https://github.com/lablup/ui-common/compare/v0.2.0-alpha.12...v0.2.0-alpha.13 +[0.2.0-alpha.12]: https://github.com/lablup/ui-common/compare/v0.2.0-alpha.11...v0.2.0-alpha.12 +[0.2.0-alpha.11]: https://github.com/lablup/ui-common/compare/v0.2.0-alpha.10...v0.2.0-alpha.11 +[0.2.0-alpha.10]: https://github.com/lablup/ui-common/compare/v0.2.0-alpha.9...v0.2.0-alpha.10 +[0.2.0-alpha.9]: https://github.com/lablup/ui-common/compare/v0.2.0-alpha.8...v0.2.0-alpha.9 +[0.2.0-alpha.8]: https://github.com/lablup/ui-common/compare/v0.2.0-alpha.7...v0.2.0-alpha.8 +[0.2.0-alpha.7]: https://github.com/lablup/ui-common/compare/v0.2.0-alpha.6...v0.2.0-alpha.7 +[0.2.0-alpha.6]: https://github.com/lablup/ui-common/compare/v0.2.0-alpha.5...v0.2.0-alpha.6 +[0.2.0-alpha.5]: https://github.com/lablup/ui-common/compare/v0.2.0-alpha.4...v0.2.0-alpha.5 +[0.2.0-alpha.4]: https://github.com/lablup/ui-common/compare/v0.2.0-alpha.3...v0.2.0-alpha.4 +[0.2.0-alpha.3]: https://github.com/lablup/ui-common/compare/v0.2.0-alpha.2...v0.2.0-alpha.3 +[0.2.0-alpha.2]: https://github.com/lablup/ui-common/compare/v0.2.0-alpha.1...v0.2.0-alpha.2 +[0.2.0-alpha.1]: https://github.com/lablup/ui-common/compare/v0.2.0-alpha.0...v0.2.0-alpha.1 +[0.2.0-alpha.0]: https://github.com/lablup/ui-common/compare/v0.1.0-alpha.23...v0.2.0-alpha.0 +[0.1.0-alpha.23]: https://github.com/lablup/ui-common/compare/v0.1.0-alpha.22...v0.1.0-alpha.23 +[0.1.0-alpha.22]: https://github.com/lablup/ui-common/compare/v0.1.0-alpha.21...v0.1.0-alpha.22 +[0.1.0-alpha.21]: https://github.com/lablup/ui-common/compare/v0.1.0-alpha.20...v0.1.0-alpha.21 +[0.1.0-alpha.20]: https://github.com/lablup/ui-common/compare/v0.1.0-alpha.19...v0.1.0-alpha.20 [0.1.0-alpha.19]: https://github.com/lablup/ui-common/compare/v0.1.0-alpha.18...v0.1.0-alpha.19 [0.1.0-alpha.18]: https://github.com/lablup/ui-common/compare/v0.1.0-alpha.17...v0.1.0-alpha.18 [0.1.0-alpha.17]: https://github.com/lablup/ui-common/compare/v0.1.0-alpha.16...v0.1.0-alpha.17 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 47e7bba..ecaa7bd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -393,20 +393,22 @@ pre-built CSS at runtime. ## Bumping Astryx `@astryxdesign/core`, `@astryxdesign/theme-neutral` and `@astryxdesign/cli` -move together, exact-pinned. `@astryxdesign/lab` is an exact canary pin, as +move together, exact-pinned. The Astryx CLI is pinned twice: as a dependency +of `packages/cli` (`@lablup/ui-common-cli`, which runs it for consumers) and as +a devDependency of the root (its `theme:*` and `check:integration` scripts). `@astryxdesign/lab` is an exact canary pin, as both a devDependency and an optional peer. `ui-common sync-astryx` does the bump: ``` -node bin/ui-common.mjs sync-astryx 0.6.3 --dry-run # plan, and Astryx's codemods on src/ as a dry run -node bin/ui-common.mjs sync-astryx 0.6.3 --lab 0.6.3-canary.abc1234 +node packages/cli/bin/ui-common.mjs sync-astryx 0.6.3 --dry-run # plan, and Astryx's codemods on src/ as a dry run +node packages/cli/bin/ui-common.mjs sync-astryx 0.6.3 --lab 0.6.3-canary.abc1234 ``` -It moves the pins, runs `pnpm install`, `pnpm run gen:exports` and +It moves the pins in both `package.json` files, runs `pnpm install`, `pnpm run gen:exports` and `pnpm run theme:build`, runs Astryx's own codemods on `src/` (a dry run, then applied), runs the tests, and records the Astryx codemods consumers need in -`codemods/<next version>/upstream.json` (`--as <version>` picks the version; +`packages/cli/codemods/<next version>/upstream.json` (`--as <version>` picks the version; release under that version). `ui-common upgrade` runs them for a consumer that crosses it, with the `@lablup/ui-common` specifiers swapped for Astryx's so Astryx's codemods recognise them. It runs Astryx's codemods before the tests, @@ -424,26 +426,26 @@ Then, by hand: ## The upgrade tool -`ui-common upgrade` runs the steps in `codemods/registry.mjs`, keyed by the +`ui-common upgrade` runs the steps in `packages/cli/codemods/registry.mjs`, keyed by the ui-common version that made the change, over a consumer's source. -- **0.1 → 0.2** (`codemods/0.2/`) takes all of its data from - [`migration/0.1-to-0.2.json`](migration/0.1-to-0.2.json): replacement +- **0.1 → 0.2** (`packages/cli/codemods/0.2/`) takes all of its data from + [`packages/cli/migration/0.1-to-0.2.json`](packages/cli/migration/0.1-to-0.2.json): replacement imports, prop renames, value maps, required packages, stylesheet entry points, class renames and the manual notes its TODO markers quote. Change the map, not the codemods, when the migration changes. - `codemods/0.2/legacy-classes.json` lists the 0.1 class names; regenerate it - from a 0.1 checkout with `scripts/extract-legacy-classes.mjs`. -- **Upstream steps** are `codemods/<version>/upstream.json`, written by + `packages/cli/codemods/0.2/legacy-classes.json` lists the 0.1 class names; regenerate it + from a 0.1 checkout with `packages/cli/scripts/extract-legacy-classes.mjs`. +- **Upstream steps** are `packages/cli/codemods/<version>/upstream.json`, written by `sync-astryx`. A codemod that cannot prove a rewrite safe leaves the code as it was, with a -`TODO(ui-common-upgrade):` comment and a report entry. `test/upgrade/` runs +`TODO(ui-common-upgrade):` comment and a report entry. `packages/cli/test/upgrade/` runs every step over fixture projects and compares the result with `expected/`, report included. After an intended change: ``` -UPDATE_FIXTURES=1 pnpm vitest run test/upgrade +UPDATE_FIXTURES=1 pnpm vitest run packages/cli/test/upgrade ``` and read the diff. Fixtures are consumer code: keep them free of product diff --git a/README.md b/README.md index b37d51c..576f7da 100644 --- a/README.md +++ b/README.md @@ -33,14 +33,33 @@ Peer dependencies: `@lablup/ui-common/lab`. It is pinned to the exact canary ui-common is built against, and it needs the override below. -Astryx itself (`@astryxdesign/core`, `@astryxdesign/theme-neutral`, -`@astryxdesign/cli`) comes in as ui-common's own dependencies, pinned exactly. +Astryx itself (`@astryxdesign/core`, `@astryxdesign/theme-neutral`) comes in +as ui-common's own dependencies, pinned exactly. The `ui-common` bin and the +Astryx CLI it wraps are a separate dev-time package, `@lablup/ui-common-cli` +([The ui-common CLI](#the-ui-common-cli)). `lucide-react` (the icon set Astryx's neutral theme already depends on) and `intl-messageformat` come in the same way. Do not add them to your project. ui-common owns the Astryx version. Two copies of Astryx means two copies of its React contexts, and components stop seeing the theme. +### pnpm 10 and later + +`@astryxdesign/core` and `@astryxdesign/cli` have `postinstall` scripts. pnpm +10 and later run no dependency's install scripts until the project decides +about each one: pnpm 10 installs and prints a warning, pnpm 11 fails +`pnpm install` with `ERR_PNPM_IGNORED_BUILDS`. The scripts only print an +`astryx init` hint, so decline them, in `pnpm-workspace.yaml`: + +```yaml +allowBuilds: + "@astryxdesign/cli": false + "@astryxdesign/core": false +``` + +This repository's own `pnpm-workspace.yaml` does the same. npm runs the +scripts, or asks about them, and needs nothing. + ### With `@lablup/ui-common/lab` The lab canary declares an exact peer on the core canary it was cut from, not @@ -103,8 +122,8 @@ Locally, use a personal access token with `read:packages`, in your user ## Set up -Declare the layer order once, first, in your app's entry stylesheet. Then load -the stylesheets: +Declare the layer order first in your app's entry stylesheet, then load the +stylesheets in this order: ```css @layer reset, theme, base, astryx-base, astryx-theme, ui-common, components, utilities; @@ -117,6 +136,16 @@ the stylesheets: @import "@lablup/ui-common/lab/lab.css"; ``` +Every stylesheet ui-common ships starts with the same `@layer` statement too, +component sheets included. A layer's place is fixed by the first stylesheet +that names it, and a component's sheet (imported by its module) usually +reaches the page before your entry stylesheet does. Without the statement in +the component sheets, `ui-common` would be the lowest layer and Astryx's base +styles would beat ui-common's. Declaring it in your entry stylesheet as well +is still recommended: it documents the order, and it places your own +`components` and `utilities` layers wherever your sheets load. Repeating an +identical statement changes nothing. + Wrap the app in the theme: ```tsx @@ -128,6 +157,17 @@ import { lablupTheme } from "@lablup/ui-common/theme/lablup/built"; </Theme>; ``` +`<Theme theme={lablupTheme}>` is required. `theme.css` is scoped to +`[data-astryx-theme="lablup"]`, which only `<Theme>` sets, so without it the +app renders Astryx's default palette. No error is raised. + +Dark mode is the `mode` prop: `<Theme theme={lablupTheme} mode="dark">`, +`"light"`, or `"system"` (the default, which follows the OS). The root +`<Theme>` owns `html[data-theme]`: it sets `light` or `dark`, removes the +attribute for `system`, and removes it on unmount. A 0.1-style toggle that +writes its own value there, such as `data-theme="orange-dark"`, no longer +works. Switch `mode` instead. + `/theme/lablup/built` pairs with `theme.css` and injects nothing at runtime. `@lablup/ui-common/theme/lablup` is the same theme as source, for runtime injection or for extending it with `defineTheme`. Use one or the other. @@ -136,6 +176,31 @@ Astryx's neutral theme is mirrored the same way at `/theme/neutral`. The theme names its font family (Ubuntu Sans, then Pretendard Variable) but does not load it. Loading fonts is the app's job. +### Tests (Vitest with jsdom) + +ui-common's modules import their stylesheets, and Node cannot load a `.css` +import from `node_modules`. Vitest externalises dependencies by default, so a +test that imports ui-common fails with `Unknown file extension ".css"`. +Let Vitest process the package instead: + +```ts +// vitest.config.ts +export default defineConfig({ + test: { + environment: "jsdom", + server: { deps: { inline: [/@lablup\/ui-common/] } }, + }, +}); +``` + +### Two copies + +In development, ui-common warns in the console when a second copy of itself +is loaded, and says whether the copies also run on separate copies of +`@astryxdesign/core`. Two copies do not share the modal stack, and with two +Astryx cores the `Theme` and i18n providers stop reaching components. Dedupe +until `pnpm why @lablup/ui-common` lists one version. + ### Layers | Layer | Owner | @@ -375,26 +440,46 @@ Removed in 0.2, each replaced by Astryx: The kept components keep their 0.1 props. Their class names moved to `uic-` (`page-header` is `uic-page-header`), so CSS or tests that select the old -names need updating. [`migration/0.1-to-0.2.json`](migration/0.1-to-0.2.json) +names need updating. [`packages/cli/migration/0.1-to-0.2.json`](packages/cli/migration/0.1-to-0.2.json) lists every import, prop, class and stylesheet change in a form the upgrade tool reads. Before you start, read [docs/migrating-to-0.2.md](docs/migrating-to-0.2.md): the problems the first app hit when it moved onto 0.2, and a checklist. -Let the upgrade tool do the mechanical part. After bumping the dependency: +Let the upgrade tool do the mechanical part. It ships in +`@lablup/ui-common-cli`, so run it one-off from the project still on 0.1: ``` -pnpm exec ui-common upgrade --from 0.1 --dry-run # writes nothing; prints the changes and the report -pnpm exec ui-common upgrade --from 0.1 # applies it +pnpm dlx @lablup/ui-common-cli@next upgrade --from 0.1 --dry-run # writes nothing; prints the changes and the report +pnpm dlx @lablup/ui-common-cli@next upgrade --from 0.1 # applies it ``` +(`npx @lablup/ui-common-cli@next upgrade --from 0.1` with npm.) Keep the +`@next` while 0.2 is in prerelease: the CLI has published only prereleases, +which go to the `next` dist-tag, and npm points `latest` at a package's first +publish, so a bare `@lablup/ui-common-cli` resolves to its first alpha. Drop +`@next` once 0.2.0 is published. It bumps +`@lablup/ui-common` in `package.json` and adds `@lablup/ui-common-cli` as a +devDependency at the same version; then run your install, and later upgrades +are `pnpm exec ui-common upgrade --from <old version>`. + It moves the imports, reshapes the props it can prove safe, rewrites the -`styles/base.css` import into the 0.2 stylesheet set, and updates -`package.json`. Everything else is a `TODO(ui-common-upgrade)` comment in the +`styles/base.css` import into the 0.2 stylesheet set (or, in an app that +never imported it, imports that set first in the app's entry script), wraps +the app's root render (`createRoot(…).render(<App />)`) in +`<Theme theme={lablupTheme}>` when no module uses `<Theme>` yet, and updates +`package.json`. Code that imports a moved component through a module of your +own that re-exports it (a barrel such as `@/components/common`, found through +relative paths and your tsconfig `paths`) gets the same rewrite. A component of +yours that wraps one and takes its props is listed in the report instead: its +props are yours to change. Everything else is a `TODO(ui-common-upgrade)` comment in the code and a line in `ui-common-upgrade-report.md`, together with the CSS, DOM -queries, tests and module mocks that still name 0.1 classes, and custom -properties of yours that Astryx declares too. +queries, tests and module mocks that still name 0.1 classes, custom +properties of yours that Astryx declares too, and code that switches 0.1 +themes through `data-theme`. Steps the app cannot work without (the +stylesheets or `<Theme>`, where the upgrade could not add them) open the +report under "Action required". Deprecated in 0.2, removed in 0.3: @@ -417,8 +502,30 @@ Deprecated in 0.2, removed in 0.3: ## The ui-common CLI -ui-common ships a `ui-common` bin. It wraps the Astryx CLI that ui-common pins, -so a project needs no `@astryxdesign/*` dependency of its own to use it. +The `ui-common` bin is its own package, `@lablup/ui-common-cli`, released at +the same version as `@lablup/ui-common` and taking it as a peer. It wraps the +Astryx CLI it pins, so a project needs no `@astryxdesign/*` dependency of its +own to use it. Being separate keeps the Astryx CLI and the codemod toolchain +(jscodeshift, postcss) out of a production install, the way Astryx splits +`@astryxdesign/cli` from `@astryxdesign/core`. Keep it a devDependency pinned +to the same version as `@lablup/ui-common`, and bump the two together: + +``` +pnpm add -D @lablup/ui-common-cli@<the @lablup/ui-common version> +``` + +Under pnpm 11, allow or decline the Astryx packages' postinstall (it only +prints an `astryx init` nudge) in `pnpm-workspace.yaml`, or the install stops +with `ERR_PNPM_IGNORED_BUILDS` (`ui-common upgrade` adds the entries a pnpm +project does not decide yet): + +```yaml +allowBuilds: + "@astryxdesign/core": false + "@astryxdesign/cli": false +``` + +Then: ``` pnpm exec ui-common component Button # any Astryx command: component, search, @@ -427,19 +534,22 @@ pnpm exec ui-common agents --write AGENTS.md pnpm exec ui-common upgrade --from 0.1 --dry-run ``` -| Command | What it does | -| ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `ui-common <astryx command> …` | Runs the pinned Astryx CLI and rewrites its output to ui-common: `@astryxdesign/core/<X>` is `@lablup/ui-common/<X>`, `@astryxdesign/lab` is `@lablup/ui-common/lab`, `@astryxdesign/theme-neutral` is `@lablup/ui-common/theme/neutral`, and commands read `ui-common …`. A name ui-common hides gets a note ("Use Modal, not Dialog"). `--json` output stays valid JSON; the note goes to stderr. The exit code is Astryx's. | -| `ui-common astryx …` | The same, without rewriting. | -| `ui-common agents [--write <file>] [--check]` | Prints the agent block: Astryx's `init --features agents` block, rewritten, plus ui-common's rules. It sits between `<!-- UI-COMMON:START -->` and `<!-- UI-COMMON:END -->`, which `astryx init` never touches. `--write` replaces the block in place and keeps the rest of the file; `--check` exits 1 when it is stale. | -| `ui-common upgrade [--from <v>] [--to <v>] [--dry-run] [--diff] [--report <path>] [paths…]` | Runs the codemods between two ui-common versions over `src/` (or `paths`), updates `package.json`, and writes `ui-common-upgrade-report.md` (a `--dry-run` writes nothing and prints the report, unless `--report` names a file). `--from` defaults to the version `package.json` declares, `--to` to the installed one. | -| `ui-common sync-astryx <version> [--lab <v>] [--as <v>] [--dry-run]` | Maintainers only; see [CONTRIBUTING.md](CONTRIBUTING.md#bumping-astryx). | +| Command | What it does | +| ------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ui-common <astryx command> …` | Runs the pinned Astryx CLI and rewrites its output to ui-common: `@astryxdesign/core/<X>` is `@lablup/ui-common/<X>`, `@astryxdesign/lab` is `@lablup/ui-common/lab`, `@astryxdesign/theme-neutral` is `@lablup/ui-common/theme/neutral`, and commands read `ui-common …`. A name ui-common hides gets a note ("Use Modal, not Dialog"). `--json` output stays valid JSON; the note goes to stderr. The exit code is Astryx's. | +| `ui-common astryx …` | The same, without rewriting. | +| `ui-common agents [--write <file>] [--check]` | Prints the agent block: Astryx's `init --features agents` block, rewritten, plus ui-common's rules. It sits between `<!-- UI-COMMON:START -->` and `<!-- UI-COMMON:END -->`, which `astryx init` never touches. `--write` replaces the block in place and keeps the rest of the file; `--check` exits 1 when it is stale. | +| `ui-common upgrade [--from <v>] [--to <v>] [--dry-run] [--diff] [--report <path>] [--scan <path>]… [paths…]` | Runs the codemods between two ui-common versions over `src/` (or `paths`), updates `package.json`, and writes `ui-common-upgrade-report.md` (a `--dry-run` writes nothing and prints the report, unless `--report` names a file). The report's manual-review findings come from the whole project (tests, e2e specs, scripts), or only from the `--scan` paths. `--from` defaults to the version `package.json` declares, `--to` to the CLI's own (the ui-common version it ships with). | +| `ui-common sync-astryx <version> [--lab <v>] [--as <v>] [--dry-run]` | Maintainers only; see [CONTRIBUTING.md](CONTRIBUTING.md#bumping-astryx). | Exit codes: a passed-through command exits with Astryx's code. ui-common's own commands exit 0 on success, 1 on a failed check or run, and 2 on bad arguments. `component`, `search` and the other lookups find `@astryxdesign/core` through -ui-common, so they work in a project that depends on ui-common alone. +the project's `@lablup/ui-common`, so they work in a project that depends on +ui-common (and the CLI) alone. Without the CLI installed, any command runs +one-off as `pnpm dlx @lablup/ui-common-cli@next <command>` (or `npx`; plain +`@lablup/ui-common-cli` once 0.2.0 is published). ui-common is also an Astryx CLI integration: `ui-common docs ui-common` (or `astryx docs ui-common`) explains the layer, and `ui-common component Modal` diff --git a/astryx.integration.mjs b/astryx.integration.mjs index 4647bdc..7fa7ceb 100644 --- a/astryx.integration.mjs +++ b/astryx.integration.mjs @@ -26,7 +26,7 @@ export default { append: [ "Import Astryx through @lablup/ui-common, never @astryxdesign/*: same subpaths (@lablup/ui-common/Button, /theme/tokens.stylex, /reset.css, /lab).", "Use Modal, not Dialog. ui-common hides Astryx Dialog (exports.exclude.json) so every product has one dialog surface.", - "Theme with <Theme theme={lablupTheme}> from @lablup/ui-common/theme/lablup. Declare @layer reset, theme, base, astryx-base, astryx-theme, ui-common, components, utilities; first.", + "Theme with <Theme theme={lablupTheme}> from @lablup/ui-common/theme/lablup/built (pairs with theme.css). Declare @layer reset, theme, base, astryx-base, astryx-theme, ui-common, components, utilities; first.", "ui-common strings resolve through Astryx InternationalizationProvider: pass uiCommonMessages from @lablup/ui-common/i18n-catalog in its messages.", "ComplexSelector, and lab's Drawer and Tour, come from ui-common: fixed copies of Astryx's, same API and import paths. ComplexSelector adds hasClear/onClear.", ], diff --git a/astryx/docs/ui-common.doc.mjs b/astryx/docs/ui-common.doc.mjs index bacc088..be64f9b 100644 --- a/astryx/docs/ui-common.doc.mjs +++ b/astryx/docs/ui-common.doc.mjs @@ -24,7 +24,7 @@ export const docs = { items: [ "Import Astryx components from @lablup/ui-common, at the root or at the same subpath Astryx uses.", "Import StyleX tokens from `@lablup/ui-common/theme/tokens.stylex`. The `.stylex` suffix is what the StyleX compiler looks for.", - "Wrap the app in `<Theme theme={lablupTheme}>` from `@lablup/ui-common/theme/lablup`.", + "Wrap the app in `<Theme theme={lablupTheme}>` from `@lablup/ui-common/theme/lablup/built`, which pairs with `theme/lablup/theme.css`.", ], }, { diff --git a/cli/paths.mjs b/cli/paths.mjs deleted file mode 100644 index 1554fb2..0000000 --- a/cli/paths.mjs +++ /dev/null @@ -1,180 +0,0 @@ -/** - * Where things live, resolved from this package rather than from the working - * directory. A consumer depends on @lablup/ui-common only, so under pnpm's - * isolated layout @astryxdesign/* is reachable from here and not from the - * consumer's own directory. - */ -import { existsSync, readFileSync } from "node:fs"; -import { createRequire } from "node:module"; -import { dirname, join, resolve } from "node:path"; -import { fileURLToPath, pathToFileURL } from "node:url"; - -/** Root of the installed @lablup/ui-common package. */ -export const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); - -const require = createRequire(join(PACKAGE_ROOT, "package.json")); - -/** @param {string} file */ -export function readJson(file) { - return JSON.parse(readFileSync(file, "utf8")); -} - -/** @returns {{name: string, version: string, dependencies?: Record<string,string>, peerDependencies?: Record<string,string>, devDependencies?: Record<string,string>}} */ -export function ownPackageJson() { - return readJson(join(PACKAGE_ROOT, "package.json")); -} - -/** Walk up from a resolved file to the directory holding its package.json. */ -function packageDirOf(file, name) { - let dir = dirname(file); - for (;;) { - const pkg = join(dir, "package.json"); - if (existsSync(pkg)) { - try { - if (readJson(pkg).name === name) return dir; - } catch { - // keep walking - } - } - const parent = dirname(dir); - if (parent === dir) return null; - dir = parent; - } -} - -/** - * The directory of a dependency of ui-common, or null. Resolution goes through - * the package's main entry, because not every Astryx package exports its - * package.json. - * - * @param {string} name - */ -export function dependencyDir(name) { - try { - return packageDirOf(require.resolve(name), name); - } catch { - return null; - } -} - -/** Root of the exact-pinned @astryxdesign/cli. */ -export function astryxCliRoot() { - const dir = dependencyDir("@astryxdesign/cli"); - if (!dir) { - throw new Error( - "@astryxdesign/cli is not installed next to @lablup/ui-common. Reinstall @lablup/ui-common.", - ); - } - return dir; -} - -/** The `astryx` bin script of the pinned CLI. */ -export function astryxBin() { - const root = astryxCliRoot(); - const bin = readJson(join(root, "package.json")).bin?.astryx; - return join(root, bin ?? "clients/cli/bin/astryx.mjs"); -} - -/** - * Import a module file from inside the pinned Astryx CLI. These are internals, - * not the CLI's public API; the pin is exact and `sync-astryx` re-runs the - * tests that use them on every bump. - * - * @param {string} relative path under the CLI root - */ -export async function importAstryxInternal(relative) { - const file = join(astryxCliRoot(), relative); - return import(pathToFileURL(file).href); -} - -/** Version of an installed dependency of ui-common, or null. */ -export function dependencyVersion(name) { - const dir = dependencyDir(name); - return dir ? readJson(join(dir, "package.json")).version : null; -} - -/** `exports.exclude.json`: Astryx subpaths ui-common hides, with replacements. */ -export function excludedExports() { - const file = join(PACKAGE_ROOT, "exports.exclude.json"); - if (!existsSync(file)) return []; - return /** @type {Array<{name: string, exports?: string[], replacedBy: string|null, reason: string}>} */ ( - readJson(file) - ); -} - -/** `exports.customs.json`: ui-common's own exports. */ -export function customExports() { - const file = join(PACKAGE_ROOT, "exports.customs.json"); - if (!existsSync(file)) return []; - return /** @type {Array<{name: string, source: string, subpath?: string, fork?: string, legacy?: object}>} */ ( - readJson(file) - ); -} - -/** - * ui-common's own copies of Astryx components (src/forks/): same names and - * import paths as Astryx's, which exports.exclude.json hides. `names` are the - * names a reader may meet it by; `from` is where a consumer imports it. - * - * @returns {Array<{name: string, from: string, names: string[], reason: string}>} - */ -export function forkedExports() { - const exclusions = excludedExports(); - return customExports() - .filter((c) => c.fork !== undefined) - .map((c) => { - const exclusion = exclusions.find((e) => e.replacedBy === c.name); - return { - name: c.name, - from: `@lablup/ui-common/${c.subpath ?? exclusion?.name ?? ""}`, - names: exclusion?.exports ?? [c.name], - reason: exclusion?.reason ?? "", - }; - }); -} - -/** - * The exclusions that hide an Astryx name behind a different one. A fork's - * exclusion is not one: the name stays, now ui-common's. - */ -export function hiddenExports() { - const forks = new Set(forkedExports().map((f) => f.name)); - return excludedExports().filter( - (e) => e.replacedBy === null || !forks.has(e.replacedBy), - ); -} - -/** - * The nearest directory at or above `start` holding a package.json. - * - * @param {string} start - */ -export function findProjectDir(start) { - let dir = resolve(start); - for (;;) { - if (existsSync(join(dir, "package.json"))) return dir; - const parent = dirname(dir); - if (parent === dir) return null; - dir = parent; - } -} - -/** - * How this project runs a locally installed bin, from its lockfile. - * - * @param {string} projectDir - */ -export function binInvocation(projectDir, bin = "ui-common") { - let dir = resolve(projectDir); - for (;;) { - if (existsSync(join(dir, "pnpm-lock.yaml"))) return `pnpm exec ${bin}`; - if (existsSync(join(dir, "yarn.lock"))) return `yarn ${bin}`; - if (existsSync(join(dir, "bun.lock")) || existsSync(join(dir, "bun.lockb"))) { - return `bunx ${bin}`; - } - if (existsSync(join(dir, "package-lock.json"))) return `npx ${bin}`; - const parent = dirname(dir); - if (parent === dir) return `npx ${bin}`; - dir = parent; - } -} diff --git a/cli/semver.mjs b/cli/semver.mjs deleted file mode 100644 index 1add0bd..0000000 --- a/cli/semver.mjs +++ /dev/null @@ -1,103 +0,0 @@ -/** - * The little semver the upgrade registry needs: parse, compare with - * prerelease precedence, and coerce the loose forms a person types - * (`0.1`, `v0.1.0-alpha.19`) or a package.json holds (`^0.1.0-alpha.7`). - */ - -const SEMVER = - /^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/; - -/** - * @param {string} input - * @returns {{major: number, minor: number, patch: number, pre: string[]} | null} - */ -export function parse(input) { - const match = SEMVER.exec(String(input).trim()); - if (!match) return null; - return { - major: Number(match[1]), - minor: Number(match[2] ?? 0), - patch: Number(match[3] ?? 0), - pre: match[4] ? match[4].split(".") : [], - }; -} - -/** @param {{major: number, minor: number, patch: number, pre: string[]}} v */ -export function format(v) { - return `${v.major}.${v.minor}.${v.patch}${v.pre.length ? `-${v.pre.join(".")}` : ""}`; -} - -/** - * The first version a dependency spec admits, for `^0.1.0-alpha.7`, - * `~0.1.0`, `0.1.0-alpha.19`, `>=0.1.0-alpha.0 <0.2.0`. Null for - * `workspace:*`, tags and URLs. - * - * @param {string} spec - */ -export function coerce(spec) { - const match = /(?:^|[\s^~>=<v])(\d+(?:\.\d+){0,2}(?:-[0-9A-Za-z.-]+)?)/.exec( - ` ${String(spec).trim()}`, - ); - if (!match) return null; - const parsed = parse(match[1]); - return parsed ? format(parsed) : null; -} - -/** @param {string} a @param {string} b */ -function compareIdentifiers(a, b) { - const na = /^\d+$/.test(a); - const nb = /^\d+$/.test(b); - if (na && nb) return Number(a) - Number(b); - if (na) return -1; - if (nb) return 1; - return a < b ? -1 : a > b ? 1 : 0; -} - -/** - * Semver precedence. A version with a prerelease sorts before the same - * version without one. - * - * @param {string} a - * @param {string} b - */ -export function compare(a, b) { - const va = parse(a); - const vb = parse(b); - if (!va || !vb) throw new Error(`Not a version: ${!va ? a : b}`); - for (const key of /** @type {const} */ (["major", "minor", "patch"])) { - if (va[key] !== vb[key]) return va[key] - vb[key]; - } - if (va.pre.length === 0 || vb.pre.length === 0) { - return vb.pre.length - va.pre.length; - } - for (let i = 0; i < Math.max(va.pre.length, vb.pre.length); i++) { - const x = va.pre[i]; - const y = vb.pre[i]; - if (x === undefined) return -1; - if (y === undefined) return 1; - const c = compareIdentifiers(x, y); - if (c !== 0) return c; - } - return 0; -} - -/** - * The version after `version` for a release that carries a breaking change: - * the next prerelease number while in prerelease, else the next minor (pre-1.0 - * a breaking change bumps the minor). - * - * @param {string} version - */ -export function nextBreaking(version) { - const v = parse(version); - if (!v) throw new Error(`Not a version: ${version}`); - if (v.pre.length > 0) { - const last = v.pre[v.pre.length - 1]; - const pre = /^\d+$/.test(last) - ? [...v.pre.slice(0, -1), String(Number(last) + 1)] - : [...v.pre, "1"]; - return format({ ...v, pre }); - } - if (v.major === 0) return format({ major: 0, minor: v.minor + 1, patch: 0, pre: [] }); - return format({ major: v.major + 1, minor: 0, patch: 0, pre: [] }); -} diff --git a/codemods/0.2/package-json.mjs b/codemods/0.2/package-json.mjs deleted file mode 100644 index 9571c18..0000000 --- a/codemods/0.2/package-json.mjs +++ /dev/null @@ -1,163 +0,0 @@ -/** - * 0.1 -> 0.2 package.json edits: - * - bump @lablup/ui-common to the target version (keeping `^`/`~`); - * - add the @stylexjs/stylex peer ui-common 0.2 needs, when missing; - * - add @astryxdesign/lab, pinned to the canary ui-common is built against, - * when a Drawer import was moved to `@lablup/ui-common/lab`, and point its - * core peer at ui-common's core with the project's package manager's - * override (../../cli/lab-peer.mjs). - */ -import { applyLabOverride, CORE, detectPackageManager } from "../../cli/lab-peer.mjs"; -import { ownPackageJson } from "../../cli/paths.mjs"; -import { LAB_PACKAGE, stylexPeer, UIC } from "./map.mjs"; - -const FIELDS = /** @type {const} */ ([ - "dependencies", - "devDependencies", - "peerDependencies", -]); - -/** @param {Record<string, string>} object */ -function isSorted(object) { - const keys = Object.keys(object); - return keys.every((k, i) => i === 0 || keys[i - 1].localeCompare(k) <= 0); -} - -/** - * @param {any} pkg - * @param {string} field - * @param {string} name - * @param {string} range - */ -function addDependency(pkg, field, name, range) { - const current = pkg[field] ?? {}; - const next = { ...current, [name]: range }; - pkg[field] = isSorted(current) - ? Object.fromEntries(Object.entries(next).sort(([a], [b]) => a.localeCompare(b))) - : next; -} - -/** - * @param {string} spec - * @param {string} to - * @returns {{value: string, note?: string} | null} - */ -function bumpSpec(spec, to) { - if (/^(workspace:|link:|file:|npm:|git|https?:)/.test(spec)) return null; - const simple = /^([\^~]?)v?\d+(\.\d+){0,2}(-[0-9A-Za-z.-]+)?$/.exec(spec.trim()); - if (simple) return { value: `${simple[1]}${to}` }; - return { - value: `^${to}`, - note: `the range "${spec}" was replaced with "^${to}"; widen it again if this package must still accept 0.1.`, - }; -} - -/** - * Point the lab canary's core peer at ui-common's core: `overrides` in - * package.json for npm (the project's own, when it is the install root), - * `overrides` in pnpm-workspace.yaml for pnpm, a note otherwise. - * - * @param {any} pkg parsed package.json, edited in place - * @param {{projectDir?: string, note: (message: string) => void, editFile?: (path: string, edit: (current: string | null) => string | undefined) => void}} ctx - */ -function addLabOverride(pkg, ctx) { - const pin = ownPackageJson().dependencies?.[CORE]; - if (!pin || !ctx.projectDir || !ctx.editFile) return; - const { manager, root, workspaceYaml } = detectPackageManager(ctx.projectDir, pkg); - if (manager === "npm" && root !== ctx.projectDir) { - // npm reads overrides from the install root's package.json only. - const { note } = applyLabOverride({ manager: null, pin }); - ctx.note(`npm installs from ${root}, not this package: ${note}`); - return; - } - if (manager === "pnpm" && workspaceYaml) { - const yamlFile = workspaceYaml; - ctx.editFile(yamlFile, (current) => { - const edit = applyLabOverride({ manager, pin, workspaceYaml: current }); - if (edit.note) ctx.note(edit.note); - return edit.workspaceYaml; - }); - return; - } - const { note } = applyLabOverride({ manager, pin, pkg }); - ctx.note(note); -} - -/** - * @param {string} text package.json source - * @param {{to: string, flags: {packages: Map<string, string>}, note: (message: string) => void}} ctx - */ -export function transformPackageJson(text, ctx) { - const pkg = JSON.parse(text); - const indent = /^([ \t]+)"/m.exec(text)?.[1] ?? " "; - const fields = FIELDS.filter((f) => pkg[f]?.[UIC] != null); - if (fields.length === 0) return undefined; - - for (const field of fields) { - const spec = pkg[field][UIC]; - const bumped = bumpSpec(spec, ctx.to); - if (!bumped) { - ctx.note(`${field}["${UIC}"] is "${spec}"; not a version, so it was left alone.`); - continue; - } - if (bumped.value !== spec) { - pkg[field][UIC] = bumped.value; - ctx.note(`${field}["${UIC}"]: "${spec}" → "${bumped.value}".`); - } - if (bumped.note) ctx.note(`${field}["${UIC}"]: ${bumped.note}`); - } - - const has = (/** @type {string} */ name) => - FIELDS.some((f) => pkg[f]?.[name] != null); - // A library that takes ui-common as a peer takes StyleX as a peer too; - // an application depends on it. - const library = fields.includes("peerDependencies"); - - /** - * Add `name` where this project needs it, and return the fields it went - * into. A library needs it as a peer and, when it develops against - * ui-common, as a devDependency: each is checked on its own, since one does - * not stand in for the other. An application needs it anywhere. - * - * @param {string} name - * @param {string} range - */ - const ensure = (name, range) => { - /** @type {string[]} */ - const wanted = library - ? [ - ...(pkg.peerDependencies?.[name] == null && pkg.dependencies?.[name] == null - ? ["peerDependencies"] - : []), - ...(fields.includes("devDependencies") && pkg.devDependencies?.[name] == null - ? ["devDependencies"] - : []), - ] - : has(name) - ? [] - : [fields.includes("dependencies") ? "dependencies" : fields[0]]; - for (const field of wanted) addDependency(pkg, field, name, range); - return wanted; - }; - - const stylex = stylexPeer(); - const stylexAdded = ensure(stylex.name, stylex.range); - if (stylexAdded.length > 0) - ctx.note(`added ${stylex.name} ${stylex.range} to ${stylexAdded.join(" and ")}.`); - - // Packages the map says a moved component needs (the lab Drawer). - for (const [lab, range] of ctx.flags.packages) { - const added = ensure(lab, range); - if (added.length === 0) continue; - const field = added.join(" and "); - ctx.note( - lab === LAB_PACKAGE - ? `added ${lab} ${range} to ${field}: a Drawer moved to ${UIC}/lab, and ui-common pins the lab canary exactly.` - : `added ${lab} ${range} to ${field}.`, - ); - if (lab === LAB_PACKAGE) addLabOverride(pkg, ctx); - } - - const out = `${JSON.stringify(pkg, null, indent)}${text.endsWith("\n") ? "\n" : ""}`; - return out === text ? undefined : out; -} diff --git a/docs/astryx.md b/docs/astryx.md index 8d5cf8e..de1d1ba 100644 --- a/docs/astryx.md +++ b/docs/astryx.md @@ -155,9 +155,12 @@ ui-common's own bin. ## The ui-common CLI -`bin/ui-common.mjs` wraps the pinned `@astryxdesign/cli`. It resolves that CLI -from ui-common's own install location, so a consumer needs no Astryx -dependency. +`@lablup/ui-common-cli` (`packages/cli`, released at ui-common's version and +taking it as a peer) wraps the pinned `@astryxdesign/cli`. It resolves that CLI +from its own install location, and the Astryx packages ui-common pins from the +project's `@lablup/ui-common`, so a consumer needs no Astryx dependency. Kept +apart from the library, the CLI toolchain stays out of production installs, as +`@astryxdesign/cli` stays apart from `@astryxdesign/core`. - **Passthrough.** Any Astryx command runs the pinned bin; its output is rewritten from `@astryxdesign/*` to `@lablup/ui-common/*` and from `astryx …` @@ -177,20 +180,20 @@ dependency. `agents` and the upstream codemods use Astryx CLI internals (`foundation/agent-docs`, `assets/codemods/registry.mjs`) that are not its -public API. The pin is exact, and `test/cli/` exercises both, so a bump that +public API. The pin is exact, and `packages/cli/test/cli/` exercises both, so a bump that moves them fails the tests. ## Checks -| Command | What it guards | -| ---------------------------------------- | -------------------------------------------------------------------------------------------------------------- | -| `src/exports.test.ts` | generated surface matches the installed Astryx | -| `pnpm run theme:check` | built Lablup theme matches its source | -| `src/globalStyles.test.ts` | legacy tokens cover all 122 names; layers; real tokens | -| `src/components/componentStyles.test.ts` | every component sheet: one `@layer ui-common`, `uic-` classes, Astryx tokens, no colour literal, no focus rule | -| `src/migrationMap.test.ts` | `migration/0.1-to-0.2.json` matches what was removed, what replaces it, and the renamed classes | -| `pnpm run check:pack` | every export target is packed; every bare import is a dependency or peer; every Astryx locale is mirrored | -| `pnpm run check:integration` | the CLI accepts the manifest, and the tarball carries it | -| `test/upgrade/` | the upgrade codemods turn each fixture project into its expected output, and a second run changes nothing | -| `test/cli/` | output rewriting, the agent block, the registry, upstream codemods, and `sync-astryx`'s guards | -| CI `external-install` | the tarball installs, type-checks and builds in a clean project, with Astryx's sheets in the bundle | +| Command | What it guards | +| ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `src/exports.test.ts` | generated surface matches the installed Astryx | +| `pnpm run theme:check` | built Lablup theme matches its source | +| `src/globalStyles.test.ts` | legacy tokens cover all 122 names; layers; real tokens | +| `src/components/componentStyles.test.ts` | every component sheet: one `@layer ui-common`, `uic-` classes, Astryx tokens, no colour literal, no focus rule | +| `src/migrationMap.test.ts` | `packages/cli/migration/0.1-to-0.2.json` matches what was removed, what replaces it, and the renamed classes | +| `pnpm run check:pack` | both tarballs: every export target is packed; every bare import is a dependency or peer; every Astryx locale is mirrored; the library carries none of the CLI, the CLI none of `dist/`, at one version | +| `pnpm run check:integration` | the CLI accepts the manifest, and the tarball carries it | +| `packages/cli/test/upgrade/` | the upgrade codemods turn each fixture project into its expected output, and a second run changes nothing | +| `packages/cli/test/cli/` | output rewriting, the agent block, the registry, upstream codemods, and `sync-astryx`'s guards | +| CI `external-install` | both tarballs install with pnpm in a clean project, which type-checks and builds with Astryx's sheets in the bundle and runs the `ui-common` bin | diff --git a/eslint.config.js b/eslint.config.js index 20281ea..d32c0a5 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -13,7 +13,7 @@ export default tseslint.config( // `astryx theme build` output, committed as generated. "src/theme/*/built/**", // Consumer code the upgrade codemods run on, and their expected output. - "test/upgrade/fixtures/**", + "packages/cli/test/upgrade/fixtures/**", // Agent worktrees: other checkouts of this repository, with their own dist. ".claude/**", ], @@ -77,9 +77,7 @@ export default tseslint.config( { files: [ "scripts/**/*.mjs", - "bin/**/*.mjs", - "cli/**/*.mjs", - "codemods/**/*.mjs", + "packages/cli/{bin,cli,codemods,scripts}/**/*.mjs", "*.config.{js,ts}", ], languageOptions: { globals: globals.node }, diff --git a/fixture/pnpm-workspace.yaml b/fixture/pnpm-workspace.yaml new file mode 100644 index 0000000..1f8eed6 --- /dev/null +++ b/fixture/pnpm-workspace.yaml @@ -0,0 +1,12 @@ +# The fixture is a consumer, so it is its own pnpm workspace rather than a +# package of this repository's, and installs under a consumer's settings. +# +# This is the block a pnpm 11 consumer of @lablup/ui-common needs: Astryx's +# packages have a postinstall that only prints an `astryx init` nudge, and +# pnpm refuses to install (ERR_PNPM_IGNORED_BUILDS) until each one is either +# allowed or declined. Nothing needs to run, so both are declined. +allowBuilds: + # A dependency of @lablup/ui-common. + "@astryxdesign/core": false + # A dependency of @lablup/ui-common-cli (the ui-common bin). + "@astryxdesign/cli": false diff --git a/package.json b/package.json index 91c390b..6ec63c1 100644 --- a/package.json +++ b/package.json @@ -24,18 +24,11 @@ "engines": { "node": ">=20" }, - "bin": { - "ui-common": "./bin/ui-common.mjs" - }, "packageManager": "pnpm@11.19.0", "files": [ "dist", "astryx", "astryx.integration.mjs", - "bin", - "cli", - "codemods", - "migration", "exports.exclude.json", "exports.customs.json", "NOTICE" @@ -589,7 +582,6 @@ "verify": "pnpm run typecheck && pnpm run lint && pnpm run format:check && pnpm run check:boundary && pnpm run theme:check && pnpm run test && pnpm run build && pnpm run check:pack && pnpm run check:integration" }, "dependencies": { - "@astryxdesign/cli": "0.6.2", "@astryxdesign/core": "0.6.2", "@astryxdesign/theme-neutral": "0.6.2", "@dnd-kit/core": "6.3.1", @@ -597,10 +589,7 @@ "@dnd-kit/sortable": "10.0.0", "@dnd-kit/utilities": "3.2.2", "intl-messageformat": "^11.2.9", - "jscodeshift": "^17.4.0", - "lucide-react": "^1.18.0", - "postcss": "^8.5.25", - "postcss-selector-parser": "^7.1.6" + "lucide-react": "^1.18.0" }, "peerDependencies": { "@astryxdesign/lab": "0.6.2-canary.c9fb1ad", @@ -614,6 +603,7 @@ } }, "devDependencies": { + "@astryxdesign/cli": "0.6.2", "@astryxdesign/lab": "0.6.2-canary.c9fb1ad", "@eslint/js": "^9.39.0", "@stylexjs/stylex": "0.19.0", diff --git a/packages/cli/LICENSE b/packages/cli/LICENSE new file mode 100644 index 0000000..57bc88a --- /dev/null +++ b/packages/cli/LICENSE @@ -0,0 +1,202 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + diff --git a/packages/cli/NOTICE b/packages/cli/NOTICE new file mode 100644 index 0000000..95c0116 --- /dev/null +++ b/packages/cli/NOTICE @@ -0,0 +1,8 @@ +@lablup/ui-common-cli +Copyright 2026 Lablup Inc. + +This product includes software developed at Lablup Inc. +(https://www.lablup.com/). + +It runs the Astryx CLI (@astryxdesign/cli, MIT-licensed by Meta Platforms, +Inc.), which it depends on and does not vendor. diff --git a/packages/cli/README.md b/packages/cli/README.md new file mode 100644 index 0000000..990efb4 --- /dev/null +++ b/packages/cli/README.md @@ -0,0 +1,28 @@ +# @lablup/ui-common-cli + +The `ui-common` command line for [`@lablup/ui-common`](https://www.npmjs.com/package/@lablup/ui-common): +the Astryx CLI that ui-common pins, with its output in `@lablup/ui-common` +terms, the agent block, and the upgrade codemods. + +It is a separate package so the CLI's toolchain (the Astryx CLI, jscodeshift, +postcss) stays out of a consumer's production install. It is released at the +same version as `@lablup/ui-common` and takes it as a peer. + +``` +pnpm dlx @lablup/ui-common-cli@next upgrade --from 0.1 # one-off (npx @lablup/ui-common-cli@next …) + +pnpm add -D @lablup/ui-common-cli@<the @lablup/ui-common version> # or keep it next to ui-common +pnpm exec ui-common --help +``` + +While 0.2 is in prerelease, name the `next` dist-tag (or an exact version): +only prereleases are published, and npm points `latest` at a package's first +publish, so a bare `@lablup/ui-common-cli` resolves to its first alpha. Plain +`@lablup/ui-common-cli` works once 0.2.0 is published. + +Documentation: [The ui-common CLI](https://github.com/lablup/ui-common#the-ui-common-cli) +in the repository README. + +## License + +[Apache-2.0](LICENSE). See [NOTICE](NOTICE). diff --git a/bin/ui-common.mjs b/packages/cli/bin/ui-common.mjs similarity index 100% rename from bin/ui-common.mjs rename to packages/cli/bin/ui-common.mjs diff --git a/cli/agents.mjs b/packages/cli/cli/agents.mjs similarity index 95% rename from cli/agents.mjs rename to packages/cli/cli/agents.mjs index 8986d47..49e58d1 100644 --- a/cli/agents.mjs +++ b/packages/cli/cli/agents.mjs @@ -19,7 +19,7 @@ import { forkedExports, hiddenExports, importAstryxInternal, - ownPackageJson, + uiCommonPackageJson, } from "./paths.mjs"; import { rewriteOutput } from "./rewrite.mjs"; @@ -99,7 +99,8 @@ function uiCommonSection({ version, astryxVersion, invocation }) { ); } lines.push( - `- After bumping @lablup/ui-common: \`${invocation} upgrade --from <old version>\`, then read ui-common-upgrade-report.md.`, + `- The \`ui-common\` bin is @lablup/ui-common-cli, a devDependency pinned to the same version as @lablup/ui-common; bump both together. Without it installed, \`pnpm dlx @lablup/ui-common-cli@next <cmd>\` (or \`npx @lablup/ui-common-cli@next <cmd>\`); drop \`@next\` once 0.2.0 is published.`, + `- After bumping @lablup/ui-common and @lablup/ui-common-cli: \`${invocation} upgrade --from <old version>\`, then read ui-common-upgrade-report.md.`, ); return lines; } @@ -178,7 +179,7 @@ export async function generateBlock(cwd) { const projectDir = findProjectDir(cwd) ?? cwd; const astryxBlock = await renderAstryxBlock(projectDir); return transformBlock(astryxBlock, { - version: ownPackageJson().version, + version: uiCommonPackageJson().version, astryxVersion: dependencyVersion("@astryxdesign/core") ?? "unknown", invocation: binInvocation(projectDir), componentCount: await coreComponentCount(), diff --git a/cli/diff.mjs b/packages/cli/cli/diff.mjs similarity index 100% rename from cli/diff.mjs rename to packages/cli/cli/diff.mjs diff --git a/cli/lab-peer.mjs b/packages/cli/cli/lab-peer.mjs similarity index 100% rename from cli/lab-peer.mjs rename to packages/cli/cli/lab-peer.mjs diff --git a/cli/main.mjs b/packages/cli/cli/main.mjs similarity index 66% rename from cli/main.mjs rename to packages/cli/cli/main.mjs index 1c3adef..8d050a0 100644 --- a/cli/main.mjs +++ b/packages/cli/cli/main.mjs @@ -4,15 +4,26 @@ */ import { AGENTS_HELP, agentsCommand } from "./agents.mjs"; import { passthrough } from "./passthrough.mjs"; -import { dependencyVersion, ownPackageJson } from "./paths.mjs"; +import { + cliDependencyVersion, + cliPackageJson, + uiCommonPackageJson, + uiCommonRoot, +} from "./paths.mjs"; import { ASTRYX_COMMANDS } from "./rewrite.mjs"; import { SYNC_HELP, syncAstryxCommand } from "./sync-astryx.mjs"; import { UPGRADE_HELP, upgradeCommand } from "./upgrade.mjs"; export const HELP = `Usage: ui-common <command> [options] -@lablup/ui-common's CLI. Astryx commands run the Astryx CLI that ui-common -pins, with its output rewritten to ui-common import paths. +@lablup/ui-common's CLI (@lablup/ui-common-cli). Astryx commands run the +Astryx CLI it pins, with its output rewritten to ui-common import paths. + +One-off, before ui-common is installed or bumped (\`@next\` until 0.2.0 is +published; npm's \`latest\` for this package is its first alpha): + pnpm dlx @lablup/ui-common-cli@next upgrade --from 0.1 (npx: npx @lablup/ui-common-cli@next …) +Installed as a devDependency next to @lablup/ui-common: + pnpm exec ui-common <command> ui-common commands: agents [--write <file>] [--check] @@ -32,7 +43,9 @@ Astryx commands (rewritten to @lablup/ui-common paths): Options: -h, --help Show this help (\`ui-common <command> --help\` for one command) - -V, --version Print the @lablup/ui-common version + -V, --version Print the @lablup/ui-common-cli version (the same as the + @lablup/ui-common it ships with); --verbose adds the + project's ui-common and the Astryx CLI Exit codes: a passed-through command exits with the Astryx CLI's code. ui-common commands exit 0 on success, 1 on a failed check or run, 2 on bad arguments. @@ -54,10 +67,17 @@ export async function main(argv) { return 0; case "-V": case "--version": - process.stdout.write(`${ownPackageJson().version}\n`); + process.stdout.write(`${cliPackageJson().version}\n`); if (rest.includes("--verbose")) { + let uiCommon = "not installed"; + try { + const root = uiCommonRoot(); + uiCommon = `${uiCommonPackageJson(root).version} (${root})`; + } catch { + // reported as not installed + } process.stdout.write( - `astryx ${dependencyVersion("@astryxdesign/cli") ?? "not installed"}\n`, + `@lablup/ui-common ${uiCommon}\nastryx ${cliDependencyVersion("@astryxdesign/cli") ?? "not installed"}\n`, ); } return 0; diff --git a/cli/passthrough.mjs b/packages/cli/cli/passthrough.mjs similarity index 100% rename from cli/passthrough.mjs rename to packages/cli/cli/passthrough.mjs diff --git a/packages/cli/cli/paths.mjs b/packages/cli/cli/paths.mjs new file mode 100644 index 0000000..a787f62 --- /dev/null +++ b/packages/cli/cli/paths.mjs @@ -0,0 +1,290 @@ +/** + * Where things live. Two packages are involved, and they are found apart: + * + * - The CLI's own package, @lablup/ui-common-cli: the Astryx CLI it pins, + * jscodeshift, the codemods and the migration map. + * - The @lablup/ui-common package the CLI works for: its version, its export + * lists (exports.exclude.json, exports.customs.json) and the Astryx packages + * it pins (core, theme-neutral, lab). A consumer depends on ui-common, so + * under pnpm's isolated layout those are reachable from ui-common's install + * location and not from the consumer's own directory. + * + * Nothing is resolved from the working directory except the project's own + * @lablup/ui-common. + */ +import { existsSync, readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +export const UI_COMMON = "@lablup/ui-common"; + +/** Root of the installed @lablup/ui-common-cli package. */ +export const CLI_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + +/** @param {string} file */ +export function readJson(file) { + return JSON.parse(readFileSync(file, "utf8")); +} + +/** @returns {{name: string, version: string, dependencies?: Record<string,string>, peerDependencies?: Record<string,string>}} */ +export function cliPackageJson() { + return readJson(join(CLI_ROOT, "package.json")); +} + +/** + * The @lablup/ui-common package root `fromDir` resolves, or null. Its + * `exports` map lists `./package.json`, so this works from a consumer, from + * the CLI package (its peer) and, by self-reference, from this repository. + * + * @param {string} fromDir + */ +function resolveUiCommonFrom(fromDir) { + try { + const req = createRequire(join(fromDir, "package.json")); + return dirname(req.resolve(`${UI_COMMON}/package.json`)); + } catch { + return null; + } +} + +/** + * Root of the @lablup/ui-common a command works on: the project's own install + * first, so the CLI reads the ui-common the project actually has, then the one + * beside the CLI (its peer, which a `pnpm dlx` run installs). + * + * @param {string} [cwd] + */ +export function uiCommonRoot(cwd = process.cwd()) { + const project = findProjectDir(cwd); + const dir = + (project && resolveUiCommonFrom(project)) ?? resolveUiCommonFrom(CLI_ROOT); + if (!dir) { + throw new Error( + `${UI_COMMON} is not installed. Add it to the project (the CLI is @lablup/ui-common-cli, a devDependency next to it).`, + ); + } + return dir; +} + +/** + * Root of the @lablup/ui-common this CLI release ships with: its peer, at the + * same version. `upgrade` reads its target's pins from here, since the project + * may still be on the version it is upgrading from. Falls back to the + * project's when the peer is not reachable from the CLI. + * + * @param {string} [cwd] + */ +export function targetUiCommonRoot(cwd = process.cwd()) { + return resolveUiCommonFrom(CLI_ROOT) ?? uiCommonRoot(cwd); +} + +/** + * package.json of the @lablup/ui-common a command works on (see uiCommonRoot). + * + * @param {string} [root] + * @returns {{name: string, version: string, dependencies?: Record<string,string>, peerDependencies?: Record<string,string>, devDependencies?: Record<string,string>}} + */ +export function uiCommonPackageJson(root = uiCommonRoot()) { + return readJson(join(root, "package.json")); +} + +/** Walk up from a resolved file to the directory holding its package.json. */ +function packageDirOf(file, name) { + let dir = dirname(file); + for (;;) { + const pkg = join(dir, "package.json"); + if (existsSync(pkg)) { + try { + if (readJson(pkg).name === name) return dir; + } catch { + // keep walking + } + } + const parent = dirname(dir); + if (parent === dir) return null; + dir = parent; + } +} + +/** + * The directory of a package as `fromDir` resolves it, or null. Resolution + * goes through the package's main entry, because not every Astryx package + * exports its package.json. + * + * @param {string} name + * @param {string} fromDir + */ +function packageDirFrom(name, fromDir) { + try { + const req = createRequire(join(fromDir, "package.json")); + return packageDirOf(req.resolve(name), name); + } catch { + return null; + } +} + +/** + * The directory of a dependency of @lablup/ui-common (an Astryx package it + * pins), or null. + * + * @param {string} name + * @param {string} [root] the ui-common root to resolve from + */ +export function dependencyDir(name, root) { + let from = root; + if (!from) { + try { + from = uiCommonRoot(); + } catch { + return null; + } + } + return packageDirFrom(name, from); +} + +/** + * The directory of a dependency of the CLI package itself, or null. + * + * @param {string} name + */ +export function cliDependencyDir(name) { + return packageDirFrom(name, CLI_ROOT); +} + +/** Root of the exact-pinned @astryxdesign/cli. */ +export function astryxCliRoot() { + const dir = cliDependencyDir("@astryxdesign/cli"); + if (!dir) { + throw new Error( + "@astryxdesign/cli is not installed next to @lablup/ui-common-cli. Reinstall @lablup/ui-common-cli.", + ); + } + return dir; +} + +/** The `astryx` bin script of the pinned CLI. */ +export function astryxBin() { + const root = astryxCliRoot(); + const bin = readJson(join(root, "package.json")).bin?.astryx; + return join(root, bin ?? "clients/cli/bin/astryx.mjs"); +} + +/** + * Import a module file from inside the pinned Astryx CLI. These are internals, + * not the CLI's public API; the pin is exact and `sync-astryx` re-runs the + * tests that use them on every bump. + * + * @param {string} relative path under the CLI root + */ +export async function importAstryxInternal(relative) { + const file = join(astryxCliRoot(), relative); + return import(pathToFileURL(file).href); +} + +/** + * Version of an installed dependency of ui-common, or null. + * + * @param {string} name + * @param {string} [root] the ui-common root to resolve from + */ +export function dependencyVersion(name, root) { + const dir = dependencyDir(name, root); + return dir ? readJson(join(dir, "package.json")).version : null; +} + +/** + * Version of an installed dependency of the CLI package, or null. + * + * @param {string} name + */ +export function cliDependencyVersion(name) { + const dir = cliDependencyDir(name); + return dir ? readJson(join(dir, "package.json")).version : null; +} + +/** `exports.exclude.json`: Astryx subpaths ui-common hides, with replacements. */ +export function excludedExports(root = uiCommonRoot()) { + const file = join(root, "exports.exclude.json"); + if (!existsSync(file)) return []; + return /** @type {Array<{name: string, exports?: string[], replacedBy: string|null, reason: string}>} */ ( + readJson(file) + ); +} + +/** `exports.customs.json`: ui-common's own exports. */ +export function customExports(root = uiCommonRoot()) { + const file = join(root, "exports.customs.json"); + if (!existsSync(file)) return []; + return /** @type {Array<{name: string, source: string, subpath?: string, fork?: string, legacy?: object}>} */ ( + readJson(file) + ); +} + +/** + * ui-common's own copies of Astryx components (src/forks/): same names and + * import paths as Astryx's, which exports.exclude.json hides. `names` are the + * names a reader may meet it by; `from` is where a consumer imports it. + * + * @returns {Array<{name: string, from: string, names: string[], reason: string}>} + */ +export function forkedExports() { + const exclusions = excludedExports(); + return customExports() + .filter((c) => c.fork !== undefined) + .map((c) => { + const exclusion = exclusions.find((e) => e.replacedBy === c.name); + return { + name: c.name, + from: `@lablup/ui-common/${c.subpath ?? exclusion?.name ?? ""}`, + names: exclusion?.exports ?? [c.name], + reason: exclusion?.reason ?? "", + }; + }); +} + +/** + * The exclusions that hide an Astryx name behind a different one. A fork's + * exclusion is not one: the name stays, now ui-common's. + */ +export function hiddenExports() { + const forks = new Set(forkedExports().map((f) => f.name)); + return excludedExports().filter( + (e) => e.replacedBy === null || !forks.has(e.replacedBy), + ); +} + +/** + * The nearest directory at or above `start` holding a package.json. + * + * @param {string} start + */ +export function findProjectDir(start) { + let dir = resolve(start); + for (;;) { + if (existsSync(join(dir, "package.json"))) return dir; + const parent = dirname(dir); + if (parent === dir) return null; + dir = parent; + } +} + +/** + * How this project runs a locally installed bin, from its lockfile. + * + * @param {string} projectDir + */ +export function binInvocation(projectDir, bin = "ui-common") { + let dir = resolve(projectDir); + for (;;) { + if (existsSync(join(dir, "pnpm-lock.yaml"))) return `pnpm exec ${bin}`; + if (existsSync(join(dir, "yarn.lock"))) return `yarn ${bin}`; + if (existsSync(join(dir, "bun.lock")) || existsSync(join(dir, "bun.lockb"))) { + return `bunx ${bin}`; + } + if (existsSync(join(dir, "package-lock.json"))) return `npx ${bin}`; + const parent = dirname(dir); + if (parent === dir) return `npx ${bin}`; + dir = parent; + } +} diff --git a/cli/report.mjs b/packages/cli/cli/report.mjs similarity index 85% rename from cli/report.mjs rename to packages/cli/cli/report.mjs index b767475..fe0974b 100644 --- a/cli/report.mjs +++ b/packages/cli/cli/report.mjs @@ -27,6 +27,8 @@ function escapeCell(text) { * @param {boolean} data.dryRun * @param {string[]} data.roots * @param {number} data.fileCount + * @param {string[]} [data.scanRoots] where the manual-review scan looked + * @param {number} [data.scanCount] * @param {Array<{version: string, title: string, notes: string[]}>} data.steps * @param {Array<{file: string, created: boolean, transforms: string[], added: number, removed: number}>} data.changed * @param {{changed: boolean, notes: string[]}} data.packageJson @@ -35,19 +37,29 @@ function escapeCell(text) { * @param {Record<string, {title: string, help: string}>} data.categories * @param {Array<{file: string, transform: string, error: string}>} data.errors * @param {string[]} [data.notices] where the run did something other than the usual + * @param {string[]} [data.alerts] what has to be done before the app works; first in the report * @param {number} data.tokenReads */ export function renderReport(data) { const out = []; out.push(REPORT_HEADING, ""); out.push( - `\`ui-common upgrade\` ${data.from} → ${data.to} (installed @lablup/ui-common ${data.version})` + + `\`ui-common upgrade\` ${data.from} → ${data.to} (@lablup/ui-common-cli ${data.version})` + `${data.dryRun ? ", **dry run: nothing was written**" : ""}.`, "", - `Scanned ${data.fileCount} file${data.fileCount === 1 ? "" : "s"} under ${data.roots.map(code).join(", ")}.`, + `Ran the codemods over ${data.fileCount} file${data.fileCount === 1 ? "" : "s"} under ${data.roots.map(code).join(", ")}` + + (data.scanRoots + ? `; searched ${data.scanCount ?? 0} file${data.scanCount === 1 ? "" : "s"} under ${data.scanRoots.map((r) => (r === "." ? "the project root" : code(r))).join(", ")} for manual-review findings.` + : "."), "", ); + if (data.alerts && data.alerts.length > 0) { + out.push("## Action required", ""); + for (const alert of data.alerts) out.push(`- ${alert}`); + out.push(""); + } + const manual = data.findings.length; out.push("## Summary", ""); out.push("| | Count |", "|---|---:|"); diff --git a/packages/cli/cli/resolve.mjs b/packages/cli/cli/resolve.mjs new file mode 100644 index 0000000..813d80e --- /dev/null +++ b/packages/cli/cli/resolve.mjs @@ -0,0 +1,257 @@ +/** + * Resolve a project's own import specifiers to files, the way its bundler + * would for the common cases: relative paths, and the `paths` / `baseUrl` + * aliases its tsconfig declares (`@/components/common`). A specifier that + * resolves neither way and is not an installed package is recorded as an + * unresolved alias (a Vite `resolve.alias`, say), for the report. + * + * Only what `ui-common upgrade` needs: file-to-file resolution inside the + * project. Packages are never resolved. + */ +import { existsSync, readFileSync, statSync } from "node:fs"; +import { builtinModules } from "node:module"; +import { dirname, join, resolve } from "node:path"; + +const SCRIPT_EXTENSIONS = [ + ".tsx", + ".ts", + ".jsx", + ".js", + ".mts", + ".mjs", + ".cts", + ".cjs", +]; + +/** + * JSON with comments and trailing commas, as tsconfig allows. + * + * @param {string} text + */ +export function parseJsonc(text) { + let out = ""; + for (let i = 0; i < text.length; i++) { + const ch = text[i]; + if (ch === '"') { + const start = i; + for (i++; i < text.length && text[i] !== '"'; i++) if (text[i] === "\\") i++; + out += text.slice(start, i + 1); + } else if (ch === "/" && text[i + 1] === "/") { + while (i < text.length && text[i] !== "\n") i++; + out += "\n"; + } else if (ch === "/" && text[i + 1] === "*") { + const end = text.indexOf("*/", i + 2); + i = end === -1 ? text.length : end + 1; + } else out += ch; + } + return JSON.parse(out.replace(/,(\s*[}\]])/g, "$1")); +} + +/** @param {string} path */ +function isFile(path) { + try { + return statSync(path).isFile(); + } catch { + return false; + } +} + +/** + * The file an import of `base` (no extension, or a `.js` one standing for a + * `.ts` source) loads: `base` itself, `base.<ext>`, or `base/index.<ext>`. + * + * @param {string} base absolute + */ +export function resolveFile(base) { + if (isFile(base) && SCRIPT_EXTENSIONS.some((e) => base.endsWith(e))) return base; + for (const ext of SCRIPT_EXTENSIONS) + if (isFile(`${base}${ext}`)) return `${base}${ext}`; + const js = /\.(m|c)?jsx?$/.exec(base); + if (js) { + const stem = base.slice(0, js.index); + for (const ext of SCRIPT_EXTENSIONS) + if (isFile(`${stem}${ext}`)) return `${stem}${ext}`; + } + for (const ext of SCRIPT_EXTENSIONS) { + const index = join(base, `index${ext}`); + if (isFile(index)) return index; + } + return null; +} + +/** + * `compilerOptions` of a tsconfig, merged over what it extends (relative + * `extends` only), with `paths` and `baseUrl` made absolute. + * + * @param {string} file + * @param {Set<string>} [seen] + * @returns {{baseUrl?: string, paths?: Record<string, string[]>, pathsBase?: string}} + */ +function readTsconfig(file, seen = new Set()) { + if (seen.has(file) || !isFile(file)) return {}; + seen.add(file); + let json; + try { + json = parseJsonc(readFileSync(file, "utf8")); + } catch { + return {}; + } + const dir = dirname(file); + /** @type {{baseUrl?: string, paths?: Record<string, string[]>, pathsBase?: string}} */ + let merged = {}; + const parents = Array.isArray(json.extends) ? json.extends : [json.extends]; + for (const parent of parents) { + if (typeof parent !== "string" || !parent.startsWith(".")) continue; + const target = resolve(dir, parent); + merged = { + ...merged, + ...readTsconfig(target.endsWith(".json") ? target : `${target}.json`, seen), + }; + } + const options = json.compilerOptions ?? {}; + if (typeof options.baseUrl === "string") + merged.baseUrl = resolve(dir, options.baseUrl); + if (options.paths && typeof options.paths === "object") { + merged.paths = options.paths; + merged.pathsBase = merged.baseUrl ?? dir; + } else if (merged.paths && typeof options.baseUrl === "string") { + merged.pathsBase = merged.baseUrl; + } + return merged; +} + +/** + * Every tsconfig of the project that may declare aliases: `tsconfig.json`, + * the configs it references, and the usual Vite split (`tsconfig.app.json`). + * + * @param {string} projectDir + */ +function projectTsconfigs(projectDir) { + const root = join(projectDir, "tsconfig.json"); + const files = [root, join(projectDir, "tsconfig.app.json")]; + try { + const json = parseJsonc(readFileSync(root, "utf8")); + for (const ref of json.references ?? []) { + if (typeof ref?.path !== "string") continue; + const target = resolve(projectDir, ref.path); + files.push(target.endsWith(".json") ? target : join(target, "tsconfig.json")); + } + } catch { + // No tsconfig, or one this cannot read: relative imports only. + } + return [...new Set(files)].filter(isFile); +} + +/** @param {string} specifier */ +function packageName(specifier) { + const parts = specifier.split("/"); + return specifier.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0]; +} + +const BUILTINS = new Set(builtinModules); + +/** + * @param {string} projectDir + */ +export function createResolver(projectDir) { + const configs = projectTsconfigs(projectDir).map((f) => readTsconfig(f)); + /** @type {Array<{prefix: string, suffix: string, wildcard: boolean, targets: string[]}>} */ + const aliases = []; + /** @type {string[]} */ + const baseUrls = []; + for (const config of configs) { + if (config.baseUrl) baseUrls.push(config.baseUrl); + if (!config.paths || !config.pathsBase) continue; + for (const [pattern, targets] of Object.entries(config.paths)) { + if (!Array.isArray(targets)) continue; + const star = pattern.indexOf("*"); + aliases.push({ + prefix: star === -1 ? pattern : pattern.slice(0, star), + suffix: star === -1 ? "" : pattern.slice(star + 1), + wildcard: star !== -1, + targets: targets.map((t) => + resolve(/** @type {string} */ (config.pathsBase), t), + ), + }); + } + } + // Longest prefix first, as TypeScript matches. + aliases.sort((a, b) => b.prefix.length - a.prefix.length); + + /** @type {Map<string, boolean>} */ + const installed = new Map(); + /** @param {string} name */ + const isInstalled = (name) => { + let hit = installed.get(name); + if (hit !== undefined) return hit; + hit = false; + for (let dir = projectDir; ;) { + if (existsSync(join(dir, "node_modules", name))) { + hit = true; + break; + } + const parent = dirname(dir); + if (parent === dir) break; + dir = parent; + } + installed.set(name, hit); + return hit; + }; + + /** Alias-like specifiers nothing resolved, by their first segment. @type {Map<string, number>} */ + const unresolved = new Map(); + + /** + * @param {string} fromFile absolute + * @param {string} specifier + * @param {{probe?: boolean}} [options] probe: a lookup that records no miss + * @returns {string | null} + */ + function resolveImport(fromFile, specifier, { probe = false } = {}) { + if (specifier.startsWith(".") || specifier.startsWith("/")) { + return resolveFile(resolve(dirname(fromFile), specifier)); + } + if (specifier.includes(":") || BUILTINS.has(packageName(specifier))) return null; + let aliased = false; + for (const alias of aliases) { + let rest; + if (alias.wildcard) { + if (!specifier.startsWith(alias.prefix) || !specifier.endsWith(alias.suffix)) + continue; + rest = specifier.slice( + alias.prefix.length, + specifier.length - alias.suffix.length, + ); + } else if (specifier !== alias.prefix) continue; + aliased = true; + for (const target of alias.targets) { + const hit = resolveFile( + rest === undefined ? target : target.replace("*", rest), + ); + if (hit) return hit; + } + } + // A tsconfig alias to a stylesheet or JSON file: resolved, just not a script. + if (aliased) return null; + const name = packageName(specifier); + if (isInstalled(name)) return null; + for (const base of baseUrls) { + const hit = resolveFile(join(base, specifier)); + if (hit) return hit; + } + if (probe) return null; + // Only what looks like an alias is worth reporting: a bare name that is + // not installed is as likely a package this checkout has not installed. + const first = specifier.split("/")[0]; + if ( + (/^[@~#$]/.test(specifier) && !/^@[\w-]/.test(specifier)) || + existsSync(join(projectDir, first)) + ) { + const key = `${first}/`; + unresolved.set(key, (unresolved.get(key) ?? 0) + 1); + } + return null; + } + + return { resolveImport, unresolved }; +} diff --git a/cli/rewrite.mjs b/packages/cli/cli/rewrite.mjs similarity index 100% rename from cli/rewrite.mjs rename to packages/cli/cli/rewrite.mjs diff --git a/packages/cli/cli/semver.mjs b/packages/cli/cli/semver.mjs new file mode 100644 index 0000000..94dad9c --- /dev/null +++ b/packages/cli/cli/semver.mjs @@ -0,0 +1,278 @@ +/** + * The little semver the upgrade registry needs: parse, compare with + * prerelease precedence, and coerce the loose forms a person types + * (`0.1`, `v0.1.0-alpha.19`) or a package.json holds (`^0.1.0-alpha.7`). + */ + +const SEMVER = + /^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/; + +/** + * @param {string} input + * @returns {{major: number, minor: number, patch: number, pre: string[]} | null} + */ +export function parse(input) { + const match = SEMVER.exec(String(input).trim()); + if (!match) return null; + return { + major: Number(match[1]), + minor: Number(match[2] ?? 0), + patch: Number(match[3] ?? 0), + pre: match[4] ? match[4].split(".") : [], + }; +} + +/** @param {{major: number, minor: number, patch: number, pre: string[]}} v */ +export function format(v) { + return `${v.major}.${v.minor}.${v.patch}${v.pre.length ? `-${v.pre.join(".")}` : ""}`; +} + +/** + * The first version a dependency spec admits, for `^0.1.0-alpha.7`, + * `~0.1.0`, `0.1.0-alpha.19`, `>=0.1.0-alpha.0 <0.2.0`. Null for + * `workspace:*`, tags and URLs. + * + * @param {string} spec + */ +export function coerce(spec) { + const match = /(?:^|[\s^~>=<v])(\d+(?:\.\d+){0,2}(?:-[0-9A-Za-z.-]+)?)/.exec( + ` ${String(spec).trim()}`, + ); + if (!match) return null; + const parsed = parse(match[1]); + return parsed ? format(parsed) : null; +} + +/** @param {string} a @param {string} b */ +function compareIdentifiers(a, b) { + const na = /^\d+$/.test(a); + const nb = /^\d+$/.test(b); + if (na && nb) return Number(a) - Number(b); + if (na) return -1; + if (nb) return 1; + return a < b ? -1 : a > b ? 1 : 0; +} + +/** + * Semver precedence. A version with a prerelease sorts before the same + * version without one. + * + * @param {string} a + * @param {string} b + */ +export function compare(a, b) { + const va = parse(a); + const vb = parse(b); + if (!va || !vb) throw new Error(`Not a version: ${!va ? a : b}`); + for (const key of /** @type {const} */ (["major", "minor", "patch"])) { + if (va[key] !== vb[key]) return va[key] - vb[key]; + } + if (va.pre.length === 0 || vb.pre.length === 0) { + return vb.pre.length - va.pre.length; + } + for (let i = 0; i < Math.max(va.pre.length, vb.pre.length); i++) { + const x = va.pre[i]; + const y = vb.pre[i]; + if (x === undefined) return -1; + if (y === undefined) return 1; + const c = compareIdentifiers(x, y); + if (c !== 0) return c; + } + return 0; +} + +/** + * The version after `version` for a release that carries a breaking change: + * the next prerelease number while in prerelease, else the next minor (pre-1.0 + * a breaking change bumps the minor). + * + * @param {string} version + */ +export function nextBreaking(version) { + const v = parse(version); + if (!v) throw new Error(`Not a version: ${version}`); + if (v.pre.length > 0) { + const last = v.pre[v.pre.length - 1]; + const pre = /^\d+$/.test(last) + ? [...v.pre.slice(0, -1), String(Number(last) + 1)] + : [...v.pre, "1"]; + return format({ ...v, pre }); + } + if (v.major === 0) return format({ major: 0, minor: v.minor + 1, patch: 0, pre: [] }); + return format({ major: v.major + 1, minor: 0, patch: 0, pre: [] }); +} + +/** + * @typedef {{version: string, inclusive: boolean, text?: string}} Bound + * `text` is the comparator as written, kept when the bound survives. + */ + +/** + * A partial version (`19`, `19.x`, `19.2.*`) as its first admitted version + * and the version its x-range stops before. Null for anything else. + * + * @param {string} input + */ +function partial(input) { + const m = + /^v?(\d+|[xX*])(?:\.(\d+|[xX*]))?(?:\.(\d+|[xX*]))?(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec( + input, + ); + if (!m) return null; + const wild = (/** @type {string | undefined} */ s) => s == null || /^[xX*]$/.test(s); + if (wild(m[1])) return { min: null, end: null, full: false, parts: 0 }; + const major = Number(m[1]); + if (wild(m[2])) + return { min: `${major}.0.0`, end: `${major + 1}.0.0`, full: false, parts: 1 }; + const minor = Number(m[2]); + if (wild(m[3])) + return { + min: `${major}.${minor}.0`, + end: `${major}.${minor + 1}.0`, + full: false, + parts: 2, + }; + const version = `${major}.${minor}.${Number(m[3])}${m[4] ? `-${m[4]}` : ""}`; + return { min: version, end: null, full: true, parts: 3 }; +} + +/** + * One `||` alternative as a lower and an upper bound; null when it is not a + * range this reads (a tag, a URL). + * + * @param {string} alternative + * @returns {{lower: Bound | null, upper: Bound | null, hyphen?: string} | null} + */ +function bounds(alternative) { + const text = alternative.trim(); + if (text === "") return { lower: null, upper: null }; + const hyphen = /^(\S+)\s+-\s+(\S+)$/.exec(text); + if (hyphen) { + const from = partial(hyphen[1]); + const to = partial(hyphen[2]); + if (!from || !to) return null; + return { + lower: from.min ? { version: from.min, inclusive: true } : null, + upper: to.full + ? { version: /** @type {string} */ (to.min), inclusive: true } + : to.end + ? { version: to.end, inclusive: false } + : null, + hyphen: hyphen[2], + }; + } + /** @type {Bound | null} */ + let lower = null; + /** @type {Bound | null} */ + let upper = null; + /** @param {Bound} b */ + const raise = (b) => { + if (!lower || compare(b.version, lower.version) > 0) lower = b; + }; + /** @param {Bound} b */ + const cap = (b) => { + const c = upper ? compare(b.version, upper.version) : -1; + if (c < 0 || (c === 0 && !b.inclusive)) upper = b; + }; + for (const token of text.replace(/(<=|>=|[<>=^~])\s+/g, "$1").split(/\s+/)) { + const m = /^(<=|>=|<|>|=|\^|~)?(.*)$/.exec(token); + const op = m?.[1] ?? ""; + const v = partial(m?.[2] ?? ""); + if (!v) return null; + if (!v.min) { + // `*`, `x`, `>=*`: no bound; `<*` admits nothing. + if (op === "<" || op === ">") + return { lower: null, upper: { version: "0.0.0", inclusive: false } }; + continue; + } + const at = /** @type {string} */ (v.min); + if (op === "^" || op === "~") { + const p = /** @type {{major: number, minor: number, patch: number}} */ ( + parse(at) + ); + raise({ version: at, inclusive: true }); + const end = + op === "~" + ? v.parts >= 2 + ? `${p.major}.${p.minor + 1}.0` + : `${p.major + 1}.0.0` + : p.major > 0 || v.parts === 1 + ? `${p.major + 1}.0.0` + : p.minor > 0 || v.parts === 2 + ? `0.${p.minor + 1}.0` + : `0.0.${p.patch + 1}`; + cap({ version: end, inclusive: false }); + } else if (op === ">=") raise({ version: at, inclusive: true, text: token }); + else if (op === ">") + raise( + v.full + ? { version: at, inclusive: false, text: token } + : { version: /** @type {string} */ (v.end), inclusive: true, text: token }, + ); + else if (op === "<") cap({ version: at, inclusive: false, text: token }); + else if (op === "<=") + cap( + v.full + ? { version: at, inclusive: true, text: token } + : { version: /** @type {string} */ (v.end), inclusive: false, text: token }, + ); + else { + raise({ version: at, inclusive: true }); + cap( + v.full + ? { version: at, inclusive: true } + : { version: /** @type {string} */ (v.end), inclusive: false }, + ); + } + } + return { lower, upper }; +} + +/** + * `range` narrowed to the versions at or above `floor`, alternative by + * alternative: `>=18 <21 || ^22` at 19.2.0 is `>=19.2.0 <21 || ^22`. An + * alternative already above the floor keeps its text; one wholly below it is + * dropped. Null when nothing is left; `range` itself when it is not a range + * this reads. + * + * @param {string} range + * @param {string} floor a full version + * @returns {string | null} + */ +export function narrowToFloor(range, floor) { + const f = parse(floor); + if (!f) throw new Error(`Not a version: ${floor}`); + const alternatives = range.split("||").map((a) => a.trim()); + const parsed = alternatives.map(bounds); + if (parsed.some((b) => b == null)) return range; + /** @type {string[]} */ + const kept = []; + for (const [i, b] of /** @type {NonNullable<ReturnType<typeof bounds>>[]} */ ( + parsed + ).entries()) { + const { lower, upper } = b; + if (lower && compare(lower.version, floor) >= 0) { + kept.push(alternatives[i]); + continue; + } + if (upper) { + const c = compare(upper.version, floor); + if (c < 0 || (c === 0 && !upper.inclusive)) continue; + } + let next; + if (!upper) next = `>=${floor}`; + else if (b.hyphen != null) next = `${floor} - ${b.hyphen}`; + else if (!upper.inclusive && upper.version === `${f.major + 1}.0.0` && f.major > 0) + next = `^${floor}`; + else if (upper.inclusive && upper.version === floor) next = floor; + else + next = `>=${floor} ${upper.text ?? `${upper.inclusive ? "<=" : "<"}${upper.version}`}`; + kept.push(next); + } + const unique = [...new Set(kept)]; + if (unique.length === 0) return null; + return unique.length === alternatives.length && + unique.every((a, i) => a === alternatives[i]) + ? range + : unique.join(" || "); +} diff --git a/cli/shadow.mjs b/packages/cli/cli/shadow.mjs similarity index 100% rename from cli/shadow.mjs rename to packages/cli/cli/shadow.mjs diff --git a/cli/sync-astryx.mjs b/packages/cli/cli/sync-astryx.mjs similarity index 81% rename from cli/sync-astryx.mjs rename to packages/cli/cli/sync-astryx.mjs index 9c2bf7e..fc84f6c 100644 --- a/cli/sync-astryx.mjs +++ b/packages/cli/cli/sync-astryx.mjs @@ -2,13 +2,14 @@ * `ui-common sync-astryx <version>`: the maintainer's Astryx bump, inside the * ui-common repository. * - * 1. Move the exact pins (core, theme-neutral, cli; lab with --lab), and the + * 1. Move the exact pins (core, theme-neutral and the cli devDependency in the + * root package.json, the cli in packages/cli's; lab with --lab), and the * core version README and pnpm-workspace.yaml override lab's core peer to. * 2. Install, regenerate the export mirror and the built Lablup theme. * 3. Run Astryx's codemods on ui-common's own src/ (dry run, then applied). * 4. Run the tests, which include the export and theme drift checks. * 5. Record the Astryx codemods consumers need for this bump under the next - * ui-common version: codemods/<version>/upstream.json. `ui-common upgrade` + * ui-common version: packages/cli/codemods/<version>/upstream.json. `ui-common upgrade` * runs them for consumers, with the import specifiers swapped. * * `--dry-run` changes nothing: it prints the plan, runs Astryx's codemods on @@ -21,17 +22,22 @@ */ import { execFileSync, spawnSync } from "node:child_process"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { syncLabOverrideDocs } from "./lab-peer.mjs"; import { findProjectDir, readJson } from "./paths.mjs"; import { compare, nextBreaking, parse } from "./semver.mjs"; -const PINNED = [ - "@astryxdesign/core", - "@astryxdesign/theme-neutral", - "@astryxdesign/cli", -]; +/** Exact pins in the root package.json (@lablup/ui-common), by field. */ +const PINNED = /** @type {const} */ ([ + ["dependencies", "@astryxdesign/core"], + ["dependencies", "@astryxdesign/theme-neutral"], + // The root's own scripts (theme:build, check:integration) run it. + ["devDependencies", "@astryxdesign/cli"], +]); +/** The CLI package, which carries the Astryx CLI as a dependency. */ +export const CLI_PACKAGE_DIR = join("packages", "cli"); +const CLI_PINNED = /** @type {const} */ ([["dependencies", "@astryxdesign/cli"]]); const LAB = "@astryxdesign/lab"; export const SYNC_HELP = `Usage: ui-common sync-astryx <version> [--lab <version>] [--as <version>] [--dry-run] @@ -39,7 +45,9 @@ export const SYNC_HELP = `Usage: ui-common sync-astryx <version> [--lab <version Maintainers only, inside the @lablup/ui-common repository. Moves the Astryx pin set to <version>, regenerates the export mirror and the Lablup theme, runs Astryx's codemods on src/, runs the tests, and records the Astryx codemods -consumers need under the next ui-common version (codemods/<as>/upstream.json). +consumers need under the next ui-common version +(packages/cli/codemods/<as>/upstream.json). Run it from the repository root +or anywhere under it. --lab <version> Also move the @astryxdesign/lab canary pin (dev + peer). --as <version> The ui-common version to record the codemods under. @@ -52,26 +60,37 @@ Exit codes: 0 done, 1 a step failed (the tree is left as it was at that step), `; /** + * The repository root at or above `cwd`: the @lablup/ui-common package with + * its scripts and the CLI package beside it. A directory inside the + * repository (packages/cli) finds it too. + * * @param {string} cwd * @returns {string | null} the repository root, or null outside it */ export function findUiCommonRepo(cwd) { - const dir = findProjectDir(cwd); - if (!dir) return null; + for (let dir = findProjectDir(cwd); dir;) { + if (isUiCommonRepo(dir)) return dir; + const parent = dirname(dir); + if (parent === dir) return null; + dir = findProjectDir(parent); + } + return null; +} + +/** @param {string} dir */ +function isUiCommonRepo(dir) { try { const pkg = readJson(join(dir, "package.json")); - if (pkg.name !== "@lablup/ui-common") return null; + if (pkg.name !== "@lablup/ui-common") return false; } catch { - return null; + return false; } // A consumer's node_modules copy has the name but not the repository. - if ( - !existsSync(join(dir, "scripts/gen-exports.mjs")) || - !existsSync(join(dir, "exports.exclude.json")) - ) { - return null; - } - return dir; + return ( + existsSync(join(dir, "scripts/gen-exports.mjs")) && + existsSync(join(dir, "exports.exclude.json")) && + existsSync(join(dir, CLI_PACKAGE_DIR, "package.json")) + ); } /** @@ -108,7 +127,7 @@ function listAstryxCodemods(repo, from, to) { * @param {{codemods: Array<{id: string, version: string, title: string}>, optional: string[]}} listed */ export function upstreamManifest(repo, version, astryx, listed) { - const file = join(repo, "codemods", version, "upstream.json"); + const file = join(repo, CLI_PACKAGE_DIR, "codemods", version, "upstream.json"); /** @type {any} */ let manifest = { $comment: @@ -214,8 +233,9 @@ export async function syncAstryxCommand(argv) { } const pkgFile = join(repo, "package.json"); - const pkgText = readFileSync(pkgFile, "utf8"); - const pkg = JSON.parse(pkgText); + const pkg = JSON.parse(readFileSync(pkgFile, "utf8")); + const cliPkgFile = join(repo, CLI_PACKAGE_DIR, "package.json"); + const cliPkg = JSON.parse(readFileSync(cliPkgFile, "utf8")); const current = pkg.dependencies?.["@astryxdesign/core"]; if (!current || !parse(current)) { process.stderr.write( @@ -231,9 +251,9 @@ export async function syncAstryxCommand(argv) { ); out(` repository: ${repo}`); const edits = []; - for (const name of PINNED) { - const from = pkg.dependencies?.[name]; - if (from !== target) edits.push(`dependencies["${name}"]: ${from} → ${target}`); + for (const [field, name] of PINNED) { + const from = pkg[field]?.[name]; + if (from !== target) edits.push(`${field}["${name}"]: ${from} → ${target}`); } if (lab) { for (const field of ["devDependencies", "peerDependencies"]) { @@ -246,6 +266,16 @@ export async function syncAstryxCommand(argv) { ? ` package.json: ${edits.join("; ")}` : " package.json: pins already at the target", ); + const cliEdits = []; + for (const [field, name] of CLI_PINNED) { + const from = cliPkg[field]?.[name]; + if (from !== target) cliEdits.push(`${field}["${name}"]: ${from} → ${target}`); + } + out( + cliEdits.length > 0 + ? ` ${CLI_PACKAGE_DIR}/package.json: ${cliEdits.join("; ")}` + : ` ${CLI_PACKAGE_DIR}/package.json: pins already at the target`, + ); for (const edit of labOverrideEdits(repo, target)) { out(` ${edit.name}: lab's core override → ${target}`); } @@ -307,15 +337,19 @@ export async function syncAstryxCommand(argv) { } // Pins. - for (const name of PINNED) { - pkg.dependencies[name] = target; + for (const [field, name] of PINNED) { + pkg[field] = { ...pkg[field], [name]: target }; + } + for (const [field, name] of CLI_PINNED) { + cliPkg[field] = { ...cliPkg[field], [name]: target }; } if (lab) { pkg.devDependencies = { ...pkg.devDependencies, [LAB]: lab }; pkg.peerDependencies = { ...pkg.peerDependencies, [LAB]: lab }; } writeFileSync(pkgFile, `${JSON.stringify(pkg, null, 2)}\n`); - out(" package.json written"); + writeFileSync(cliPkgFile, `${JSON.stringify(cliPkg, null, 2)}\n`); + out(` package.json and ${CLI_PACKAGE_DIR}/package.json written`); for (const edit of labOverrideEdits(repo, target)) { writeFileSync(edit.file, edit.after); out(` ${edit.name}: lab's core override moved to ${target}`); diff --git a/cli/upgrade.mjs b/packages/cli/cli/upgrade.mjs similarity index 65% rename from cli/upgrade.mjs rename to packages/cli/cli/upgrade.mjs index d9d4b6b..048c179 100644 --- a/cli/upgrade.mjs +++ b/packages/cli/cli/upgrade.mjs @@ -14,14 +14,17 @@ import { mkdirSync, readdirSync, readFileSync, + statSync, writeFileSync, } from "node:fs"; +import { spawnSync } from "node:child_process"; import { dirname, extname, join, relative, resolve, sep } from "node:path"; import { registeredVersions, stepsBetween } from "../codemods/registry.mjs"; import { TODO_TAG } from "../codemods/lib/jsx.mjs"; import { diffStat, unifiedDiff } from "./diff.mjs"; -import { findProjectDir, ownPackageJson } from "./paths.mjs"; +import { cliPackageJson, findProjectDir } from "./paths.mjs"; +import { createResolver } from "./resolve.mjs"; import { renderReport, REPORT_HEADING } from "./report.mjs"; import { coerce, compare, parse } from "./semver.mjs"; @@ -35,8 +38,26 @@ const IGNORED_DIRS = new Set([ "coverage", ".turbo", ".cache", + // Only skipped by the project-wide finding scan (collectProjectFiles): + // build output and tool state that is never the project's own source. + "target", + ".venv", + "venv", + "__pycache__", + "storybook-static", + "playwright-report", + "test-results", + ".svelte-kit", + ".nuxt", + ".output", + ".vite", + ".yarn", + ".pnpm-store", ]); +/** Bigger than any hand-written source; a bundle or a generated file. */ +const MAX_SCAN_BYTES = 512 * 1024; + export const SOURCE_EXTENSIONS = new Set([ ".tsx", ".ts", @@ -99,10 +120,89 @@ function readdirSafe(p) { } } +/** + * The files the manual-review scan reads: every source-like file under + * `roots`, without build output, generated directories, bundles, and + * packages nested in the project (another package.json below it is another + * project). In a git checkout the list comes from git, so .gitignore'd files + * are skipped too. + * + * @param {string[]} roots absolute + * @param {string} projectDir + */ +export function collectProjectFiles(roots, projectDir) { + /** @type {string[]} */ + let listed = []; + const git = spawnSync( + "git", + ["ls-files", "-z", "--cached", "--others", "--exclude-standard", "--", "."], + { cwd: projectDir, encoding: "utf8", maxBuffer: 256 * 1024 * 1024 }, + ); + if (git.status === 0 && git.stdout) { + listed = git.stdout + .split("\0") + .filter(Boolean) + .map((f) => join(projectDir, f)); + } else { + walk(projectDir, listed); + // walk keeps sources only; package.json files mark nested packages. + listed.push(...findPackageJsons(projectDir)); + } + const nested = new Set( + listed + .filter((f) => f.endsWith(`${sep}package.json`) && dirname(f) !== projectDir) + .map((f) => dirname(f) + sep), + ); + const inRoots = (/** @type {string} */ f) => + roots.some((r) => f === r || f.startsWith(r.endsWith(sep) ? r : r + sep)); + return [ + ...new Set( + listed.filter((f) => { + if (!inRoots(f)) return false; + if (!SOURCE_EXTENSIONS.has(extname(f)) || f.endsWith(".d.ts")) return false; + if (/\.min\.[cm]?[jt]s$|\.min\.css$/.test(f)) return false; + const parts = relative(projectDir, f).split(sep); + if (parts.slice(0, -1).some((d) => IGNORED_DIRS.has(d))) return false; + for (const dir of nested) if (f.startsWith(dir)) return false; + try { + return statSync(f).size <= MAX_SCAN_BYTES; + } catch { + return false; + } + }), + ), + ].sort(); +} + +/** @param {string} dir */ +function findPackageJsons(dir) { + /** @type {string[]} */ + const out = []; + const visit = (/** @type {string} */ d) => { + let entries; + try { + entries = readdirSync(d, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + if (entry.isSymbolicLink()) continue; + const full = join(d, entry.name); + if (entry.isDirectory()) { + if (!IGNORED_DIRS.has(entry.name)) visit(full); + } else if (entry.name === "package.json") out.push(full); + } + }; + visit(dir); + return out; +} + /** * @typedef {object} UpgradeOptions * @property {string} cwd * @property {string[]} paths as given (relative to cwd) + * @property {string[]} [scan] where the manual-review scan looks (relative to + * cwd); default: the whole project * @property {string} [from] * @property {string} [to] * @property {boolean} [dryRun] @@ -123,7 +223,9 @@ export async function runUpgrade(options) { const pkgFile = join(projectDir, "package.json"); const pkgText = existsSync(pkgFile) ? readFileSync(pkgFile, "utf8") : null; const pkg = pkgText ? JSON.parse(pkgText) : {}; - const own = ownPackageJson(); + // The codemods ship with the CLI, which moves in lockstep with ui-common: + // its version is the ui-common version they upgrade to. + const own = cliPackageJson(); const declared = pkg.dependencies?.["@lablup/ui-common"] ?? @@ -167,6 +269,10 @@ export async function runUpgrade(options) { return { code: 2 }; } const files = collectFiles(roots); + const scanRoots = + options.scan && options.scan.length > 0 + ? options.scan.map((p) => resolve(cwd, p)) + : [projectDir]; // A dry run prints the report unless --report names a file for it. The // report replaces an earlier report, never anything else. @@ -205,12 +311,72 @@ export async function runUpgrade(options) { const errors = []; /** @type {string[]} */ const notices = []; + /** @type {string[]} */ + const alerts = []; + const resolver = createResolver(projectDir); + /** @type {Map<string, string | null>} */ + const unscanned = new Map(); const ctx = { from, to, flags: { packages: new Map(), touched: new Set() }, projectDir, note: (/** @type {string} */ message) => packageNotes.push(message), + /** + * A project file as it was before this run: the scanned sources from + * memory, anything else from disk (read once). Null outside the project + * or when unreadable. + * + * @param {string} path absolute + */ + source: (path) => { + const known = state.get(path); + if (known) return known.created ? null : known.original; + if ( + !path.startsWith(projectDir + sep) || + path.split(sep).includes("node_modules") + ) + return null; + if (!unscanned.has(path)) { + let text = null; + try { + text = readFileSync(path, "utf8"); + } catch { + // left null + } + unscanned.set(path, text); + } + return unscanned.get(path) ?? null; + }, + /** The project file an import specifier names (relative or a tsconfig alias), or null. */ + resolveImport: resolver.resolveImport, + /** The project's package.json, parsed, as it was before this run. */ + pkg, + /** + * Something the person has to do before the upgraded app works: the + * report opens with these. + * + * @param {string} message + */ + alert: (message) => alerts.push(message), + /** @param {string} message where the run did something other than the usual */ + notice: (message) => notices.push(message), + /** Every file the finding scan would read (the whole project), absolute. */ + projectFiles: () => collectProjectFiles([projectDir], projectDir), + /** + * A file's content as this run has it now (edits included), or from disk. + * + * @param {string} path absolute + */ + current: (path) => { + const known = state.get(path); + if (known) return known.current; + try { + return readFileSync(path, "utf8"); + } catch { + return null; + } + }, /** * Edit a project file outside the scanned sources (pnpm-workspace.yaml). * `edit` gets its current text (null: absent) and returns the new text, @@ -219,8 +385,9 @@ export async function runUpgrade(options) { * * @param {string} path * @param {(current: string | null) => string | undefined} edit + * @param {string} [id] the transform named in the report */ - editFile: (path, edit) => { + editFile: (path, edit, id = "package-json") => { const known = state.get(path); const current = known ? known.current @@ -231,14 +398,13 @@ export async function runUpgrade(options) { if (next == null || next === current) return; if (known) { known.current = next; - if (!known.transforms.includes("package-json")) - known.transforms.push("package-json"); + if (!known.transforms.includes(id)) known.transforms.push(id); return; } state.set(path, { original: current ?? "", current: next, - transforms: ["package-json"], + transforms: [id], created: current == null, project: true, }); @@ -322,6 +488,20 @@ export async function runUpgrade(options) { } } + // Project-level edits that need every file transformed first. + for (const { step } of steps) { + if (!step.afterTransforms) continue; + try { + step.afterTransforms(ctx, { jscodeshift }); + } catch (err) { + errors.push({ + file: ".", + transform: "after-transforms", + error: /** @type {Error} */ (err).message, + }); + } + } + // package.json, after every source transform has set its flags. let pkgAfter = pkgText; if (pkgText) { @@ -344,13 +524,32 @@ export async function runUpgrade(options) { const findings = []; /** @type {Record<string, {title: string, help: string}>} */ const categories = {}; + // The finding scan reads the whole project (or --scan), not only the + // transformed sources: tests, e2e specs and scripts name 0.1 classes and + // stylesheet paths too. Files outside the sources are read, never written. + /** @type {Array<[string, string]>} */ + const scanned = []; + const inScan = (/** @type {string} */ f) => + scanRoots.some((r) => f === r || f.startsWith(r + sep)); + for (const [file, entry] of state) { + if (entry.created || entry.project || !inScan(file)) continue; + scanned.push([file, entry.current]); + } + for (const file of collectProjectFiles(scanRoots, projectDir)) { + if (state.has(file)) continue; + const text = ctx.source(file); + if (text != null) scanned.push([file, text]); + } + scanned.sort((a, b) => a[0].localeCompare(b[0])); for (const { step } of steps) { Object.assign(categories, step.categories ?? {}); + if (step.findings) findings.push(...step.findings(ctx, rel)); if (!step.scan) continue; - for (const [file, entry] of state) { - if (entry.created || entry.project) continue; - findings.push(...step.scan(rel(file), entry.current)); - } + const context = step.prepareScan?.( + scanned.map(([file, text]) => [rel(file), text]), + ); + for (const [file, text] of scanned) + findings.push(...step.scan(rel(file), text, context)); } /** @type {Array<{file: string, line: number, text: string}>} */ const todos = []; @@ -402,6 +601,15 @@ export async function runUpgrade(options) { if (pkgChanged && pkgAfter != null) writeFileSync(pkgFile, pkgAfter); } + if (resolver.unresolved.size > 0) { + const list = [...resolver.unresolved.entries()] + .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) + .map(([prefix, n]) => `\`${prefix}\` (${n})`); + notices.push( + `Imports through ${list.join(", ")} did not resolve: the upgrade reads relative imports and tsconfig \`paths\`, not bundler aliases. Elements of 0.1 components imported through a project barrel that way were not migrated; check those modules by hand.`, + ); + } + const report = renderReport({ from, to, @@ -409,6 +617,10 @@ export async function runUpgrade(options) { dryRun, roots: roots.map((r) => relative(projectDir, r).split(sep).join("/") || "."), fileCount: files.length, + scanRoots: scanRoots.map( + (r) => relative(projectDir, r).split(sep).join("/") || ".", + ), + scanCount: scanned.length, steps: steps.map(({ version, step }) => ({ version, title: step.title, @@ -426,6 +638,7 @@ export async function runUpgrade(options) { categories, errors, notices, + alerts, tokenReads, }); if (writeReport) { @@ -449,6 +662,7 @@ export async function runUpgrade(options) { log(unifiedDiff("package.json", pkgText, pkgAfter)); } for (const e of errors) warn(` ! ${e.file} [${e.transform}]: ${e.error}`); + for (const alert of alerts) warn(` ACTION REQUIRED: ${alert}`); for (const notice of notices) warn(` note: ${notice}`); if (writeReport) log(`Report: ${relative(cwd, reportFile) || reportFile}`); else log(`\n${report}`); @@ -470,22 +684,33 @@ export async function runUpgrade(options) { }; } -export const UPGRADE_HELP = `Usage: ui-common upgrade [--from <version>] [--to <version>] [--dry-run] [--diff] [--report <path>] [paths…] +export const UPGRADE_HELP = `Usage: ui-common upgrade [--from <version>] [--to <version>] [--dry-run] [--diff] [--report <path>] [--scan <path>]… [paths…] Run the codemods registered between two @lablup/ui-common versions over your source (TS/TSX/JS/JSX through jscodeshift, CSS through postcss), update -package.json, and write a manual-review report. +package.json, and write a manual-review report. From 0.1, package.json also +gains @lablup/ui-common-cli (this bin) as a devDependency at the new version. + +One-off, from a project still on 0.1 (\`@next\` until 0.2.0 is published): + pnpm dlx @lablup/ui-common-cli@next upgrade --from 0.1 --dry-run + (npx @lablup/ui-common-cli@next upgrade --from 0.1 --dry-run) --from <version> The ui-common version the code is written against. Default: the version package.json declares. - --to <version> Default: the installed @lablup/ui-common version. + --to <version> Default: this CLI's version, which is the @lablup/ui-common + version it ships with. --dry-run Write nothing; list what would change and print the report. --diff With --dry-run, also print unified diffs. --report <path> Where to write the report. Default: ui-common-upgrade-report.md, except in a dry run, which writes a report only to a path given here. An existing file is replaced only if it is an earlier report. - paths… Directories or files to scan. Default: src/ + --scan <path> Where the report looks for 0.1 class names, stylesheet + paths, test queries and module mocks. Default: the whole + project (tests, e2e, scripts, …), without node_modules, + build output, git-ignored files and nested packages. + Repeat to narrow it: --scan src --scan e2e. + paths… Directories or files to transform. Default: src/ Exit codes: 0 done, 1 some files could not be transformed (see the report), 2 bad arguments or nothing to scan. @@ -514,6 +739,11 @@ export async function upgradeCommand(argv) { case "--report": options.report = value(); break; + case "--scan": { + const v = value(); + if (v) (options.scan ??= []).push(v); + break; + } case "--dry-run": options.dryRun = true; break; @@ -533,7 +763,7 @@ export async function upgradeCommand(argv) { } options.paths.push(arg); } - if (["--from", "--to", "--report"].includes(flag)) { + if (["--from", "--to", "--report", "--scan"].includes(flag)) { const v = /** @type {any} */ (options)[flag.slice(2)]; if (!v) { process.stderr.write(`ui-common upgrade: ${flag} needs a value\n`); diff --git a/codemods/0.2/components.mjs b/packages/cli/codemods/0.2/components.mjs similarity index 67% rename from codemods/0.2/components.mjs rename to packages/cli/codemods/0.2/components.mjs index 8d96035..493a726 100644 --- a/codemods/0.2/components.mjs +++ b/packages/cli/codemods/0.2/components.mjs @@ -15,9 +15,11 @@ import { identifierNames, renameElement, tagName, + TODO_TAG, } from "../lib/jsx.mjs"; import { addTodo, printSource } from "../lib/todo.mjs"; import { ELEMENT_TRANSFORMS } from "./elements.mjs"; +import { importsLocalLegacy, localExports, registerWrapper } from "./local-modules.mjs"; import { MOVED, REMOVED, REMOVED_TYPES, UIC } from "./map.mjs"; export const meta = { @@ -166,8 +168,14 @@ function renameReferences(j, root, from, to, programScope) { return; } if (parent?.type === "ExportSpecifier" && parent.local === p.node) { - parent.exported = j.identifier(parent.exported?.name ?? from); - parent.local = j.identifier(to); + // A fresh node: recast prints a parsed specifier from its original + // fields, so editing local/exported in place loses the public name. + const spec = j.exportSpecifier.from({ + local: j.identifier(to), + exported: j.identifier(parent.exported?.name ?? from), + }); + if (parent.exportKind) spec.exportKind = parent.exportKind; + p.parent.replace(spec); return; } if (!isReference(p)) return; @@ -270,14 +278,120 @@ function reuseNode(path, decls, kindKey) { } } +/** @param {any} entry a local-module export */ +const isRemovedType = (entry) => entry?.kind === "type" && entry.removed === true; + +/** + * Remove a top-level statement, handing its comments (TODOs included) to the + * statement after it, or before it when it was the last. + * + * @param {any} j + * @param {any} root + * @param {any} path + */ +function pruneKeepingComments(j, root, path) { + const node = path.node; + const comments = node.comments ?? []; + const body = root.find(j.Program).get().node.body; + const index = body.indexOf(node); + path.prune(); + if (comments.length === 0 || index === -1) return; + const next = body[index]; + if (next) { + next.comments = [...comments, ...(next.comments ?? [])]; + return; + } + const previous = body[index - 1]; + if (previous) { + previous.comments = [ + ...(previous.comments ?? []), + ...comments.map((/** @type {any} */ c) => ({ + ...c, + leading: false, + trailing: true, + })), + ]; + return; + } + keepCommentsInEmptyModule(j, root.find(j.Program).get().node, comments); +} + +/** + * A module the codemod emptied keeps its TODOs on an `export {};`, which also + * keeps it a module. + * + * @param {any} j + * @param {any} program + * @param {any[]} comments + */ +function keepCommentsInEmptyModule(j, program, comments) { + const decl = j.exportNamedDeclaration(null, []); + decl.comments = comments.map((/** @type {any} */ c) => ({ + ...c, + leading: true, + trailing: false, + })); + program.body.push(decl); +} + +/** + * A removed 0.1 type with no Astryx counterpart loses its import. A local + * re-export of it (`import type { X } from …; export type { X };`) goes too, + * the same way a re-export straight from ui-common does: left behind it names + * nothing, and the file no longer parses. No placeholder type is declared in + * its place: an `unknown` alias would keep every importer compiling against a + * type that no longer exists. The TODO goes on the export, or where it stood. + * + * @param {any} j + * @param {any} root + * @param {Map<string, {imported: string, component: string}>} dropped + * @param {any} programScope + */ +function dropLocalReexports(j, root, dropped, programScope) { + root.find(j.ExportNamedDeclaration).forEach((/** @type {any} */ path) => { + const node = path.node; + if (node.source || node.declaration) return; + const specifiers = node.specifiers ?? []; + const gone = specifiers.filter( + (/** @type {any} */ s) => + s.local?.name && + dropped.has(s.local.name) && + isModuleBinding(path, s.local.name, programScope), + ); + if (gone.length === 0) return; + const names = gone.map((/** @type {any} */ s) => s.exported?.name ?? s.local.name); + const components = [ + ...new Set( + gone.map((/** @type {any} */ s) => dropped.get(s.local.name)?.component), + ), + ]; + const message = `${names.join(", ")} (removed with ${components.join(", ")} in 0.2, no Astryx counterpart) ${names.length === 1 ? "is" : "are"} no longer re-exported from here; modules importing ${names.length === 1 ? "it" : "them"} from here need a type of their own.`; + const rest = specifiers.filter((/** @type {any} */ s) => !gone.includes(s)); + if (rest.length > 0) { + node.specifiers = rest; + addTodo(j, path, message); + return; + } + node.comments = [ + ...(node.comments ?? []), + j.commentLine(` ${TODO_TAG}: ${message}`, true, false), + ]; + pruneKeepingComments(j, root, path); + }); +} + /** * @param {{source: string, path: string}} file * @param {{jscodeshift: any}} api * @param {{flags: {packages: Map<string, string>, touched: Set<string>}}} ctx */ export default function transform(file, api, ctx) { - if (!file.source.includes(UIC)) return undefined; + const direct = file.source.includes(UIC); + const viaLocal = importsLocalLegacy(api.jscodeshift, ctx, file.path, file.source); + if (!direct && !viaLocal) return undefined; const j = api.jscodeshift; + // Registers the wrappers this module defines, for the report. + if (direct && ctx.source) localExports(j, ctx, file.path); const root = j(file.source); const isTS = /\.[cm]?tsx?$/.test(file.path); const taken = takenNames(j, root); @@ -302,6 +416,13 @@ export default function transform(file, api, ctx) { const plans = []; /** @type {any[]} */ const extra = []; + /** + * Local bindings of removed 0.1 types whose import was dropped: a local + * `export type { X }` of one goes with it (see dropLocalReexports). + * + * @type {Map<string, {imported: string, component: string}>} + */ + const droppedTypes = new Map(); /** * The local name to use for `name` from `@lablup/ui-common/<subpath>`, @@ -328,10 +449,69 @@ export default function transform(file, api, ctx) { return local; }; + /** + * An import from a project module that hands 0.1 components on (a barrel + * the codemod rewrites under the 0.1 names): its components get the same + * element rewrite as a direct import, and the import itself stays. Its + * removed types go, as from ui-common. A wrapper's import is only counted. + * + * @param {any} path + * @param {string} source + */ + const localImport = (path, source) => { + const target = ctx.resolveImport?.(file.path, source); + const entries = target ? localExports(j, ctx, target) : null; + if (!entries || entries.size === 0) return; + const declIsType = path.node.importKind === "type"; + const kept = []; + for (const spec of path.node.specifiers ?? []) { + const name = + spec.type === "ImportSpecifier" + ? spec.imported.name + : spec.type === "ImportDefaultSpecifier" + ? "default" + : null; + const entry = name == null ? undefined : entries.get(name); + if (entry?.kind === "type" && entry.removed) { + touched = true; + addTodo( + j, + path, + `type ${entry.type} was removed with ${entry.component} in 0.2 and has no Astryx counterpart; ${source} no longer exports it.`, + ); + droppedTypes.set(spec.local.name, { + imported: entry.type, + component: entry.component, + }); + continue; + } + kept.push(spec); + if (entry?.kind === "wrapper") registerWrapper(ctx, entry, file.path); + if (entry?.kind !== "component") continue; + if (declIsType || spec.importKind === "type") continue; + touched = true; + ctx.flags.touched.add(entry.component); + bindings.set(spec.local.name, { + component: entry.component, + entry: REMOVED.get(entry.component), + local: spec.local.name, + jsxCount: 0, + valueRefs: 0, + add: null, + via: path, + }); + } + if (kept.length > 0) path.node.specifiers = kept; + else pruneKeepingComments(j, root, path); + }; + root.find(j.ImportDeclaration).forEach((/** @type {any} */ path) => { const source = path.node.source.value; const from = typeof source === "string" ? classify(source) : null; - if (!from) return; + if (!from) { + if (viaLocal && typeof source === "string") localImport(path, source); + return; + } const specifiers = path.node.specifiers ?? []; if (specifiers.length === 0) return; const declIsType = path.node.importKind === "type"; @@ -413,6 +593,7 @@ export default function transform(file, api, ctx) { path, `type ${imported} was removed with ${resolved.component} in 0.2 and has no Astryx counterpart.`, ); + droppedTypes.set(local, { imported, component: resolved.component }); continue; } let finalLocal = local; @@ -430,16 +611,54 @@ export default function transform(file, api, ctx) { plans.push({ path, keep, adds }); }); + /** + * A type re-export from a project module (`export type { X }` from `./DataTable`) + * of a type that module no longer exports (its own import of X from ui-common + * was dropped): drop it too. + * + * @param {any} path + * @param {string} source + */ + const localReexport = (path, source) => { + const target = ctx.resolveImport?.(file.path, source); + const entries = target ? localExports(j, ctx, target) : null; + if (!entries || entries.size === 0) return; + const specifiers = path.node.specifiers ?? []; + const gone = specifiers.filter( + (/** @type {any} */ s) => + s.type === "ExportSpecifier" && isRemovedType(entries.get(s.local?.name)), + ); + if (gone.length === 0) return; + touched = true; + const names = gone.map((/** @type {any} */ s) => s.exported.name); + const message = `${names.join(", ")} ${names.length === 1 ? "is" : "are"} no longer re-exported: ${source} does not export ${names.length === 1 ? "it" : "them"} any more (removed in 0.2, no Astryx counterpart).`; + const rest = specifiers.filter((/** @type {any} */ s) => !gone.includes(s)); + if (rest.length > 0) { + path.node.specifiers = rest; + addTodo(j, path, message); + } else { + path.node.comments = [ + ...(path.node.comments ?? []), + j.commentLine(` ${TODO_TAG}: ${message}`, true, false), + ]; + pruneKeepingComments(j, root, path); + } + }; + // Re-exports: `export { Select } from "@lablup/ui-common"` keeps its name. root.find(j.ExportNamedDeclaration).forEach((/** @type {any} */ path) => { const source = path.node.source?.value; const from = typeof source === "string" ? classify(source) : null; - if (!from) return; + if (!from) { + if (viaLocal && typeof source === "string") localReexport(path, source); + return; + } const declIsType = path.node.exportKind === "type"; const keep = []; /** @type {Map<string, any[]>} */ const moved = new Map(); let reexportedComponent = false; + let droppedSpecs = 0; for (const spec of path.node.specifiers ?? []) { const localName = spec.local?.name ?? spec.exported.name; const imported = @@ -467,6 +686,7 @@ export default function transform(file, api, ctx) { `type ${imported} was removed with ${resolved.component} in 0.2 and has no Astryx counterpart.`, ); touched = true; + droppedSpecs++; continue; } target = resolved.to; @@ -490,7 +710,13 @@ export default function transform(file, api, ctx) { ); moved.set(key, list); } - if (moved.size === 0) return; + if (moved.size === 0) { + // Only removed types: they go, and the TODO stays where they stood. + if (droppedSpecs === 0) return; + if (keep.length > 0) path.node.specifiers = keep; + else pruneKeepingComments(j, root, path); + return; + } const decls = [...moved.entries()].map(([key, specs]) => { const [kind, targetSource] = key.split("\u0000"); const decl = j.exportNamedDeclaration(null, specs, j.stringLiteral(targetSource)); @@ -500,7 +726,7 @@ export default function transform(file, api, ctx) { if (reexportedComponent) { decls[0].comments = [ j.commentLine( - " TODO(ui-common-upgrade): re-exported under the 0.1 name, but the component is Astryx's now; modules importing it from here still pass 0.1 props and need the same migration.", + " TODO(ui-common-upgrade): re-exported under the 0.1 name, but the component is Astryx's now. The upgrade migrated the elements of it in the modules it scanned that import it from here; any other importer still passes 0.1 props.", true, false, ), @@ -514,6 +740,8 @@ export default function transform(file, api, ctx) { } }); + if (droppedTypes.size > 0) dropLocalReexports(j, root, droppedTypes, programScope); + root.find(j.ExportAllDeclaration).forEach((/** @type {any} */ path) => { const source = path.node.source?.value; if (typeof source !== "string" || !classify(source)) return; @@ -521,7 +749,7 @@ export default function transform(file, api, ctx) { addTodo( j, path, - `\`export *\` from ${source} now re-exports Astryx's Badge, Button, Tooltip, … under the 0.1 names; modules importing them from here still pass 0.1 props.`, + `\`export *\` from ${source} now re-exports Astryx's Badge, Button, Tooltip, … under the 0.1 names. The upgrade migrated the elements of them in the modules it scanned that import them from here; any other importer still passes 0.1 props.`, ); }); @@ -598,6 +826,29 @@ export default function transform(file, api, ctx) { }); } + // A local-barrel import whose every element became another component + // (Button → IconButton) is unused now. + for (const [local, binding] of bindings) { + if (!binding.via || binding.via.pruned) continue; + let used = false; + root.findJSXElements(local).forEach((/** @type {any} */ p) => { + if (isModuleBinding(p, local, programScope)) used = true; + }); + root.find(j.Identifier, { name: local }).forEach((/** @type {any} */ p) => { + if (p.node.type === "JSXIdentifier" || !isReference(p)) return; + if (isModuleBinding(p, local, programScope)) used = true; + }); + if (used) continue; + const node = binding.via.node; + node.specifiers = (node.specifiers ?? []).filter( + (/** @type {any} */ s) => s.local?.name !== local, + ); + if (node.specifiers.length === 0) { + pruneKeepingComments(j, root, binding.via); + binding.via.pruned = true; + } + } + for (const [from, to] of renames) renameReferences(j, root, from, to, programScope); // Imports: the planned moves, minus a Card import every BaseCard outgrew. @@ -605,6 +856,7 @@ export default function transform(file, api, ctx) { [...bindings.values()] .filter( (b) => + b.add != null && b.component === "BaseCard" && b.jsxCount === 0 && b.valueRefs === 0 && @@ -629,6 +881,7 @@ export default function transform(file, api, ctx) { const program = root.find(j.Program).get().node; const first = program.body[0]; if (first) first.comments = [...comments, ...(first.comments ?? [])]; + else keepCommentsInEmptyModule(j, program, comments); } } } diff --git a/codemods/0.2/elements.mjs b/packages/cli/codemods/0.2/elements.mjs similarity index 100% rename from codemods/0.2/elements.mjs rename to packages/cli/codemods/0.2/elements.mjs diff --git a/codemods/0.2/index.mjs b/packages/cli/codemods/0.2/index.mjs similarity index 86% rename from codemods/0.2/index.mjs rename to packages/cli/codemods/0.2/index.mjs index 51ea089..b7260eb 100644 --- a/codemods/0.2/index.mjs +++ b/packages/cli/codemods/0.2/index.mjs @@ -3,14 +3,17 @@ * `migration/0.1-to-0.2.json` (see ./map.mjs). */ import transformComponents, { meta as componentsMeta } from "./components.mjs"; +import { wrapperFindings } from "./local-modules.mjs"; +import { ensureTheme } from "./theme.mjs"; import { LAB_CSS, LAB_PACKAGE, REMOVED, UIC } from "./map.mjs"; import { transformPackageJson } from "./package-json.mjs"; -import { CATEGORIES, scanFile } from "./scan.mjs"; +import { CATEGORIES, prepareScan, scanFile } from "./scan.mjs"; import { cssMeta, jsMeta, transformScriptImports, transformStylesheet, + wireStylesheets, } from "./stylesheets.mjs"; /** @@ -52,8 +55,14 @@ export default { { ...jsMeta, run: transformScriptImports, parse: true }, { ...cssMeta, run: transformStylesheet, parse: false }, ], + afterTransforms: (ctx, api) => { + wireStylesheets(ctx, api); + ensureTheme(ctx, api); + }, packageJson: transformPackageJson, + prepareScan, scan: scanFile, + findings: wrapperFindings, categories: CATEGORIES, notes, }; diff --git a/codemods/0.2/legacy-classes.json b/packages/cli/codemods/0.2/legacy-classes.json similarity index 100% rename from codemods/0.2/legacy-classes.json rename to packages/cli/codemods/0.2/legacy-classes.json diff --git a/packages/cli/codemods/0.2/local-modules.mjs b/packages/cli/codemods/0.2/local-modules.mjs new file mode 100644 index 0000000..a13d0fb --- /dev/null +++ b/packages/cli/codemods/0.2/local-modules.mjs @@ -0,0 +1,510 @@ +/** + * The project's own modules that hand ui-common 0.1 components on: barrels + * (`export { Button } from "@lablup/ui-common/components/Button"`, + * `export * from …`, an import then `export { … }`, `export const X = Y`) and + * wrappers (an exported component that renders one). + * + * The components codemod rewrites such a barrel in place and keeps the 0.1 + * public name, so an import of `Select` from the barrel (`@/components/common`) now gets + * Astryx's Selector. Its call sites still pass 0.1 props: this module tells + * the codemod which local imports stand for which 0.1 component, so the same + * element rewrite runs on them. A wrapper is not a re-export: its props are + * its own, so its call sites are left alone and the wrapper is reported. + * + * Every module is read as it was before this run (the barrel itself is being + * rewritten), through the runner's `ctx.source` and `ctx.resolveImport`. + */ +import { REMOVED, REMOVED_TYPES, UIC } from "./map.mjs"; + +/** + * @typedef {{kind: 'component', component: string} + * | {kind: 'type', component: string, type: string, removed: boolean} + * | {kind: 'wrapper', components: string[], file: string, line: number, name: string}} LocalExport + * + * @typedef {object} LocalModuleContext + * @property {(path: string) => string | null} [source] a file's content before this run + * @property {(from: string, specifier: string, options?: {probe?: boolean}) => string | null} [resolveImport] + */ + +/** @type {WeakMap<object, {modules: Map<string, Map<string, LocalExport>>, wrappers: Map<string, {def: LocalExport & {kind: 'wrapper'}, importers: Set<string>}>}>} */ +const caches = new WeakMap(); + +/** @param {object} ctx */ +function cacheFor(ctx) { + let cache = caches.get(ctx); + if (!cache) { + cache = { modules: new Map(), wrappers: new Map() }; + caches.set(ctx, cache); + } + return cache; +} + +/** + * What a ui-common specifier's name stands for in 0.1. + * + * @param {string} source + * @param {string} name imported name (`default` for a default import) + * @returns {LocalExport | null} + */ +function uicName(source, name) { + let imported = name; + if (source !== UIC) { + const match = /^@lablup\/ui-common\/components\/([A-Za-z]+)$/.exec(source); + if (!match) return null; + if (name === "default") imported = match[1]; + } + if (REMOVED.has(imported)) return { kind: "component", component: imported }; + const type = REMOVED_TYPES.get(imported); + if (type) + return { + kind: "type", + component: type.component, + type: imported, + removed: type.to == null, + }; + return null; +} + +/** + * Every removed name a ui-common specifier offers, for `export *`. + * + * @param {string} source + * @returns {Map<string, LocalExport>} + */ +function uicStar(source) { + /** @type {Map<string, LocalExport>} */ + const out = new Map(); + const match = /^@lablup\/ui-common\/components\/([A-Za-z]+)$/.exec(source); + if (source !== UIC && !match) return out; + for (const [name, removed] of REMOVED) { + if (match && name !== match[1]) continue; + out.set(name, { kind: "component", component: name }); + for (const [type, to] of Object.entries(removed.types)) { + out.set(type, { kind: "type", component: name, type, removed: to == null }); + } + } + return out; +} + +/** @param {string} source */ +const isUic = (source) => source === UIC || source.startsWith(`${UIC}/components/`); + +/** + * Import sources a file names, without parsing it. + * + * @param {string} source + */ +function importSources(source) { + const out = new Set(); + for (const m of source.matchAll(/\b(?:from|import)\s*["']([^"']+)["']/g)) + out.add(m[1]); + return out; +} + +/** Strip parentheses and TS casts. @param {any} node */ +function unwrap(node) { + let n = node; + while ( + n && + (n.type === "TSAsExpression" || + n.type === "TSSatisfiesExpression" || + n.type === "TSNonNullExpression" || + n.type === "ParenthesizedExpression") + ) + n = n.expression; + return n; +} + +/** + * The function a component declaration defines: a function declaration, or + * the function a `const` holds, through `forwardRef(…)` / `memo(…)`, or the + * declaration `memo(Name)` names. + * + * @param {any} decl + * @param {Map<string, any>} [declarations] the module's top-level declarations + * @param {number} [depth] + */ +function componentFunction(decl, declarations = new Map(), depth = 0) { + if (decl.type === "FunctionDeclaration") return decl; + let init = decl.type === "VariableDeclarator" ? unwrap(decl.init) : null; + while (init?.type === "CallExpression") { + init = unwrap( + init.arguments.find( + (/** @type {any} */ a) => + a.type === "ArrowFunctionExpression" || + a.type === "FunctionExpression" || + a.type === "CallExpression" || + a.type === "Identifier", + ), + ); + } + // `memo(EmptyStateComponent)`: the component is declared on its own. + if (init?.type === "Identifier" && depth < 3) { + const target = declarations.get(init.name); + return target ? componentFunction(target, declarations, depth + 1) : null; + } + return init?.type === "ArrowFunctionExpression" || init?.type === "FunctionExpression" + ? init + : null; +} + +/** + * The names a component's props parameter binds: the props object, or each + * destructured prop and the rest. + * + * @param {any} param + * @returns {Set<string>} + */ +function paramNames(param) { + const out = new Set(); + const p = param?.type === "AssignmentPattern" ? param.left : param; + if (p?.type === "Identifier") out.add(p.name); + else if (p?.type === "ObjectPattern") { + for (const prop of p.properties) { + if (prop.type === "RestElement" && prop.argument.type === "Identifier") + out.add(prop.argument.name); + else if (prop.value?.type === "Identifier") out.add(prop.value.name); + else if ( + prop.value?.type === "AssignmentPattern" && + prop.value.left.type === "Identifier" + ) + out.add(prop.value.left.name); + } + } + return out; +} + +/** + * The 0.1 components (and removed types) a project module exports, by + * exported name. Empty for a module that hands on none. + * + * @param {any} j jscodeshift + * @param {LocalModuleContext} ctx + * @param {string} file absolute + * @param {Set<string>} [stack] modules being read, against cycles + * @returns {Map<string, LocalExport>} + */ +export function localExports(j, ctx, file, stack = new Set()) { + const cache = cacheFor(ctx); + const hit = cache.modules.get(file); + if (hit) return hit; + /** @type {Map<string, LocalExport>} */ + const out = new Map(); + const source = ctx.source?.(file); + if (source == null || stack.has(file) || !/\bexport\b/.test(source)) { + if (!stack.has(file)) cache.modules.set(file, out); + return out; + } + // Cheap bail-out: nothing from ui-common, directly or through a module the + // resolver finds (relative, tsconfig `paths` or `baseUrl` alike). A probe, + // since the text scan also matches specifiers in comments. + /** @type {Map<string, string>} */ + const targets = new Map(); + let worthReading = false; + for (const s of importSources(source)) { + const target = isUic(s) ? null : ctx.resolveImport?.(file, s, { probe: true }); + if (target) targets.set(s, target); + if (target || isUic(s)) worthReading = true; + } + if (!worthReading) { + cache.modules.set(file, out); + return out; + } + stack.add(file); + let root; + try { + // Parse by the module's own extension, not the importer's. + const parser = /\.[cm]?tsx?$/.test(file) ? "tsx" : "babel"; + root = (typeof j.withParser === "function" ? j.withParser(parser) : j)(source); + } catch { + stack.delete(file); + cache.modules.set(file, out); + return out; + } + + /** @param {string} specifier */ + const moduleExports = (specifier) => { + const target = targets.get(specifier) ?? ctx.resolveImport?.(file, specifier); + return target ? localExports(j, ctx, target, stack) : new Map(); + }; + + // Local bindings that stand for a 0.1 component or type. + /** @type {Map<string, LocalExport>} */ + const bindings = new Map(); + root.find(j.ImportDeclaration).forEach((/** @type {any} */ path) => { + const from = path.node.source.value; + if (typeof from !== "string") return; + for (const spec of path.node.specifiers ?? []) { + if (spec.type === "ImportNamespaceSpecifier") continue; + const name = + spec.type === "ImportDefaultSpecifier" ? "default" : spec.imported.name; + const entry = isUic(from) ? uicName(from, name) : moduleExports(from).get(name); + if (entry) bindings.set(spec.local.name, entry); + } + }); + + // Top-level declarations, for `export { X }` of a local and for wrappers. + /** @type {Map<string, any>} */ + const declarations = new Map(); + /** @type {Map<string, any>} */ + const typeDeclarations = new Map(); + for (const statement of root.find(j.Program).get().node.body) { + const node = + statement.type === "ExportNamedDeclaration" || + statement.type === "ExportDefaultDeclaration" + ? statement.declaration + : statement; + if (!node) continue; + if ( + (node.type === "FunctionDeclaration" || node.type === "ClassDeclaration") && + node.id + ) { + declarations.set(node.id.name, node); + } else if (node.type === "VariableDeclaration") { + for (const d of node.declarations) { + if (d.id?.type === "Identifier") declarations.set(d.id.name, d); + } + } else if ( + (node.type === "TSInterfaceDeclaration" || + node.type === "TSTypeAliasDeclaration") && + node.id + ) { + typeDeclarations.set(node.id.name, node); + } + } + + /** + * The 0.1 components whose props type a props parameter's type is built + * on: `SelectProps`, `Omit<DataTableProps<T>, …>`, an intersection with + * one, or a local interface or type alias that extends or aliases one. + * + * @param {any} param + * @returns {Set<string>} + */ + const propsTypeComponents = (param) => { + const out = new Set(); + const seen = new Set(); + const WRAPPING = new Set(["Omit", "Partial", "Pick", "Readonly", "Required"]); + /** + * @param {string} name + * @param {any} args type arguments + */ + const reference = (name, args) => { + const bound = bindings.get(name); + if (bound?.kind === "type" && /Props$/.test(bound.type)) out.add(bound.component); + if (WRAPPING.has(name)) visit(args?.params?.[0]); + const local = typeDeclarations.get(name); + if (local && !seen.has(name)) { + seen.add(name); + if (local.type === "TSTypeAliasDeclaration") visit(local.typeAnnotation); + for (const heritage of local.extends ?? []) { + const id = heritage.expression; + if (id?.type === "Identifier") + reference(id.name, heritage.typeParameters ?? heritage.typeArguments); + } + } + }; + /** Only what the props type is made of, not the types of its members. @param {any} t */ + const visit = (t) => { + if (!t) return; + switch (t.type) { + case "TSTypeAnnotation": + case "TSParenthesizedType": + visit(t.typeAnnotation); + break; + case "TSIntersectionType": + case "TSUnionType": + t.types.forEach(visit); + break; + case "TSTypeReference": + if (t.typeName?.type === "Identifier") + reference(t.typeName.name, t.typeParameters ?? t.typeArguments); + break; + default: + } + }; + const p = param?.type === "AssignmentPattern" ? param.left : param; + visit(p?.typeAnnotation); + return out; + }; + + /** + * What exporting local `name` hands on: an aliased 0.1 binding, or a + * wrapper that renders one. + * + * @param {string} name + * @param {string} exported + * @returns {LocalExport | null} + */ + const describeLocal = (name, exported) => { + const bound = bindings.get(name); + if (bound) return bound; + const decl = declarations.get(name); + if (!decl) return null; + if (decl.type === "VariableDeclarator" && decl.init?.type === "Identifier") { + return bindings.get(decl.init.name) ?? describeLocal(decl.init.name, exported); + } + const fn = componentFunction(decl, declarations); + if (!fn) return null; + const params = paramNames(fn.params[0]); + // Its callers pass 0.1 props when its props type is built on a 0.1 one… + const typed = propsTypeComponents(fn.params[0]); + /** @param {any} expression */ + const fromParams = (expression) => { + const e = unwrap(expression); + return e?.type === "Identifier" && params.has(e.name); + }; + const rendered = new Set(); + j(fn) + .find(j.JSXElement) + .forEach((/** @type {any} */ p) => { + const tag = p.node.openingElement.name; + if (tag.type !== "JSXIdentifier") return; + const entry = bindings.get(tag.name); + const components = + entry?.kind === "component" + ? [entry.component] + : entry?.kind === "wrapper" + ? entry.components + : []; + if (components.length === 0) return; + // …or when it spreads its own props (or their rest) into one. + const spreads = (p.node.openingElement.attributes ?? []).some( + (/** @type {any} */ a) => + a.type === "JSXSpreadAttribute" && fromParams(a.argument), + ); + if (spreads || components.some((c) => typed.has(c))) + for (const c of components) rendered.add(c); + }); + if (rendered.size === 0) return null; + return { + kind: "wrapper", + components: [...rendered].sort(), + file, + line: + decl.loc?.start.line ?? + (typeof decl.start === "number" + ? source.slice(0, decl.start).split("\n").length + : 0), + name: exported === "default" ? name : exported, + }; + }; + + root.find(j.ExportNamedDeclaration).forEach((/** @type {any} */ path) => { + const node = path.node; + const from = node.source?.value; + if (typeof from === "string") { + const theirs = isUic(from) ? null : moduleExports(from); + for (const spec of node.specifiers ?? []) { + if (spec.type !== "ExportSpecifier") continue; + const name = spec.local?.name ?? spec.exported.name; + const entry = theirs ? theirs.get(name) : uicName(from, name); + if (entry) out.set(spec.exported.name, entry); + } + return; + } + if (node.declaration) { + const decl = node.declaration; + const names = + decl.type === "VariableDeclaration" + ? decl.declarations + .filter((/** @type {any} */ d) => d.id?.type === "Identifier") + .map((/** @type {any} */ d) => d.id.name) + : decl.id + ? [decl.id.name] + : []; + for (const name of names) { + const entry = describeLocal(name, name); + if (entry) out.set(name, entry); + } + return; + } + for (const spec of node.specifiers ?? []) { + if (spec.type !== "ExportSpecifier") continue; + const entry = describeLocal(spec.local.name, spec.exported.name); + if (entry) out.set(spec.exported.name, entry); + } + }); + + root.find(j.ExportDefaultDeclaration).forEach((/** @type {any} */ path) => { + const decl = path.node.declaration; + let entry = null; + if (decl.type === "Identifier") entry = describeLocal(decl.name, "default"); + else if (decl.id?.name) entry = describeLocal(decl.id.name, "default"); + if (entry) out.set("default", entry); + }); + + root.find(j.ExportAllDeclaration).forEach((/** @type {any} */ path) => { + const from = path.node.source?.value; + if (typeof from !== "string" || path.node.exported) return; + const theirs = isUic(from) ? uicStar(from) : moduleExports(from); + for (const [name, entry] of theirs) { + if (name !== "default" && !out.has(name)) out.set(name, entry); + } + }); + + stack.delete(file); + cache.modules.set(file, out); + for (const entry of out.values()) { + if (entry.kind === "wrapper") registerWrapper(ctx, entry); + } + return out; +} + +/** + * @param {object} ctx + * @param {LocalExport & {kind: 'wrapper'}} def + * @param {string} [importer] + */ +export function registerWrapper(ctx, def, importer) { + const { wrappers } = cacheFor(ctx); + const key = `${def.file}\u0000${def.name}`; + const known = wrappers.get(key) ?? { def, importers: new Set() }; + if (importer) known.importers.add(importer); + wrappers.set(key, known); +} + +/** + * Whether a file imports anything from a project module that hands on a 0.1 + * component or type, without parsing the file itself. + * + * @param {any} j + * @param {LocalModuleContext} ctx + * @param {string} file + * @param {string} source + */ +export function importsLocalLegacy(j, ctx, file, source) { + if (!ctx.resolveImport) return false; + for (const specifier of importSources(source)) { + if (specifier.startsWith(UIC)) continue; + const target = ctx.resolveImport(file, specifier); + if (target && localExports(j, ctx, target).size > 0) return true; + } + return false; +} + +/** + * The wrappers the run met, for the report: one finding per wrapper. + * + * @param {object} ctx + * @param {(file: string) => string} rel + */ +export function wrapperFindings(ctx, rel) { + const cache = caches.get(ctx); + if (!cache) return []; + return [...cache.wrappers.values()] + .sort((a, b) => a.def.file.localeCompare(b.def.file) || a.def.line - b.def.line) + .map(({ def, importers }) => { + const targets = def.components.map((c) => { + const to = REMOVED.get(c)?.to; + return to && to !== c ? `${c} (Astryx ${to})` : c; + }); + return { + category: "local-wrapper", + file: rel(def.file), + line: def.line, + text: def.name, + detail: `local wrapper around ${targets.join(", ")}: review its props.${importers.size > 0 ? ` Imported by ${importers.size} scanned module${importers.size === 1 ? "" : "s"}, whose props were not migrated.` : ""}`, + }; + }); +} diff --git a/codemods/0.2/map.mjs b/packages/cli/codemods/0.2/map.mjs similarity index 94% rename from codemods/0.2/map.mjs rename to packages/cli/codemods/0.2/map.mjs index d800c30..a754b7f 100644 --- a/codemods/0.2/map.mjs +++ b/packages/cli/codemods/0.2/map.mjs @@ -6,10 +6,15 @@ */ import { join } from "node:path"; -import { ownPackageJson, PACKAGE_ROOT, readJson } from "../../cli/paths.mjs"; +import { + CLI_ROOT, + readJson, + targetUiCommonRoot, + uiCommonPackageJson, +} from "../../cli/paths.mjs"; export const UIC = "@lablup/ui-common"; -export const MIGRATION_FILE = join(PACKAGE_ROOT, "migration", "0.1-to-0.2.json"); +export const MIGRATION_FILE = join(CLI_ROOT, "migration", "0.1-to-0.2.json"); /** * @typedef {object} PropRename @@ -113,11 +118,14 @@ export const STYLESHEETS = { export const LAB_PACKAGE = "@astryxdesign/lab"; export const LAB_CSS = `${UIC}/lab/lab.css`; -/** The StyleX peer range ui-common itself declares. */ +/** The StyleX peer range the target ui-common declares. */ export function stylexPeer() { return { name: "@stylexjs/stylex", - range: ownPackageJson().peerDependencies?.["@stylexjs/stylex"] ?? "^0.19.0", + range: + uiCommonPackageJson(targetUiCommonRoot()).peerDependencies?.[ + "@stylexjs/stylex" + ] ?? "^0.19.0", }; } diff --git a/packages/cli/codemods/0.2/package-json.mjs b/packages/cli/codemods/0.2/package-json.mjs new file mode 100644 index 0000000..45f3729 --- /dev/null +++ b/packages/cli/codemods/0.2/package-json.mjs @@ -0,0 +1,333 @@ +/** + * 0.1 -> 0.2 package.json edits: + * - bump @lablup/ui-common to the target version (keeping `^`/`~`); + * - add @lablup/ui-common-cli, the `ui-common` bin, to devDependencies at + * exactly the target version (0.1 shipped the bin inside ui-common); + * - add the @stylexjs/stylex peer ui-common 0.2 needs, when missing; + * - in a library, drop React 18 from the react / react-dom peer ranges; + * - in a pnpm project, decline the Astryx postinstalls in `allowBuilds`; + * - add @astryxdesign/lab, pinned to the canary ui-common is built against, + * when a Drawer import was moved to `@lablup/ui-common/lab`, and point its + * core peer at ui-common's core with the project's package manager's + * override (../../cli/lab-peer.mjs). + */ +import { applyLabOverride, CORE, detectPackageManager } from "../../cli/lab-peer.mjs"; +import { targetUiCommonRoot, uiCommonPackageJson } from "../../cli/paths.mjs"; +import { coerce, narrowToFloor } from "../../cli/semver.mjs"; +import { LAB_PACKAGE, stylexPeer, UIC } from "./map.mjs"; + +/** The CLI package, released in lockstep with ui-common. */ +export const CLI_PACKAGE = "@lablup/ui-common-cli"; + +const FIELDS = /** @type {const} */ ([ + "dependencies", + "devDependencies", + "peerDependencies", +]); + +/** @param {Record<string, string>} object */ +function isSorted(object) { + const keys = Object.keys(object); + return keys.every((k, i) => i === 0 || keys[i - 1].localeCompare(k) <= 0); +} + +/** + * @param {any} pkg + * @param {string} field + * @param {string} name + * @param {string} range + */ +function addDependency(pkg, field, name, range) { + const current = pkg[field] ?? {}; + const next = { ...current, [name]: range }; + pkg[field] = isSorted(current) + ? Object.fromEntries(Object.entries(next).sort(([a], [b]) => a.localeCompare(b))) + : next; +} + +/** + * @param {string} spec + * @param {string} to + * @returns {{value: string, note?: string} | null} + */ +function bumpSpec(spec, to) { + if (/^(workspace:|link:|file:|npm:|git|https?:)/.test(spec)) return null; + const simple = /^([\^~]?)v?\d+(\.\d+){0,2}(-[0-9A-Za-z.-]+)?$/.exec(spec.trim()); + if (simple) return { value: `${simple[1]}${to}` }; + return { + value: `^${to}`, + note: `the range "${spec}" was replaced with "^${to}"; widen it again if this package must still accept 0.1.`, + }; +} + +/** Packages whose postinstall pnpm 11 must be told to run or skip. */ +export const DECLINED_BUILDS = ["@astryxdesign/core", "@astryxdesign/cli"]; + +/** + * `pnpm-workspace.yaml` with `allowBuilds` declining each of `names` it does + * not decide yet. An entry set to true or false is left as it is; one with + * any other value (pnpm writes "set this to true or false" when it stops an + * install) counts as undecided and is set to false. Comments and every other + * line are kept. + * + * @param {string | null} yaml current text, null when there is no file + * @param {string[]} names + * @returns {{yaml?: string, notes: string[]}} + */ +export function applyAllowBuilds(yaml, names) { + const line = (/** @type {string} */ name, indent = " ") => + `${indent}"${name}": false`; + const why = + "their postinstall only prints an `astryx init` nudge, and pnpm 11 stops the install (ERR_PNPM_IGNORED_BUILDS) until each is allowed or declined"; + if (yaml == null) { + return { + yaml: `allowBuilds:\n${names.map((n) => line(n)).join("\n")}\n`, + notes: [ + `wrote pnpm-workspace.yaml with allowBuilds declining ${names.join(", ")}: ${why}.`, + ], + }; + } + const block = /^allowBuilds:[ \t]*(#.*)?(\r?\n)/m.exec(yaml); + if (!block) { + if (/^allowBuilds\s*:/m.test(yaml)) { + return { + notes: [ + `pnpm-workspace.yaml has an allowBuilds entry this cannot edit; decline ${names.join(", ")} in it: ${why}.`, + ], + }; + } + const base = yaml.replace(/\s*$/, ""); + return { + yaml: `${base}${base ? "\n\n" : ""}allowBuilds:\n${names.map((n) => line(n)).join("\n")}\n`, + notes: [ + `added allowBuilds declining ${names.join(", ")} to pnpm-workspace.yaml: ${why}.`, + ], + }; + } + const at = block.index + block[0].length; + const after = yaml.slice(at); + const end = /^\S/m.exec(after)?.index ?? after.length; + let body = after.slice(0, end); + const indent = /^([ \t]+)\S/m.exec(body)?.[1] ?? " "; + const notes = []; + const added = []; + for (const name of names) { + const esc = name.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&"); + const entry = new RegExp( + `^([ \\t]+["']?${esc}["']?[ \\t]*:[ \\t]*)([^\\r\\n#]*?)([ \\t]*(#.*)?)$`, + "m", + ); + const match = entry.exec(body); + if (!match) { + added.push(name); + continue; + } + const value = match[2].trim().replace(/^["']|["']$/g, ""); + if (value === "true" || value === "false") continue; + body = body.replace(entry, (_m, head, _v, tail) => `${head}false${tail}`); + notes.push( + `pnpm-workspace.yaml allowBuilds["${name}"] was "${match[2].trim()}", which pnpm does not accept; set it to false.`, + ); + } + if (added.length > 0) { + const trimmed = body.replace(/\s*$/, ""); + const rest = body.slice(trimmed.length); + body = `${trimmed}${trimmed ? "\n" : ""}${added.map((n) => line(n, indent)).join("\n")}${rest.includes("\n") ? rest : "\n"}`; + notes.push( + `added ${added.join(", ")} to allowBuilds in pnpm-workspace.yaml, declined: ${why}.`, + ); + } + const next = `${yaml.slice(0, at)}${body}${after.slice(end)}`; + return next === yaml ? { notes } : { yaml: next, notes }; +} + +/** React's floor when the target ui-common's peer cannot be read. */ +const REACT_FLOOR = "19.2.0"; + +/** + * A React range narrowed to `floor` and up (`>=18 <21 || ^22` → + * `>=19.2.0 <21 || ^22`); null when it admits no version that high, the + * range itself when it already starts there or is not a version range. + * + * @param {string} range + * @param {string} floor + * @returns {string | null} + */ +export function narrowReactRange(range, floor) { + if (/^(workspace:|link:|file:|npm:|catalog:)/.test(range.trim())) return range; + return narrowToFloor(range, floor); +} + +/** + * Point the lab canary's core peer at ui-common's core: `overrides` in + * package.json for npm (the project's own, when it is the install root), + * `overrides` in pnpm-workspace.yaml for pnpm, a note otherwise. + * + * @param {any} pkg parsed package.json, edited in place + * @param {{projectDir?: string, note: (message: string) => void, editFile?: (path: string, edit: (current: string | null) => string | undefined) => void}} ctx + */ +function addLabOverride(pkg, ctx) { + const pin = uiCommonPackageJson(targetUiCommonRoot(ctx.projectDir)).dependencies?.[ + CORE + ]; + if (!pin || !ctx.projectDir || !ctx.editFile) return; + const { manager, root, workspaceYaml } = detectPackageManager(ctx.projectDir, pkg); + if (manager === "npm" && root !== ctx.projectDir) { + // npm reads overrides from the install root's package.json only. + const { note } = applyLabOverride({ manager: null, pin }); + ctx.note(`npm installs from ${root}, not this package: ${note}`); + return; + } + if (manager === "pnpm" && workspaceYaml) { + const yamlFile = workspaceYaml; + ctx.editFile(yamlFile, (current) => { + const edit = applyLabOverride({ manager, pin, workspaceYaml: current }); + if (edit.note) ctx.note(edit.note); + return edit.workspaceYaml; + }); + return; + } + const { note } = applyLabOverride({ manager, pin, pkg }); + ctx.note(note); +} + +/** + * @param {string} text package.json source + * @param {{to: string, flags: {packages: Map<string, string>}, note: (message: string) => void}} ctx + */ +export function transformPackageJson(text, ctx) { + const pkg = JSON.parse(text); + const indent = /^([ \t]+)"/m.exec(text)?.[1] ?? " "; + const fields = FIELDS.filter((f) => pkg[f]?.[UIC] != null); + if (fields.length === 0) return undefined; + + for (const field of fields) { + const spec = pkg[field][UIC]; + const bumped = bumpSpec(spec, ctx.to); + if (!bumped) { + ctx.note(`${field}["${UIC}"] is "${spec}"; not a version, so it was left alone.`); + continue; + } + if (bumped.value !== spec) { + pkg[field][UIC] = bumped.value; + ctx.note(`${field}["${UIC}"]: "${spec}" → "${bumped.value}".`); + } + if (bumped.note) ctx.note(`${field}["${UIC}"]: ${bumped.note}`); + } + + const has = (/** @type {string} */ name) => + FIELDS.some((f) => pkg[f]?.[name] != null); + + // The bin moved out of ui-common into its own package. It is a dev-time + // tool, pinned exactly: it upgrades to and reads the ui-common of its own + // version. + const cliField = FIELDS.find((f) => pkg[f]?.[CLI_PACKAGE] != null); + if (!cliField) { + addDependency(pkg, "devDependencies", CLI_PACKAGE, ctx.to); + ctx.note( + `added ${CLI_PACKAGE} ${ctx.to} to devDependencies: the \`ui-common\` bin ships in its own package since 0.2, released at the same version as ${UIC}.`, + ); + } else { + const spec = pkg[cliField][CLI_PACKAGE]; + const bumped = bumpSpec(spec, ctx.to); + if (!bumped) { + ctx.note( + `${cliField}["${CLI_PACKAGE}"] is "${spec}"; not a version, so it was left alone.`, + ); + } else if (spec !== ctx.to) { + pkg[cliField][CLI_PACKAGE] = ctx.to; + ctx.note(`${cliField}["${CLI_PACKAGE}"]: "${spec}" → "${ctx.to}".`); + } + } + // A library that takes ui-common as a peer takes StyleX as a peer too; + // an application depends on it. + const library = fields.includes("peerDependencies"); + + /** + * Add `name` where this project needs it, and return the fields it went + * into. A library needs it as a peer and, when it develops against + * ui-common, as a devDependency: each is checked on its own, since one does + * not stand in for the other. An application needs it anywhere. + * + * @param {string} name + * @param {string} range + */ + const ensure = (name, range) => { + /** @type {string[]} */ + const wanted = library + ? [ + ...(pkg.peerDependencies?.[name] == null && pkg.dependencies?.[name] == null + ? ["peerDependencies"] + : []), + ...(fields.includes("devDependencies") && pkg.devDependencies?.[name] == null + ? ["devDependencies"] + : []), + ] + : has(name) + ? [] + : [fields.includes("dependencies") ? "dependencies" : fields[0]]; + for (const field of wanted) addDependency(pkg, field, name, range); + return wanted; + }; + + const stylex = stylexPeer(); + const stylexAdded = ensure(stylex.name, stylex.range); + if (stylexAdded.length > 0) + ctx.note(`added ${stylex.name} ${stylex.range} to ${stylexAdded.join(" and ")}.`); + + // 0.2 needs React 19.2: a library that still accepts older React in its + // peers would install beside such an app and break there. + const reactPeers = + uiCommonPackageJson(targetUiCommonRoot(ctx.projectDir)).peerDependencies ?? {}; + for (const name of ["react", "react-dom"]) { + const floor = coerce(reactPeers[name] ?? "") ?? REACT_FLOOR; + const spec = pkg.peerDependencies?.[name]; + if (library && typeof spec === "string") { + const next = narrowReactRange(spec, floor); + if (next == null) { + ctx.note( + `peerDependencies["${name}"] is "${spec}", which admits no React ${floor} or later; ${UIC} 0.2 needs it, so move the range up by hand.`, + ); + } else if (next !== spec) { + pkg.peerDependencies[name] = next; + ctx.note( + `peerDependencies["${name}"]: "${spec}" → "${next}": ${UIC} 0.2 needs React ${floor} or later.`, + ); + } + } + const own = pkg.dependencies?.[name] ?? pkg.devDependencies?.[name]; + if (typeof own === "string" && narrowReactRange(own, floor) !== own) { + ctx.note( + `${pkg.dependencies?.[name] ? "dependencies" : "devDependencies"}["${name}"] is "${own}": ${UIC} 0.2 needs React ${floor} or later; upgrade React too.`, + ); + } + } + + // pnpm 11 refuses to install until the Astryx postinstalls are decided. + if (ctx.projectDir && ctx.editFile) { + const { manager, workspaceYaml } = detectPackageManager(ctx.projectDir, pkg); + if (manager === "pnpm" && workspaceYaml) { + ctx.editFile(workspaceYaml, (current) => { + const edit = applyAllowBuilds(current, DECLINED_BUILDS); + for (const note of edit.notes) ctx.note(note); + return edit.yaml; + }); + } + } + + // Packages the map says a moved component needs (the lab Drawer). + for (const [lab, range] of ctx.flags.packages) { + const added = ensure(lab, range); + if (added.length === 0) continue; + const field = added.join(" and "); + ctx.note( + lab === LAB_PACKAGE + ? `added ${lab} ${range} to ${field}: a Drawer moved to ${UIC}/lab, and ui-common pins the lab canary exactly.` + : `added ${lab} ${range} to ${field}.`, + ); + if (lab === LAB_PACKAGE) addLabOverride(pkg, ctx); + } + + const out = `${JSON.stringify(pkg, null, indent)}${text.endsWith("\n") ? "\n" : ""}`; + return out === text ? undefined : out; +} diff --git a/codemods/0.2/scan.mjs b/packages/cli/codemods/0.2/scan.mjs similarity index 68% rename from codemods/0.2/scan.mjs rename to packages/cli/codemods/0.2/scan.mjs index 70b52dd..cd247b6 100644 --- a/codemods/0.2/scan.mjs +++ b/packages/cli/codemods/0.2/scan.mjs @@ -16,8 +16,9 @@ import { fileURLToPath } from "node:url"; import postcss from "postcss"; import selectorParser from "postcss-selector-parser"; -import { dependencyDir } from "../../cli/paths.mjs"; +import { dependencyDir, targetUiCommonRoot } from "../../cli/paths.mjs"; import { keptClassRename } from "./map.mjs"; +import { scanTheme } from "./theme.mjs"; const legacy = JSON.parse( readFileSync( @@ -68,9 +69,11 @@ export function astryxCustomProperties() { if (astryxProperties) return astryxProperties; const names = new Set(); const sheets = []; - const core = dependencyDir("@astryxdesign/core"); + // The Astryx the upgrade moves to: the project may still be on 0.1. + const root = targetUiCommonRoot(); + const core = dependencyDir("@astryxdesign/core", root); if (core) sheets.push(join(core, "dist/astryx.css")); - const neutral = dependencyDir("@astryxdesign/theme-neutral"); + const neutral = dependencyDir("@astryxdesign/theme-neutral", root); if (neutral) sheets.push(join(neutral, "dist/theme.css")); for (const sheet of sheets) { let text; @@ -116,7 +119,7 @@ function classesIn(selector) { } /** - * @typedef {{category: string, file: string, line: number, text: string, detail?: string}} Finding + * @typedef {{category: string, file: string, line: number, text: string, detail?: string, classes?: string[]}} Finding */ /** @@ -149,6 +152,7 @@ function scanStylesheet(file, source) { line: rule.source?.start?.line ?? 0, text: rule.selector.replace(/\s+/g, " "), detail: hits.map(describeClass).join(", "), + classes: hits, }); } }); @@ -191,14 +195,17 @@ function scanStylesheet(file, source) { line: i + 1, text: code.trim().replace(/\s*[{,]$/, ""), detail: [...new Set(hits)].map(describeClass).join(", "), + classes: hits, }); } }); return findings; } +// DOM APIs, and the selector-taking calls of Playwright, Cypress and +// Testing Library's container queries. const SELECTOR_CALL = - /\b(querySelector(?:All)?|closest|matches|webkitMatchesSelector)\(\s*(["'`])((?:(?!\2)[^\\]|\\.)*)\2/g; + /(?:\b(querySelector(?:All)?|closest|matches|webkitMatchesSelector|locator|waitForSelector)|(?<![\w$])(\$\$?(?:eval)?)|\bcy\.(get|find))\(\s*(["'`])((?:(?!\4)[^\\]|\\.)*)\4/g; const CLASS_CALL = /\b(classList\.(?:contains|add|remove|toggle|replace)|getElementsByClassName|toHaveClass)\(\s*(["'`])((?:(?!\2)[^\\]|\\.)*)\2/g; @@ -220,10 +227,11 @@ function scanScript(file, source) { line, text: (lines[line - 1] ?? "").trim(), detail: [...new Set(hits)].map(describeClass).join(", "), + classes: hits, }); }; for (const m of source.matchAll(SELECTOR_CALL)) { - const hits = classesIn(m[3]).filter(isLegacyClass); + const hits = classesIn(m[5]).filter(isLegacyClass); if (hits.length > 0) record(m, hits); } for (const m of source.matchAll(CLASS_CALL)) { @@ -254,18 +262,79 @@ function scanScript(file, source) { return findings; } +/** A selector that is one class, with pseudo-classes at most: `.tabs__tab:hover`. */ +const DEFINITION = /^\.(-?[_a-zA-Z][\w-]*)(?::{1,2}[\w-]+(?:\([^)]*\))?)*$/; + +/** + * The 0.1 class names the project owns: it defines each in a stylesheet of + * its own as a rule by itself (`.tabs__tab { … }`) and renders it in its own + * markup (`className="tabs__tab"`, outside tests). Such a project most likely + * has its own `.tabs__tab`, so findings on it are listed apart, as lower + * confidence. A definition alone is not enough: an override of ui-common's + * class (`.drawer__content { padding: 0 }`) looks the same. + * + * @param {Array<[string, string]>} files [relative path, content] + * @returns {{ownClasses: Set<string>}} + */ +export function prepareScan(files) { + const defined = new Set(); + /** @param {string} selectors */ + const collect = (selectors) => { + for (const part of selectors.split(",")) { + const m = DEFINITION.exec(part.trim()); + if (m && isLegacyClass(m[1])) defined.add(m[1]); + } + }; + for (const [file, source] of files) { + if (file.endsWith(".css")) { + try { + postcss + .parse(source, { from: file }) + .walkRules((rule) => collect(rule.selector)); + } catch { + // unparseable: nothing defined + } + } else if (/\.(scss|sass|less)$/.test(file)) { + for (const line of source.split("\n")) { + const m = /^\s*([^{}/@]+?)\s*\{\s*$/.exec(line); + if (m) collect(m[1]); + } + } + } + const own = new Set(); + if (defined.size === 0) return { ownClasses: own }; + const escape = (/** @type {string} */ c) => c.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const pattern = new RegExp( + `(?:^|[\\s"'\`])(${[...defined].map(escape).join("|")})(?=[\\s"'\`]|$)`, + "gm", + ); + for (const [file, source] of files) { + if (/\.(css|scss|sass|less)$/.test(file) || isTestFile(file)) continue; + for (const m of source.matchAll(pattern)) own.add(m[1]); + } + return { ownClasses: own }; +} + +const ORIGIN = { + "css-selector": "CSS selector", + "dom-hook": "DOM hook", + "test-query": "test query", +}; + /** * @param {string} file relative path * @param {string} source final content + * @param {{ownClasses?: Set<string>}} [context] from prepareScan * @returns {Finding[]} */ -export function scanFile(file, source) { +export function scanFile(file, source, context) { /** @type {Finding[]} */ const findings = []; if (/\.(css|scss|sass|less)$/.test(file)) findings.push(...scanStylesheet(file, source)); else findings.push(...scanScript(file, source)); + findings.push(...scanTheme(file, source)); source.split("\n").forEach((line, i) => { if (/@lablup\/ui-common\/styles\//.test(line)) { findings.push({ @@ -277,6 +346,14 @@ export function scanFile(file, source) { }); } }); + const own = context?.ownClasses; + for (const f of findings) { + if (f.classes && own && f.classes.every((c) => own.has(c))) { + f.detail = `${/** @type {Record<string, string>} */ (ORIGIN)[f.category]}: ${f.detail}`; + f.category = "own-class"; + } + delete f.classes; + } return findings; } @@ -301,6 +378,18 @@ export const CATEGORIES = { title: "Custom properties that collide with Astryx tokens", help: "Astryx declares the same name. Whichever rule wins the cascade now restyles both your CSS and Astryx's components. Rename yours, or set it through a theme (`defineTheme`) on purpose.", }, + theme: { + title: "0.1 theme switches and theme selectors", + help: '0.1 switched themes by stylesheet and `html[data-theme="orange-…"]`. In 0.2 `<Theme theme={lablupTheme} mode=…>` owns `html[data-theme]` and sets it to `light` or `dark`, so code that writes another value fights it, and selectors on another value never match.', + }, + "own-class": { + title: "0.1 class names your own CSS also defines (lower confidence)", + help: "The same names as above, but your own stylesheets define each of them as a rule of its own (`.tabs__tab { … }`), so they most likely belong to markup you render, not to ui-common's. Skim them; most need nothing.", + }, + "local-wrapper": { + title: "Local wrappers around 0.1 components", + help: "Your own component renders a 0.1 component and hands its props on, so it now renders the Astryx one. Its call sites pass the wrapper's props, which the upgrade does not rewrite: check the wrapper's props type and what it passes on against the Astryx component. A pure re-export (`export { Button } from …`) is not listed: its call sites were migrated.", + }, "stylesheet-path": { title: "0.1 stylesheet paths left in place", help: "Scripts, configs or tests that name `@lablup/ui-common/styles/*` directly. base.css and the orange themes are deprecated in 0.2 and removed in 0.3; the Lablup theme replaces them.", diff --git a/codemods/0.2/stylesheets.mjs b/packages/cli/codemods/0.2/stylesheets.mjs similarity index 60% rename from codemods/0.2/stylesheets.mjs rename to packages/cli/codemods/0.2/stylesheets.mjs index 8af972c..da8f0d2 100644 --- a/codemods/0.2/stylesheets.mjs +++ b/packages/cli/codemods/0.2/stylesheets.mjs @@ -13,13 +13,14 @@ * - `styles/themes/orange-{light,dark}.css` imports are dropped: the Lablup * theme covers both colour schemes. */ -import { basename, dirname, join } from "node:path"; +import { existsSync, readFileSync } from "node:fs"; +import { basename, dirname, join, relative, resolve, sep } from "node:path"; import postcss from "postcss"; import { TODO_TAG } from "../lib/jsx.mjs"; import { addTodo, printSource } from "../lib/todo.mjs"; -import { LAB_CSS, LAB_PACKAGE, STYLESHEETS } from "./map.mjs"; +import { LAB_CSS, LAB_PACKAGE, STYLESHEETS, UIC } from "./map.mjs"; const { base, layerOrder, imports: replacement, entryFile } = STYLESHEETS; const DROPPED = STYLESHEETS.dropped; @@ -236,6 +237,71 @@ export function transformStylesheet(file, _api, ctx) { return transformPreprocessed(file.source, file.path, ctx); } +const STYLESHEET = /\.(css|scss|sass|less)(\?.*)?$/; + +/** + * Whether an import has to come after the stylesheet entry: a stylesheet + * (the entry's `@layer` statement must be the first one the page sees), a + * @lablup/ui-common module (its components' styles), or a module of the + * project's own, which loads both. + * + * @param {string} source + */ +function loadsStyles(source) { + return ( + STYLESHEET.test(source) || + source === UIC || + source.startsWith(`${UIC}/`) || + source.startsWith(".") || + source.startsWith("/") + ); +} + +/** + * Put the import of `specifier` before every import that loads styles, or + * add it there: after the last import that does not, else first. Vite + * injects stylesheets in import order, and the `@layer` order statement only + * holds if it comes first. + * + * @param {any} j + * @param {any} root + * @param {string} specifier + * @returns {boolean} whether anything moved or was added + */ +export function placeEntryImport(j, root, specifier) { + const program = root.find(j.Program).get().node; + const body = program.body; + const isImport = (/** @type {any} */ n) => n.type === "ImportDeclaration"; + const existing = body.find( + (/** @type {any} */ n) => isImport(n) && n.source.value === specifier, + ); + const first = body.find( + (/** @type {any} */ n) => + isImport(n) && n !== existing && loadsStyles(String(n.source.value)), + ); + if (existing) { + if (!first || body.indexOf(existing) < body.indexOf(first)) return false; + body.splice(body.indexOf(existing), 1); + } + const decl = existing ?? j.importDeclaration([], j.stringLiteral(specifier)); + if (first) { + const at = body.indexOf(first); + // A header comment on the first import stays on top. + if (at === 0 && first.comments?.length) { + decl.comments = [...first.comments, ...(decl.comments ?? [])]; + first.comments = []; + } + body.splice(at, 0, decl); + return true; + } + let last = -1; + body.forEach((/** @type {any} */ n, /** @type {number} */ i) => { + if (isImport(n)) last = i; + }); + body.splice(last + 1, 0, decl); + return true; +} + export const jsMeta = { id: "script-stylesheet-imports", title: @@ -256,6 +322,8 @@ export function transformScriptImports(file, api, ctx) { const entrySpecifier = `./${entryFile}`; const alreadyImportsEntry = root.find(j.ImportDeclaration, { source: { value: entrySpecifier } }).size() > 0; + /** @type {string[]} */ + const entryImports = []; root.find(j.ImportDeclaration).forEach((/** @type {any} */ path) => { const source = path.node.source.value; @@ -281,7 +349,9 @@ export function transformScriptImports(file, api, ctx) { // which is a numbered sibling when the project has its own entry there. const entry = ctx.createFile(join(dirname(file.path), entryFile), entryCss(ctx)); path.node.source = j.stringLiteral(`./${basename(entry)}`); + entryImports.push(`./${basename(entry)}`); }); + for (const specifier of entryImports) placeEntryImport(j, root, specifier); root.find(j.CallExpression).forEach((/** @type {any} */ path) => { const node = path.node; @@ -303,3 +373,107 @@ export function transformScriptImports(file, api, ctx) { }); return file.source.endsWith("\n") && !out.endsWith("\n") ? `${out}\n` : out; } + +/** What loading the 0.2 stylesheets looks like, in a script or a stylesheet. */ +const WIRED = new RegExp( + [...replacement, `${UIC}/styles/`, entryFile.replace(/\.css$/, "")] + .map((r) => r.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&")) + .join("|"), +); + +/** + * The app's entry script: the module script `index.html` loads, else the + * package's `main`, else the usual `src/main.*` / `src/index.*`. + * + * @param {string} projectDir + * @param {any} pkg + * @returns {{file: string, how: string} | null} + */ +export function findEntryScript(projectDir, pkg) { + const html = join(projectDir, "index.html"); + if (existsSync(html)) { + const text = readFileSync(html, "utf8"); + for (const tag of text.matchAll(/<script\b[^>]*>/gi)) { + const src = /\bsrc\s*=\s*["']([^"']+)["']/i.exec(tag[0])?.[1]; + if (!src || /^[a-z]+:|^\/\//i.test(src)) continue; + if (!/type\s*=\s*["']module["']/i.test(tag[0]) && !/\.[cm]?[jt]sx?$/.test(src)) + continue; + const file = src.startsWith("/") + ? join(projectDir, src.replace(/^\/+/, "")) + : resolve(projectDir, src); + if (existsSync(file)) return { file, how: "the module script index.html loads" }; + } + } + const main = typeof pkg?.main === "string" ? resolve(projectDir, pkg.main) : null; + if ( + main && + /\.[cm]?[jt]sx?$/.test(main) && + existsSync(main) && + !relative(projectDir, main) + .split(sep) + .some((d) => ["dist", "build", "out"].includes(d)) + ) + return { file: main, how: "package.json main" }; + for (const name of ["main", "index"]) { + for (const ext of [".tsx", ".ts", ".jsx", ".js"]) { + const file = join(projectDir, "src", `${name}${ext}`); + if (existsSync(file)) return { file, how: `the conventional entry` }; + } + } + return null; +} + +/** + * A 0.1 app that never imported styles/base.css relied on each component + * loading its own CSS; 0.2 components load none, so after the upgrade + * nothing would load Astryx's stylesheets or the theme. When no file of the + * project loads them, write the same ui-common-entry.css the base.css + * rewrite writes beside the app's entry script and import it there, first. + * With no entry to be found, the report opens with the manual step. + * Libraries are left alone: the app that uses them loads the stylesheets. + * + * @param {any} ctx + * @param {{jscodeshift: any}} api + */ +export function wireStylesheets(ctx, api) { + const pkg = ctx.pkg ?? {}; + if (pkg.peerDependencies?.[UIC] != null) return; + const declared = ["dependencies", "devDependencies"].some( + (f) => pkg[f]?.[UIC] != null, + ); + if (!declared) return; + for (const file of ctx.projectFiles()) { + const text = ctx.current(file); + if (text && WIRED.test(text)) return; + } + const rel = (/** @type {string} */ f) => + relative(ctx.projectDir, f).split(sep).join("/"); + const entry = findEntryScript(ctx.projectDir, pkg); + if (!entry) { + ctx.alert( + `**Load @lablup/ui-common's stylesheets.** Nothing in this project loads them, and the upgrade found no entry script to import them from (no index.html module script, package.json \`main\`, or \`src/main.*\` / \`src/index.*\`). 0.1 components loaded their own CSS; 0.2 components load none, so the app renders unstyled until its entry stylesheet starts with: \`${[layerOrder, ...replacement.map((r) => `@import "${r}";`)].join(" ")}\``, + ); + return; + } + const j = api.jscodeshift.withParser( + /\.[cm]?tsx?$/.test(entry.file) ? "tsx" : "babel", + ); + const cssFile = ctx.createFile(join(dirname(entry.file), entryFile), entryCss(ctx)); + const specifier = `./${basename(cssFile)}`; + ctx.editFile( + entry.file, + (/** @type {string | null} */ current) => { + if (current == null) return undefined; + const root = j(current); + if (!placeEntryImport(j, root, specifier)) return undefined; + const out = root.toSource({ + quote: current.includes("from '") ? "single" : "double", + }); + return current.endsWith("\n") && !out.endsWith("\n") ? `${out}\n` : out; + }, + "stylesheet-entry", + ); + ctx.notice( + `No file loaded @lablup/ui-common's stylesheets (0.1 components loaded their own CSS; 0.2's load none), so the upgrade wrote ${rel(cssFile)} and imported it first in ${rel(entry.file)} (${entry.how}). Move the import if your app loads its stylesheets elsewhere.`, + ); +} diff --git a/packages/cli/codemods/0.2/theme.mjs b/packages/cli/codemods/0.2/theme.mjs new file mode 100644 index 0000000..82b3585 --- /dev/null +++ b/packages/cli/codemods/0.2/theme.mjs @@ -0,0 +1,325 @@ +/** + * 0.1 -> 0.2 theming. 0.1 themed the page by stylesheet and attribute + * (`styles/themes/orange-dark.css`, `html[data-theme="orange-dark"]`); 0.2 + * themes it with `<Theme theme={lablupTheme} mode=…>`, which owns + * `html[data-theme]` and sets it to `light` or `dark` itself. + * + * - scanTheme reports what fights that: scripts that write `data-theme`, + * 0.1 theme names, and selectors on any other `data-theme` value. + * - ensureTheme wraps the app's root render in `<Theme>` when no module uses + * one and the root render is unambiguous (`createRoot(…).render(<App />)`); + * otherwise the report opens with the step. + */ +import { relative, sep } from "node:path"; + +import selectorParser from "postcss-selector-parser"; + +import { UIC } from "./map.mjs"; +import { isTestFile } from "./scan.mjs"; +import { findEntryScript } from "./stylesheets.mjs"; + +const MODES = new Set(["light", "dark"]); +export const THEME_IMPORT = { name: "Theme", source: UIC }; +export const LABLUP_THEME_IMPORT = { + name: "lablupTheme", + source: `${UIC}/theme/lablup/built`, +}; + +const SCRIPT_ADVICE = + '`<Theme>` sets `html[data-theme]` to "light" or "dark" itself: pass the mode as `<Theme theme={lablupTheme} mode="light" | "dark" | "system">` instead of writing the attribute.'; +const SELECTOR_ADVICE = + '`<Theme>` only ever sets `html[data-theme]` to "light" or "dark": select on `html[data-theme="dark"]` (or "light"), and put brand colours in the theme.'; + +/** @param {string} text @param {number} index */ +function lineAt(text, index) { + return text.slice(0, index).split("\n").length; +} + +/** + * @param {string} file relative path + * @param {string} source + * @returns {Array<{category: string, file: string, line: number, text: string, detail: string}>} + */ +export function scanTheme(file, source) { + const findings = []; + const lines = source.split("\n"); + /** @param {number} index @param {string} detail */ + const add = (index, detail) => { + const line = lineAt(source, index); + if (findings.some((f) => f.line === line)) return; + findings.push({ + category: "theme", + file, + line, + text: (lines[line - 1] ?? "").trim(), + detail, + }); + }; + + if (/\.(css|scss|sass|less)$/.test(file)) { + // Comments out, keeping offsets, so a line number still points at the rule. + const code = source + .replace(/\/\*[\s\S]*?\*\//g, (c) => c.replace(/[^\n]/g, " ")) + .replace(/(^|[^:])\/\/[^\n]*/g, (c, head) => + file.endsWith(".css") ? c : `${head}${" ".repeat(c.length - head.length)}`, + ); + // One finding per value and file: a theme sheet repeats its selector on + // every rule. + /** @type {Map<string, {index: number, count: number, detail: string}>} */ + const seen = new Map(); + for (const m of code.matchAll( + /\[\s*data-theme\s*([~|^$*]?=)\s*(["']?)([^\]"']*)\2\s*\]/g, + )) { + if (m[1] === "=" && MODES.has(m[3])) continue; + let ok = false; + try { + selectorParser((s) => { + s.walkAttributes((a) => { + if (a.attribute === "data-theme") ok = true; + }); + }).processSync(m[0]); + } catch { + ok = true; + } + if (!ok) continue; + const key = `${m[1]}${m[3]}`; + const known = seen.get(key); + if (known) known.count++; + else + seen.set(key, { + index: m.index ?? 0, + count: 1, + detail: `data-theme ${m[1]} "${m[3]}"`, + }); + } + for (const { index, count, detail } of seen.values()) { + add( + index, + `${detail}${count > 1 ? ` (${count} selectors in this file)` : ""}: ${SELECTOR_ADVICE}`, + ); + } + return findings; + } + + const writes = [ + /\bdataset\s*(?:\.\s*theme|\[\s*["'`]theme["'`]\s*\])\s*=(?!=)\s*([^;\n]*)/g, + /\bsetAttribute\(\s*["'`]data-theme["'`]\s*,\s*([^)\n]*)/g, + ]; + for (const pattern of writes) { + for (const m of source.matchAll(pattern)) { + const value = m[1].trim(); + const literal = /^(["'`])([\w-]*)\1$/.exec(value); + if (literal && MODES.has(literal[2])) continue; + add( + m.index ?? 0, + `writes data-theme ${literal ? `"${literal[2]}"` : "from an expression"}. ${SCRIPT_ADVICE}`, + ); + } + } + for (const m of source.matchAll(/(["'`])(orange-(?:light|dark))\1/g)) { + add(m.index ?? 0, `0.1 theme name "${m[2]}". ${SCRIPT_ADVICE}`); + } + return findings; +} + +/** + * Whether a module imports `Theme` from ui-common (or Astryx). + * + * @param {string} source + */ +function usesTheme(source) { + return /import\s*\{[^}]*\bTheme\b[^}]*\}\s*from\s*["'](@lablup\/ui-common|@astryxdesign\/core)[^"']*["']/.test( + source, + ); +} + +/** + * The `root.render(<X />)` calls of a module: `createRoot(el).render(…)`, + * `ReactDOM.createRoot(el).render(…)`, or `.render(…)` on a variable + * initialised with one of those. + * + * @param {any} j + * @param {any} root + */ +function rootRenders(j, root) { + /** @param {any} node */ + const isCreateRoot = (node) => { + if (node?.type !== "CallExpression") return false; + const callee = node.callee; + const name = + callee.type === "Identifier" + ? callee.name + : callee.type === "MemberExpression" && callee.property.type === "Identifier" + ? callee.property.name + : null; + return name === "createRoot"; + }; + const roots = new Set(); + root.find(j.VariableDeclarator).forEach((/** @type {any} */ p) => { + if (p.node.id.type === "Identifier" && isCreateRoot(p.node.init)) + roots.add(p.node.id.name); + }); + const calls = []; + root.find(j.CallExpression).forEach((/** @type {any} */ p) => { + const callee = p.node.callee; + if ( + callee.type !== "MemberExpression" || + callee.property.type !== "Identifier" || + callee.property.name !== "render" + ) + return; + const object = callee.object; + if ( + isCreateRoot(object) || + (object.type === "Identifier" && roots.has(object.name)) + ) + calls.push(p); + }); + return calls; +} + +/** + * @param {any} el + */ +function isStrictMode(el) { + const name = el.openingElement.name; + return ( + (name.type === "JSXIdentifier" && name.name === "StrictMode") || + (name.type === "JSXMemberExpression" && name.property.name === "StrictMode") + ); +} + +/** + * Wrap the app's root render in `<Theme theme={lablupTheme}>`, once, when + * no module of the project uses `<Theme>` and exactly one module renders a + * root with a JSX element. Anything less clear-cut goes to the report. + * + * @param {any} ctx + * @param {{jscodeshift: any}} api + */ +export function ensureTheme(ctx, api) { + const pkg = ctx.pkg ?? {}; + if (pkg.peerDependencies?.[UIC] != null) return; + if (!["dependencies", "devDependencies"].some((f) => pkg[f]?.[UIC] != null)) return; + const rel = (/** @type {string} */ f) => + relative(ctx.projectDir, f).split(sep).join("/"); + // Tests render roots of their own; they are not the app's. + const scripts = ctx + .projectFiles() + .filter( + (/** @type {string} */ f) => /\.[cm]?[jt]sx?$/.test(f) && !isTestFile(rel(f)), + ); + /** @type {Array<{file: string, text: string}>} */ + const candidates = []; + for (const file of scripts) { + const text = ctx.current(file); + if (!text) continue; + if (usesTheme(text)) return; + if (/\bcreateRoot\b/.test(text) && /\.render\s*\(/.test(text)) + candidates.push({ file, text }); + } + const how = `\`<Theme theme={lablupTheme}>\` (\`import { Theme } from "${THEME_IMPORT.source}"\`, \`import { lablupTheme } from "${LABLUP_THEME_IMPORT.source}"\`)`; + const manual = (/** @type {string} */ why) => + ctx.alert( + `**Wrap the app in ${how}.** ${why} Without it Astryx components get no theme; pass \`mode\` ("light" | "dark" | "system", the default) where the app switches colour schemes.`, + ); + // Several roots (a second page, a verification harness): the one the + // entry index.html loads is the app's. + const entry = findEntryScript(ctx.projectDir, pkg)?.file; + /** @type {string[]} */ + const others = []; + if (candidates.length > 1 && entry && candidates.some((c) => c.file === entry)) { + others.push(...candidates.filter((c) => c.file !== entry).map((c) => rel(c.file))); + candidates.splice( + 0, + candidates.length, + ...candidates.filter((c) => c.file === entry), + ); + } + if (candidates.length !== 1) { + manual( + candidates.length === 0 + ? "No module uses `<Theme>`, and the upgrade found no `createRoot(…).render(…)` to wrap." + : `No module uses \`<Theme>\`, and ${candidates.length} modules render a root (${candidates.map((c) => rel(c.file)).join(", ")}), so the upgrade did not pick one.`, + ); + return; + } + const [{ file, text }] = candidates; + const j = api.jscodeshift.withParser(/\.[cm]?tsx?$/.test(file) ? "tsx" : "babel"); + const root = j(text); + const renders = rootRenders(j, root); + const arg = renders.length === 1 ? renders[0].node.arguments[0] : null; + if (!arg || arg.type !== "JSXElement") { + manual( + `${rel(file)} renders a root, but not as a single \`render(<App />)\` the upgrade can wrap safely.`, + ); + return; + } + const bound = new Set(); + root.find(j.Identifier).forEach((/** @type {any} */ p) => bound.add(p.node.name)); + root.find(j.JSXIdentifier).forEach((/** @type {any} */ p) => bound.add(p.node.name)); + if (bound.has(THEME_IMPORT.name) || bound.has(LABLUP_THEME_IMPORT.name)) { + manual( + `${rel(file)} already uses the name Theme or lablupTheme for something else.`, + ); + return; + } + + // Edit the text at the parsed positions, so the rest of the file keeps + // its formatting. + const q = text.includes("from '") && !text.includes('from "') ? "'" : '"'; + /** @type {Array<[number, number, string]>} */ + const edits = []; + /** @param {number} at */ + const indentAt = (at) => + /^[ \t]*/.exec(text.slice(text.lastIndexOf("\n", at - 1) + 1))?.[0] ?? ""; + /** @param {string} block @param {string} pad */ + const indent = (block, pad) => + block + .split("\n") + .map((l) => (l.trim() === "" ? l : `${pad}${l}`)) + .join("\n"); + if (isStrictMode(arg) && arg.closingElement) { + const open = arg.openingElement.end; + const close = arg.closingElement.start; + const inner = text.slice(open, close); + const first = inner.split("\n").find((l) => l.trim() !== "") ?? ""; + const pad = /^[ \t]*/.exec(first)?.[0] ?? ""; + const body = inner + .trim() + .split("\n") + .map((l, i) => (i === 0 ? l : l.replace(new RegExp(`^${pad}`), ""))) + .join("\n"); + edits.push([ + open, + close, + `\n${pad}<Theme theme={lablupTheme}>\n${indent(body, `${pad} `)}\n${pad}</Theme>\n${indentAt(close)}`, + ]); + } else { + const pad = indentAt(arg.start); + const body = text.slice(arg.start, arg.end); + edits.push([ + arg.start, + arg.end, + `<Theme theme={lablupTheme}>\n${indent(body, `${pad} `)}\n${pad}</Theme>`, + ]); + } + const imports = root.find(j.ImportDeclaration).nodes(); + const after = imports.length > 0 ? imports[imports.length - 1].end : 0; + const lines = [ + `import { ${THEME_IMPORT.name} } from ${q}${THEME_IMPORT.source}${q};`, + `import { ${LABLUP_THEME_IMPORT.name} } from ${q}${LABLUP_THEME_IMPORT.source}${q};`, + ]; + edits.push([ + after, + after, + after === 0 ? `${lines.join("\n")}\n` : `\n${lines.join("\n")}`, + ]); + let out = text; + for (const [from, to, insert] of edits.sort((a, b) => b[0] - a[0])) + out = `${out.slice(0, from)}${insert}${out.slice(to)}`; + ctx.editFile(file, () => out, "theme"); + ctx.notice( + `No module used \`<Theme>\`, so the upgrade wrapped the root render in ${rel(file)}${entry === file ? "" : " (not the entry index.html loads: check it is the app's root)"} in \`<Theme theme={lablupTheme}>\`.${others.length > 0 ? ` ${others.join(", ")} also render${others.length === 1 ? "s" : ""} a root; wrap ${others.length === 1 ? "it" : "them"} too if ${others.length === 1 ? "it renders" : "they render"} ui-common components.` : ""} Its mode defaults to "system"; pass \`mode="light" | "dark"\` where the app switches colour schemes (see "0.1 theme switches and theme selectors").`, + ); +} diff --git a/codemods/lib/jsx.mjs b/packages/cli/codemods/lib/jsx.mjs similarity index 100% rename from codemods/lib/jsx.mjs rename to packages/cli/codemods/lib/jsx.mjs diff --git a/codemods/lib/todo.mjs b/packages/cli/codemods/lib/todo.mjs similarity index 100% rename from codemods/lib/todo.mjs rename to packages/cli/codemods/lib/todo.mjs diff --git a/codemods/registry.mjs b/packages/cli/codemods/registry.mjs similarity index 83% rename from codemods/registry.mjs rename to packages/cli/codemods/registry.mjs index b07f73d..dbaa72c 100644 --- a/codemods/registry.mjs +++ b/packages/cli/codemods/registry.mjs @@ -27,8 +27,11 @@ import { compare, parse } from "../cli/semver.mjs"; * @typedef {object} Step * @property {string} title * @property {Transform[]} transforms + * @property {(ctx: any, api: {jscodeshift: any}) => void} [afterTransforms] project-level edits once every file is transformed * @property {(text: string, ctx: any) => string | undefined} [packageJson] - * @property {(file: string, source: string) => Array<{category: string, file: string, line: number, text: string, detail?: string}>} [scan] + * @property {(files: Array<[string, string]>) => any} [prepareScan] a context for `scan`, from every file it will read ([relative path, content]) + * @property {(file: string, source: string, context?: any) => Array<{category: string, file: string, line: number, text: string, detail?: string}>} [scan] + * @property {(ctx: any, rel: (file: string) => string) => Array<{category: string, file: string, line: number, text: string, detail?: string}>} [findings] report findings the transforms collected, after they ran * @property {Record<string, {title: string, help: string}>} [categories] * @property {string[] | ((ctx: any) => string[])} [notes] report notes, or a function of the run */ diff --git a/codemods/upstream.mjs b/packages/cli/codemods/upstream.mjs similarity index 100% rename from codemods/upstream.mjs rename to packages/cli/codemods/upstream.mjs diff --git a/migration/0.1-to-0.2.json b/packages/cli/migration/0.1-to-0.2.json similarity index 100% rename from migration/0.1-to-0.2.json rename to packages/cli/migration/0.1-to-0.2.json diff --git a/packages/cli/package.json b/packages/cli/package.json new file mode 100644 index 0000000..b14d471 --- /dev/null +++ b/packages/cli/package.json @@ -0,0 +1,50 @@ +{ + "name": "@lablup/ui-common-cli", + "version": "0.2.0-alpha.14", + "description": "The ui-common command line: the pinned Astryx CLI in @lablup/ui-common terms, the agent block, and the upgrade codemods", + "license": "Apache-2.0", + "author": "Lablup Inc.", + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/lablup/ui-common.git", + "directory": "packages/cli" + }, + "homepage": "https://github.com/lablup/ui-common#the-ui-common-cli", + "bugs": { + "url": "https://github.com/lablup/ui-common/issues" + }, + "publishConfig": { + "access": "public", + "provenance": false + }, + "engines": { + "node": ">=22.13.0" + }, + "bin": { + "ui-common": "./bin/ui-common.mjs" + }, + "files": [ + "bin", + "cli", + "codemods", + "migration", + "README.md", + "NOTICE" + ], + "exports": { + "./package.json": "./package.json" + }, + "dependencies": { + "@astryxdesign/cli": "0.6.2", + "jscodeshift": "^17.4.0", + "postcss": "^8.5.25", + "postcss-selector-parser": "^7.1.6" + }, + "peerDependencies": { + "@lablup/ui-common": "workspace:*" + }, + "devDependencies": { + "@lablup/ui-common": "workspace:*" + } +} diff --git a/scripts/extract-legacy-classes.mjs b/packages/cli/scripts/extract-legacy-classes.mjs similarity index 94% rename from scripts/extract-legacy-classes.mjs rename to packages/cli/scripts/extract-legacy-classes.mjs index 0665c28..fe812e2 100644 --- a/scripts/extract-legacy-classes.mjs +++ b/packages/cli/scripts/extract-legacy-classes.mjs @@ -7,7 +7,7 @@ * checkout of the last 0.1 release: * * git archive v0.1.0-alpha.23 src | tar -x -C /tmp/uc-0.1 - * node scripts/extract-legacy-classes.mjs /tmp/uc-0.1 v0.1.0-alpha.23 + * node packages/cli/scripts/extract-legacy-classes.mjs /tmp/uc-0.1 v0.1.0-alpha.23 * * `ui-common upgrade` reports consumer selectors, DOM queries and tests that * target these names. Astryx does not render them. @@ -26,6 +26,7 @@ if (!checkout) { process.exit(2); } +// The CLI package root (packages/cli). const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const files = globSync("src/components/**/*.css", { cwd: checkout, absolute: true }); diff --git a/test/cli/cli.test.ts b/packages/cli/test/cli/cli.test.ts similarity index 97% rename from test/cli/cli.test.ts rename to packages/cli/test/cli/cli.test.ts index 5376dc0..53f259a 100644 --- a/test/cli/cli.test.ts +++ b/packages/cli/test/cli/cli.test.ts @@ -36,8 +36,11 @@ import { import { registeredVersions, stepsBetween } from "../../codemods/registry.mjs"; import { toAstryxSpecifiers, upstreamStep } from "../../codemods/upstream.mjs"; -const root = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); -const bin = join(root, "bin/ui-common.mjs"); +// The CLI package (packages/cli) and the repository root, which is the +// @lablup/ui-common package the CLI works for. +const cliRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); +const root = resolve(cliRoot, "../.."); +const bin = join(cliRoot, "bin/ui-common.mjs"); const temps: string[] = []; afterEach(() => { @@ -177,6 +180,9 @@ describe("output rewriting", { timeout: 60_000 }, () => { it("prints its version and help", () => { const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8")); + const cliPkg = JSON.parse(readFileSync(join(cliRoot, "package.json"), "utf8")); + // Lockstep: the CLI is released at the version of the ui-common it serves. + expect(cliPkg.version).toBe(pkg.version); expect(run(["--version"]).stdout.trim()).toBe(pkg.version); const help = run(["--help"]); expect(help.code).toBe(0); diff --git a/test/cli/lab-peer.test.ts b/packages/cli/test/cli/lab-peer.test.ts similarity index 96% rename from test/cli/lab-peer.test.ts rename to packages/cli/test/cli/lab-peer.test.ts index 9fbf458..5c7940c 100644 --- a/test/cli/lab-peer.test.ts +++ b/packages/cli/test/cli/lab-peer.test.ts @@ -18,12 +18,13 @@ import { labOverrideProblems, syncLabOverrideDocs, } from "../../cli/lab-peer.mjs"; -import { ownPackageJson } from "../../cli/paths.mjs"; +import { uiCommonPackageJson } from "../../cli/paths.mjs"; import { labOverrideEdits } from "../../cli/sync-astryx.mjs"; -const root = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); +// The repository root, where README and the ui-common package.json live. +const root = resolve(dirname(fileURLToPath(import.meta.url)), "../../../.."); const readme = readFileSync(join(root, "README.md"), "utf8"); -const corePin = ownPackageJson().dependencies["@astryxdesign/core"] as string; +const corePin = uiCommonPackageJson().dependencies?.["@astryxdesign/core"] as string; describe("the lab core override", () => { it("is documented in README at the core pin whenever lab's core peer differs", () => { diff --git a/test/upgrade/codemods.test.ts b/packages/cli/test/upgrade/codemods.test.ts similarity index 76% rename from test/upgrade/codemods.test.ts rename to packages/cli/test/upgrade/codemods.test.ts index 7d9d0b7..1e0e7e1 100644 --- a/test/upgrade/codemods.test.ts +++ b/packages/cli/test/upgrade/codemods.test.ts @@ -8,6 +8,7 @@ import jscodeshift from "jscodeshift"; import { describe, expect, it, vi } from "vitest"; import transformComponents from "../../codemods/0.2/components.mjs"; +import { narrowReactRange } from "../../codemods/0.2/package-json.mjs"; const j = jscodeshift.withParser("tsx"); @@ -180,6 +181,82 @@ export function D({ StatusTag }: { StatusTag: any }) { }); }); +describe("removed types that a module re-exports", () => { + it("drops the local re-export with the import, so the file still parses", () => { + const out = upgrade( + `import { DataTable as BaseDataTable } from "@lablup/ui-common/components/DataTable"; +import type { + DataTableProps as BaseProps, + DataTablePersistedState, + SortDirection, +} from "@lablup/ui-common/components/DataTable"; + +export type { DataTablePersistedState, SortDirection }; + +export function DataTable<T>(props: BaseProps<T>) { + return <BaseDataTable {...props} />; +} +`, + ); + expect(() => j(out)).not.toThrow(); + expect(out).not.toMatch(/export type \{[^}]*DataTablePersistedState/); + expect(out).toContain( + "// TODO(ui-common-upgrade): DataTablePersistedState, SortDirection (removed with DataTable in 0.2, no Astryx counterpart) are no longer re-exported from here;", + ); + // The TODO lands above the statement that followed the re-export. + expect(out).toMatch(/no longer re-exported[^\n]*\nexport function DataTable/); + }); + + it("keeps the other names of a mixed re-export, and an alias", () => { + const out = upgrade( + `import type { StatusKind } from "@lablup/ui-common/components/StatusTag"; +type Local = string; +export type { Local, StatusKind as Kind }; +`, + ); + expect(() => j(out)).not.toThrow(); + expect(out).toContain("export type { Local };"); + expect(out).toContain( + "TODO(ui-common-upgrade): Kind (removed with StatusTag in 0.2, no Astryx counterpart) is no longer re-exported from here", + ); + }); + + it("leaves a re-export of a local type with the same name alone", () => { + const out = upgrade( + `import { Badge } from "@lablup/ui-common"; +export type SortDirection = "asc" | "desc"; +export const B = () => <Badge variant="info">x</Badge>; +`, + ); + expect(out).toContain('export type SortDirection = "asc" | "desc";'); + }); +}); + +describe("React peers of a library", () => { + it.each([ + ["^18.2.0 || ^19.0.0", "^19.2.0"], + [">=18 <21 || ^22", ">=19.2.0 <21 || ^22"], + ["^19.0.0", "^19.2.0"], + ["*", ">=19.2.0"], + [">=19.3", ">=19.3"], + ["^18 || ^19.1 || ^20", "^19.2.0 || ^20"], + [">=18.2.0 <20", "^19.2.0"], + ["18 - 20", "19.2.0 - 20"], + ["^19.2.0", "^19.2.0"], + ["workspace:*", "workspace:*"], + ["latest", "latest"], + ])("%s -> %s", (range, expected) => { + expect(narrowReactRange(range, "19.2.0")).toBe(expected); + }); + + it.each(["^17", "~19.1", ">=18 <=19.1"])( + "%s admits no React 19.2: reported, not emptied", + (range) => { + expect(narrowReactRange(range, "19.2.0")).toBeNull(); + }, + ); +}); + /** The output with each JSX TODO marker's message elided. */ const todos = (out: string) => out.replace(/\{\/\* TODO\(ui-common-upgrade\): [^*]*\*\/\}/g, "{/* TODO */}"); diff --git a/test/upgrade/fixtures/adapter/expected/package.json b/packages/cli/test/upgrade/fixtures/adapter/expected/package.json similarity index 76% rename from test/upgrade/fixtures/adapter/expected/package.json rename to packages/cli/test/upgrade/fixtures/adapter/expected/package.json index b438c73..31640bd 100644 --- a/test/upgrade/fixtures/adapter/expected/package.json +++ b/packages/cli/test/upgrade/fixtures/adapter/expected/package.json @@ -7,5 +7,8 @@ "@lablup/ui-common": "0.2.0-alpha.0", "@stylexjs/stylex": "^0.19.0", "react": "^19.2.0" + }, + "devDependencies": { + "@lablup/ui-common-cli": "0.2.0-alpha.0" } } diff --git a/test/upgrade/fixtures/adapter/expected/src/design-system/common-adapters.tsx b/packages/cli/test/upgrade/fixtures/adapter/expected/src/design-system/common-adapters.tsx similarity index 100% rename from test/upgrade/fixtures/adapter/expected/src/design-system/common-adapters.tsx rename to packages/cli/test/upgrade/fixtures/adapter/expected/src/design-system/common-adapters.tsx diff --git a/test/upgrade/fixtures/adapter/expected/src/design-system/common-components.css b/packages/cli/test/upgrade/fixtures/adapter/expected/src/design-system/common-components.css similarity index 100% rename from test/upgrade/fixtures/adapter/expected/src/design-system/common-components.css rename to packages/cli/test/upgrade/fixtures/adapter/expected/src/design-system/common-components.css diff --git a/test/upgrade/fixtures/adapter/expected/src/index.scss b/packages/cli/test/upgrade/fixtures/adapter/expected/src/index.scss similarity index 100% rename from test/upgrade/fixtures/adapter/expected/src/index.scss rename to packages/cli/test/upgrade/fixtures/adapter/expected/src/index.scss diff --git a/packages/cli/test/upgrade/fixtures/adapter/expected/src/main.tsx b/packages/cli/test/upgrade/fixtures/adapter/expected/src/main.tsx new file mode 100644 index 0000000..a11c0c5 --- /dev/null +++ b/packages/cli/test/upgrade/fixtures/adapter/expected/src/main.tsx @@ -0,0 +1,10 @@ +import { createRoot } from "react-dom/client"; +import "./ui-common-entry.css"; +import "./design-system/common-components.css"; +import { App } from "./App"; +import { Theme } from "@lablup/ui-common"; +import { lablupTheme } from "@lablup/ui-common/theme/lablup/built"; + +createRoot(document.getElementById("root")!).render(<Theme theme={lablupTheme}> + <App /> +</Theme>); diff --git a/test/upgrade/fixtures/adapter/expected/src/ui-common-entry.css b/packages/cli/test/upgrade/fixtures/adapter/expected/src/ui-common-entry.css similarity index 100% rename from test/upgrade/fixtures/adapter/expected/src/ui-common-entry.css rename to packages/cli/test/upgrade/fixtures/adapter/expected/src/ui-common-entry.css diff --git a/test/upgrade/fixtures/adapter/expected/ui-common-upgrade-report.md b/packages/cli/test/upgrade/fixtures/adapter/expected/ui-common-upgrade-report.md similarity index 88% rename from test/upgrade/fixtures/adapter/expected/ui-common-upgrade-report.md rename to packages/cli/test/upgrade/fixtures/adapter/expected/ui-common-upgrade-report.md index 5cf1f13..c6ddbdc 100644 --- a/test/upgrade/fixtures/adapter/expected/ui-common-upgrade-report.md +++ b/packages/cli/test/upgrade/fixtures/adapter/expected/ui-common-upgrade-report.md @@ -1,8 +1,8 @@ # ui-common upgrade report -`ui-common upgrade` 0.1.0-alpha.19 → 0.2.0-alpha.0 (installed @lablup/ui-common <version>). +`ui-common upgrade` 0.1.0-alpha.19 → 0.2.0-alpha.0 (@lablup/ui-common-cli <version>). -Scanned 4 files under `src`. +Ran the codemods over 4 files under `src`; searched 4 files under the project root for manual-review findings. ## Summary @@ -22,12 +22,13 @@ Scanned 4 files under `src`. - `src/design-system/common-adapters.tsx`: +39 −23, components - `src/design-system/common-components.css`: +0 −2, stylesheet-entry - `src/index.scss`: +8 −1, stylesheet-entry -- `src/main.tsx`: +1 −1, script-stylesheet-imports +- `src/main.tsx`: +6 −2, script-stylesheet-imports, theme - `src/ui-common-entry.css` (new): +15 −0, stylesheet-entry ## package.json - dependencies["@lablup/ui-common"]: "0.1.0-alpha.19" → "0.2.0-alpha.0". +- added @lablup/ui-common-cli 0.2.0-alpha.0 to devDependencies: the `ui-common` bin ships in its own package since 0.2, released at the same version as @lablup/ui-common. - added @stylexjs/stylex ^0.19.0 to dependencies. - added @astryxdesign/lab 0.6.2-canary.c9fb1ad to dependencies: a Drawer moved to @lablup/ui-common/lab, and ui-common pins the lab canary exactly. - no package manager was detected, so point @astryxdesign/lab's @astryxdesign/core peer at ui-common's by hand (pnpm: `overrides: { "@astryxdesign/lab>@astryxdesign/core": "0.6.2" }` in pnpm-workspace.yaml; npm: `"overrides": {"@astryxdesign/lab":{"@astryxdesign/core":"0.6.2"}}` in the root package.json) and check that `why @astryxdesign/core` lists one version. @@ -74,12 +75,25 @@ Astryx declares the same name. Whichever rule wins the cascade now restyles both |---|---|---| | `src/design-system/common-components.css:6` | `:root { --color-error: #d4380d }` | --color-error | +### 0.1 theme switches and theme selectors (0) + +None. + +### 0.1 class names your own CSS also defines (lower confidence) (0) + +None. + +### Local wrappers around 0.1 components (0) + +None. + ### 0.1 stylesheet paths left in place (0) None. ## Notes +- No module used `<Theme>`, so the upgrade wrapped the root render in src/main.tsx in `<Theme theme={lablupTheme}>`. Its mode defaults to "system"; pass `mode="light" | "dark"` where the app switches colour schemes (see "0.1 theme switches and theme selectors"). - Button → Button (@lablup/ui-common/Button); or IconButton (@lablup/ui-common/IconButton) when iconOnly is set; the accessible name moves from ariaLabel to label. `label` is required. A non-string child needs `label` for the accessible name and the node as children. variant="success" has no Button variant; use primary. iconPosition="right" becomes `endContent` (an Icon or Badge element only). shape="circle", inline and active have no counterpart. The .button / .button--* classes are gone; Astryx's stable class is .astryx-button. - Drawer → Drawer (@lablup/ui-common/lab). lab Drawer renders no header: render the title, subtitle and footer inside children. closeLabel, ariaLabelledBy and ariaDescribedBy have no counterpart. preventDismiss and onDismissAttempt: decline the close in onOpenChange. The .drawer classes are gone. - EmptyState → EmptyState (@lablup/ui-common/EmptyState). primaryAction and secondaryAction become `actions`, a node: <Button variant="primary" label={a.label} onClick={a.onClick} /> and a secondary Button, or a Link for a secondaryAction with href. showIllustration={false}: omit `icon`. children has no slot; put it in `actions` or below the EmptyState. The title renders as an h3 by default; set headingLevel to fit the outline. The .empty-state classes are gone. diff --git a/test/upgrade/fixtures/adapter/input/package.json b/packages/cli/test/upgrade/fixtures/adapter/input/package.json similarity index 100% rename from test/upgrade/fixtures/adapter/input/package.json rename to packages/cli/test/upgrade/fixtures/adapter/input/package.json diff --git a/test/upgrade/fixtures/adapter/input/src/design-system/common-adapters.tsx b/packages/cli/test/upgrade/fixtures/adapter/input/src/design-system/common-adapters.tsx similarity index 100% rename from test/upgrade/fixtures/adapter/input/src/design-system/common-adapters.tsx rename to packages/cli/test/upgrade/fixtures/adapter/input/src/design-system/common-adapters.tsx diff --git a/test/upgrade/fixtures/adapter/input/src/design-system/common-components.css b/packages/cli/test/upgrade/fixtures/adapter/input/src/design-system/common-components.css similarity index 100% rename from test/upgrade/fixtures/adapter/input/src/design-system/common-components.css rename to packages/cli/test/upgrade/fixtures/adapter/input/src/design-system/common-components.css diff --git a/test/upgrade/fixtures/adapter/input/src/index.scss b/packages/cli/test/upgrade/fixtures/adapter/input/src/index.scss similarity index 100% rename from test/upgrade/fixtures/adapter/input/src/index.scss rename to packages/cli/test/upgrade/fixtures/adapter/input/src/index.scss diff --git a/test/upgrade/fixtures/adapter/input/src/main.tsx b/packages/cli/test/upgrade/fixtures/adapter/input/src/main.tsx similarity index 100% rename from test/upgrade/fixtures/adapter/input/src/main.tsx rename to packages/cli/test/upgrade/fixtures/adapter/input/src/main.tsx diff --git a/test/upgrade/fixtures/css-entry/expected/package.json b/packages/cli/test/upgrade/fixtures/css-entry/expected/package.json similarity index 67% rename from test/upgrade/fixtures/css-entry/expected/package.json rename to packages/cli/test/upgrade/fixtures/css-entry/expected/package.json index 011ed6a..614c00a 100644 --- a/test/upgrade/fixtures/css-entry/expected/package.json +++ b/packages/cli/test/upgrade/fixtures/css-entry/expected/package.json @@ -4,5 +4,8 @@ "dependencies": { "@lablup/ui-common": "0.2.0-alpha.0", "@stylexjs/stylex": "^0.19.0" + }, + "devDependencies": { + "@lablup/ui-common-cli": "0.2.0-alpha.0" } } diff --git a/test/upgrade/fixtures/css-entry/expected/src/index.css b/packages/cli/test/upgrade/fixtures/css-entry/expected/src/index.css similarity index 100% rename from test/upgrade/fixtures/css-entry/expected/src/index.css rename to packages/cli/test/upgrade/fixtures/css-entry/expected/src/index.css diff --git a/test/upgrade/fixtures/css-entry/expected/src/print.css b/packages/cli/test/upgrade/fixtures/css-entry/expected/src/print.css similarity index 100% rename from test/upgrade/fixtures/css-entry/expected/src/print.css rename to packages/cli/test/upgrade/fixtures/css-entry/expected/src/print.css diff --git a/test/upgrade/fixtures/css-entry/expected/ui-common-upgrade-report.md b/packages/cli/test/upgrade/fixtures/css-entry/expected/ui-common-upgrade-report.md similarity index 65% rename from test/upgrade/fixtures/css-entry/expected/ui-common-upgrade-report.md rename to packages/cli/test/upgrade/fixtures/css-entry/expected/ui-common-upgrade-report.md index 1b34e43..f39ac48 100644 --- a/test/upgrade/fixtures/css-entry/expected/ui-common-upgrade-report.md +++ b/packages/cli/test/upgrade/fixtures/css-entry/expected/ui-common-upgrade-report.md @@ -1,8 +1,12 @@ # ui-common upgrade report -`ui-common upgrade` 0.1.0-alpha.23 → 0.2.0-alpha.0 (installed @lablup/ui-common <version>). +`ui-common upgrade` 0.1.0-alpha.23 → 0.2.0-alpha.0 (@lablup/ui-common-cli <version>). -Scanned 2 files under `src`. +Ran the codemods over 2 files under `src`; searched 2 files under the project root for manual-review findings. + +## Action required + +- **Wrap the app in `<Theme theme={lablupTheme}>` (`import { Theme } from "@lablup/ui-common"`, `import { lablupTheme } from "@lablup/ui-common/theme/lablup/built"`).** No module uses `<Theme>`, and the upgrade found no `createRoot(…).render(…)` to wrap. Without it Astryx components get no theme; pass `mode` ("light" | "dark" | "system", the default) where the app switches colour schemes. ## Summary @@ -25,6 +29,7 @@ Scanned 2 files under `src`. ## package.json - dependencies["@lablup/ui-common"]: "0.1.0-alpha.23" → "0.2.0-alpha.0". +- added @lablup/ui-common-cli 0.2.0-alpha.0 to devDependencies: the `ui-common` bin ships in its own package since 0.2, released at the same version as @lablup/ui-common. ## Manual review @@ -54,6 +59,18 @@ None. None. +### 0.1 theme switches and theme selectors (0) + +None. + +### 0.1 class names your own CSS also defines (lower confidence) (0) + +None. + +### Local wrappers around 0.1 components (0) + +None. + ### 0.1 stylesheet paths left in place (1) Scripts, configs or tests that name `@lablup/ui-common/styles/*` directly. base.css and the orange themes are deprecated in 0.2 and removed in 0.3; the Lablup theme replaces them. diff --git a/test/upgrade/fixtures/css-entry/input/package.json b/packages/cli/test/upgrade/fixtures/css-entry/input/package.json similarity index 100% rename from test/upgrade/fixtures/css-entry/input/package.json rename to packages/cli/test/upgrade/fixtures/css-entry/input/package.json diff --git a/test/upgrade/fixtures/css-entry/input/src/index.css b/packages/cli/test/upgrade/fixtures/css-entry/input/src/index.css similarity index 100% rename from test/upgrade/fixtures/css-entry/input/src/index.css rename to packages/cli/test/upgrade/fixtures/css-entry/input/src/index.css diff --git a/test/upgrade/fixtures/css-entry/input/src/print.css b/packages/cli/test/upgrade/fixtures/css-entry/input/src/print.css similarity index 100% rename from test/upgrade/fixtures/css-entry/input/src/print.css rename to packages/cli/test/upgrade/fixtures/css-entry/input/src/print.css diff --git a/test/upgrade/fixtures/library/expected/package.json b/packages/cli/test/upgrade/fixtures/library/expected/package.json similarity index 76% rename from test/upgrade/fixtures/library/expected/package.json rename to packages/cli/test/upgrade/fixtures/library/expected/package.json index 6f132d8..8af837f 100644 --- a/test/upgrade/fixtures/library/expected/package.json +++ b/packages/cli/test/upgrade/fixtures/library/expected/package.json @@ -5,10 +5,12 @@ "peerDependencies": { "@lablup/ui-common": "^0.2.0-alpha.0", "@stylexjs/stylex": "^0.19.0", - "react": "^19.0.0" + "react": "^19.2.0", + "react-dom": "^19.2.0" }, "devDependencies": { "@lablup/ui-common": "0.2.0-alpha.0", + "@lablup/ui-common-cli": "0.2.0-alpha.0", "@stylexjs/stylex": "^0.19.0", "react": "^19.2.0" } diff --git a/test/upgrade/fixtures/library/expected/src/chat/ReasoningBlock.tsx b/packages/cli/test/upgrade/fixtures/library/expected/src/chat/ReasoningBlock.tsx similarity index 100% rename from test/upgrade/fixtures/library/expected/src/chat/ReasoningBlock.tsx rename to packages/cli/test/upgrade/fixtures/library/expected/src/chat/ReasoningBlock.tsx diff --git a/test/upgrade/fixtures/library/expected/ui-common-upgrade-report.md b/packages/cli/test/upgrade/fixtures/library/expected/ui-common-upgrade-report.md similarity index 74% rename from test/upgrade/fixtures/library/expected/ui-common-upgrade-report.md rename to packages/cli/test/upgrade/fixtures/library/expected/ui-common-upgrade-report.md index 962a51d..c9f32f3 100644 --- a/test/upgrade/fixtures/library/expected/ui-common-upgrade-report.md +++ b/packages/cli/test/upgrade/fixtures/library/expected/ui-common-upgrade-report.md @@ -1,8 +1,8 @@ # ui-common upgrade report -`ui-common upgrade` 0.1.0-alpha.0 → 0.2.0-alpha.0 (installed @lablup/ui-common <version>). +`ui-common upgrade` 0.1.0-alpha.0 → 0.2.0-alpha.0 (@lablup/ui-common-cli <version>). -Scanned 1 file under `src`. +Ran the codemods over 1 file under `src`; searched 1 file under the project root for manual-review findings. ## Summary @@ -26,7 +26,10 @@ Scanned 1 file under `src`. - devDependencies["@lablup/ui-common"]: "0.1.0-alpha.0" → "0.2.0-alpha.0". - peerDependencies["@lablup/ui-common"]: ">=0.1.0-alpha.0 <0.2.0" → "^0.2.0-alpha.0". - peerDependencies["@lablup/ui-common"]: the range ">=0.1.0-alpha.0 <0.2.0" was replaced with "^0.2.0-alpha.0"; widen it again if this package must still accept 0.1. +- added @lablup/ui-common-cli 0.2.0-alpha.0 to devDependencies: the `ui-common` bin ships in its own package since 0.2, released at the same version as @lablup/ui-common. - added @stylexjs/stylex ^0.19.0 to peerDependencies and devDependencies. +- peerDependencies["react"]: "^18.2.0 || ^19.0.0" → "^19.2.0": @lablup/ui-common 0.2 needs React 19.2.0 or later. +- peerDependencies["react-dom"]: "^18.2.0 || ^19.0.0" → "^19.2.0": @lablup/ui-common 0.2 needs React 19.2.0 or later. ## Manual review @@ -58,6 +61,18 @@ None. None. +### 0.1 theme switches and theme selectors (0) + +None. + +### 0.1 class names your own CSS also defines (lower confidence) (0) + +None. + +### Local wrappers around 0.1 components (0) + +None. + ### 0.1 stylesheet paths left in place (0) None. diff --git a/test/upgrade/fixtures/library/input/package.json b/packages/cli/test/upgrade/fixtures/library/input/package.json similarity index 77% rename from test/upgrade/fixtures/library/input/package.json rename to packages/cli/test/upgrade/fixtures/library/input/package.json index 2589d74..ba4f5e5 100644 --- a/test/upgrade/fixtures/library/input/package.json +++ b/packages/cli/test/upgrade/fixtures/library/input/package.json @@ -4,7 +4,8 @@ "type": "module", "peerDependencies": { "@lablup/ui-common": ">=0.1.0-alpha.0 <0.2.0", - "react": "^19.0.0" + "react": "^18.2.0 || ^19.0.0", + "react-dom": "^18.2.0 || ^19.0.0" }, "devDependencies": { "@lablup/ui-common": "0.1.0-alpha.0", diff --git a/test/upgrade/fixtures/library/input/src/chat/ReasoningBlock.tsx b/packages/cli/test/upgrade/fixtures/library/input/src/chat/ReasoningBlock.tsx similarity index 100% rename from test/upgrade/fixtures/library/input/src/chat/ReasoningBlock.tsx rename to packages/cli/test/upgrade/fixtures/library/input/src/chat/ReasoningBlock.tsx diff --git a/packages/cli/test/upgrade/fixtures/local-barrel/expected/package.json b/packages/cli/test/upgrade/fixtures/local-barrel/expected/package.json new file mode 100644 index 0000000..38441db --- /dev/null +++ b/packages/cli/test/upgrade/fixtures/local-barrel/expected/package.json @@ -0,0 +1,13 @@ +{ + "name": "local-barrel-app", + "private": true, + "type": "module", + "dependencies": { + "@lablup/ui-common": "0.2.0-alpha.0", + "@stylexjs/stylex": "^0.19.0", + "react": "^19.2.0" + }, + "devDependencies": { + "@lablup/ui-common-cli": "0.2.0-alpha.0" + } +} diff --git a/packages/cli/test/upgrade/fixtures/local-barrel/expected/src/components/common/Select/index.tsx b/packages/cli/test/upgrade/fixtures/local-barrel/expected/src/components/common/Select/index.tsx new file mode 100644 index 0000000..db3aa35 --- /dev/null +++ b/packages/cli/test/upgrade/fixtures/local-barrel/expected/src/components/common/Select/index.tsx @@ -0,0 +1,11 @@ +import { Selector as BaseSelect } from "@lablup/ui-common/Selector"; +import type { SelectorProps } from "@lablup/ui-common/Selector"; + +// Supplies the empty-search line the package cannot localise. +export function Select({ noOptionsLabel, ...rest }: SelectorProps) { + return ( + // TODO(ui-common-upgrade): props spread into <BaseSelect> are not migrated; check them against Astryx Selector's props. + // TODO(ui-common-upgrade): `label` is required and is a string; a node label needs a string for the accessible name. + <BaseSelect {...rest} emptyText={noOptionsLabel ?? "Nothing matches"} /> + ); +} diff --git a/packages/cli/test/upgrade/fixtures/local-barrel/expected/src/components/common/index.ts b/packages/cli/test/upgrade/fixtures/local-barrel/expected/src/components/common/index.ts new file mode 100644 index 0000000..530edca --- /dev/null +++ b/packages/cli/test/upgrade/fixtures/local-barrel/expected/src/components/common/index.ts @@ -0,0 +1,9 @@ +// The app's one import point for shared UI. +// TODO(ui-common-upgrade): re-exported under the 0.1 name, but the component is Astryx's now. The upgrade migrated the elements of it in the modules it scanned that import it from here; any other importer still passes 0.1 props. +export { Button } from "@lablup/ui-common/Button"; +// TODO(ui-common-upgrade): re-exported under the 0.1 name, but the component is Astryx's now. The upgrade migrated the elements of it in the modules it scanned that import it from here; any other importer still passes 0.1 props. +export { Card as BaseCard } from "@lablup/ui-common/Card"; +// TODO(ui-common-upgrade): type StatusKind was removed with StatusTag in 0.2 and has no Astryx counterpart. +export { Select } from "./Select"; +// TODO(ui-common-upgrade): SortDirection is no longer re-exported: ./table does not export it any more (removed in 0.2, no Astryx counterpart). +export * from "./status"; diff --git a/packages/cli/test/upgrade/fixtures/local-barrel/expected/src/components/common/status.ts b/packages/cli/test/upgrade/fixtures/local-barrel/expected/src/components/common/status.ts new file mode 100644 index 0000000..7b88d66 --- /dev/null +++ b/packages/cli/test/upgrade/fixtures/local-barrel/expected/src/components/common/status.ts @@ -0,0 +1,6 @@ +import { StatusDot } from "@lablup/ui-common/StatusDot"; +import { ProgressBar } from "@lablup/ui-common/ProgressBar"; + +export { StatusDot as StatusTag }; +// TODO(ui-common-upgrade): ProgressBar is used as a value here; props passed to it this way are not migrated to Astryx ProgressBar. +export const Meter = ProgressBar; diff --git a/packages/cli/test/upgrade/fixtures/local-barrel/expected/src/components/common/table.ts b/packages/cli/test/upgrade/fixtures/local-barrel/expected/src/components/common/table.ts new file mode 100644 index 0000000..b3197d3 --- /dev/null +++ b/packages/cli/test/upgrade/fixtures/local-barrel/expected/src/components/common/table.ts @@ -0,0 +1,3 @@ +// TODO(ui-common-upgrade): type SortDirection was removed with DataTable in 0.2 and has no Astryx counterpart. +// TODO(ui-common-upgrade): SortDirection (removed with DataTable in 0.2, no Astryx counterpart) is no longer re-exported from here; modules importing it from here need a type of their own. +export {}; diff --git a/packages/cli/test/upgrade/fixtures/local-barrel/expected/src/pages/Overview.tsx b/packages/cli/test/upgrade/fixtures/local-barrel/expected/src/pages/Overview.tsx new file mode 100644 index 0000000..4e737f8 --- /dev/null +++ b/packages/cli/test/upgrade/fixtures/local-barrel/expected/src/pages/Overview.tsx @@ -0,0 +1,24 @@ +import { ClickableCard } from "@lablup/ui-common/ClickableCard"; +import { useState } from "react"; +// TODO(ui-common-upgrade): type StatusKind was removed with StatusTag in 0.2 and has no Astryx counterpart; @/components/common no longer exports it. +import { Button, Meter, Select, StatusTag } from "@/components/common"; + +// TODO(ui-common-upgrade): type SortDirection was removed with DataTable in 0.2 and has no Astryx counterpart; ../components/common no longer exports it. +export function Overview({ kind, sort }: { kind: StatusKind; sort: SortDirection }) { + const [region, setRegion] = useState("kr"); + return ( + <ClickableCard onClick={() => setRegion("kr")} label="Open the overview"> + {/* TODO(ui-common-upgrade): StatusDot renders only the dot; `label` becomes its accessible name. Keep the visible text: <HStack gap={1}><StatusDot variant=... label={label} /><Text>{label}</Text></HStack>. */} + <StatusTag variant="success" label={kind} /> + {/* TODO(ui-common-upgrade): `label` is required and is the accessible name; the old visible `label` text maps to it with isLabelHidden when it was not shown. */} + <Meter value={40} variant="success" /> + <Button + variant="destructive" + size="sm" + onClick={() => setRegion("us")} + label="Reset" /> + <Select value={region} onChange={setRegion} options={[]} /> + <span>{sort}</span> + </ClickableCard> + ); +} diff --git a/packages/cli/test/upgrade/fixtures/local-barrel/expected/src/pages/Settings.tsx b/packages/cli/test/upgrade/fixtures/local-barrel/expected/src/pages/Settings.tsx new file mode 100644 index 0000000..30be482 --- /dev/null +++ b/packages/cli/test/upgrade/fixtures/local-barrel/expected/src/pages/Settings.tsx @@ -0,0 +1,13 @@ +import { Button } from "~/components/common"; +import { BaseCard } from "../components/common"; + +export function Settings({ save }: { save: () => void }) { + return ( + // TODO(ui-common-upgrade): variant (default | installed | available) maps to Card variant by intent: default -> "muted", installed -> "default", available -> "muted". + <BaseCard variant="outlined"> + <Button variant="primary" loading={false} onClick={save}> + Save + </Button> + </BaseCard> + ); +} diff --git a/packages/cli/test/upgrade/fixtures/local-barrel/expected/tsconfig.json b/packages/cli/test/upgrade/fixtures/local-barrel/expected/tsconfig.json new file mode 100644 index 0000000..542a54c --- /dev/null +++ b/packages/cli/test/upgrade/fixtures/local-barrel/expected/tsconfig.json @@ -0,0 +1,10 @@ +{ + // Feature code imports shared UI as "@/components/common". + "compilerOptions": { + "jsx": "react-jsx", + "paths": { + "@/*": ["./src/*"], + }, + }, + "include": ["src"], +} diff --git a/packages/cli/test/upgrade/fixtures/local-barrel/expected/ui-common-upgrade-report.md b/packages/cli/test/upgrade/fixtures/local-barrel/expected/ui-common-upgrade-report.md new file mode 100644 index 0000000..cfe124c --- /dev/null +++ b/packages/cli/test/upgrade/fixtures/local-barrel/expected/ui-common-upgrade-report.md @@ -0,0 +1,109 @@ +# ui-common upgrade report + +`ui-common upgrade` 0.1.0-alpha.23 → 0.2.0-alpha.0 (@lablup/ui-common-cli <version>). + +Ran the codemods over 6 files under `src`; searched 6 files under the project root for manual-review findings. + +## Action required + +- **Load @lablup/ui-common's stylesheets.** Nothing in this project loads them, and the upgrade found no entry script to import them from (no index.html module script, package.json `main`, or `src/main.*` / `src/index.*`). 0.1 components loaded their own CSS; 0.2 components load none, so the app renders unstyled until its entry stylesheet starts with: `@layer reset, theme, base, astryx-base, astryx-theme, ui-common, components, utilities; @import "@lablup/ui-common/reset.css"; @import "@lablup/ui-common/astryx.css"; @import "@lablup/ui-common/theme/lablup/theme.css"; @import "@lablup/ui-common/ui-common.css"; @import "@lablup/ui-common/legacy-tokens.css";` +- **Wrap the app in `<Theme theme={lablupTheme}>` (`import { Theme } from "@lablup/ui-common"`, `import { lablupTheme } from "@lablup/ui-common/theme/lablup/built"`).** No module uses `<Theme>`, and the upgrade found no `createRoot(…).render(…)` to wrap. Without it Astryx components get no theme; pass `mode` ("light" | "dark" | "system", the default) where the app switches colour schemes. + +## Summary + +| | Count | +|---|---:| +| Files changed | 6 | +| package.json changed | yes | +| TODO markers left in code | 14 | +| Manual-review findings | 1 | + +## Steps + +- 0.2.0-alpha.0: 0.1 → 0.2: ui-common on Astryx + +## Changed files + +- `src/components/common/Select/index.tsx`: +8 −4, components +- `src/components/common/index.ts`: +6 −4, components +- `src/components/common/status.ts`: +4 −2, components +- `src/components/common/table.ts`: +3 −3, components +- `src/pages/Overview.tsx`: +14 −8, components +- `src/pages/Settings.tsx`: +1 −0, components + +## package.json + +- dependencies["@lablup/ui-common"]: "0.1.0-alpha.23" → "0.2.0-alpha.0". +- added @lablup/ui-common-cli 0.2.0-alpha.0 to devDependencies: the `ui-common` bin ships in its own package since 0.2, released at the same version as @lablup/ui-common. +- added @stylexjs/stylex ^0.19.0 to dependencies. + +## Manual review + +### TODO markers (14) + +Each is a `TODO(ui-common-upgrade)` comment in the code, above the call it is about. Resolve it, then delete the comment. + +- `src/components/common/Select/index.tsx:7` props spread into <BaseSelect> are not migrated; check them against Astryx Selector's props. +- `src/components/common/Select/index.tsx:8` `label` is required and is a string; a node label needs a string for the accessible name. +- `src/components/common/index.ts:2` re-exported under the 0.1 name, but the component is Astryx's now. The upgrade migrated the elements of it in the modules it scanned that import it from here; any other importer still passes 0.1 props. +- `src/components/common/index.ts:4` re-exported under the 0.1 name, but the component is Astryx's now. The upgrade migrated the elements of it in the modules it scanned that import it from here; any other importer still passes 0.1 props. +- `src/components/common/index.ts:6` type StatusKind was removed with StatusTag in 0.2 and has no Astryx counterpart. +- `src/components/common/index.ts:8` SortDirection is no longer re-exported: ./table does not export it any more (removed in 0.2, no Astryx counterpart). +- `src/components/common/status.ts:5` ProgressBar is used as a value here; props passed to it this way are not migrated to Astryx ProgressBar. +- `src/components/common/table.ts:1` type SortDirection was removed with DataTable in 0.2 and has no Astryx counterpart. +- `src/components/common/table.ts:2` SortDirection (removed with DataTable in 0.2, no Astryx counterpart) is no longer re-exported from here; modules importing it from here need a type of their own. +- `src/pages/Overview.tsx:3` type StatusKind was removed with StatusTag in 0.2 and has no Astryx counterpart; @/components/common no longer exports it. +- `src/pages/Overview.tsx:6` type SortDirection was removed with DataTable in 0.2 and has no Astryx counterpart; ../components/common no longer exports it. +- `src/pages/Overview.tsx:11` StatusDot renders only the dot; `label` becomes its accessible name. Keep the visible text: <HStack gap={1}><StatusDot variant=... label={label} /><Text>{label}</Text></HStack>. +- `src/pages/Overview.tsx:13` `label` is required and is the accessible name; the old visible `label` text maps to it with isLabelHidden when it was not shown. +- `src/pages/Settings.tsx:6` variant (default | installed | available) maps to Card variant by intent: default -> "muted", installed -> "default", available -> "muted". + +### CSS selectors on 0.1 class names (0) + +None. + +### DOM hooks on 0.1 class names (0) + +None. + +### Tests querying 0.1 class names (0) + +None. + +### Module mocks of @lablup/ui-common (0) + +None. + +### Custom properties that collide with Astryx tokens (0) + +None. + +### 0.1 theme switches and theme selectors (0) + +None. + +### 0.1 class names your own CSS also defines (lower confidence) (0) + +None. + +### Local wrappers around 0.1 components (1) + +Your own component renders a 0.1 component and hands its props on, so it now renders the Astryx one. Its call sites pass the wrapper's props, which the upgrade does not rewrite: check the wrapper's props type and what it passes on against the Astryx component. A pure re-export (`export { Button } from …`) is not listed: its call sites were migrated. + +| Where | What | Detail | +|---|---|---| +| `src/components/common/Select/index.tsx:5` | `Select` | local wrapper around Select (Astryx Selector): review its props. Imported by 1 scanned module, whose props were not migrated. | + +### 0.1 stylesheet paths left in place (0) + +None. + +## Notes + +- Imports through `~/` (2) did not resolve: the upgrade reads relative imports and tsconfig `paths`, not bundler aliases. Elements of 0.1 components imported through a project barrel that way were not migrated; check those modules by hand. +- BaseCard → Card (@lablup/ui-common/Card); or ClickableCard (@lablup/ui-common/ClickableCard) when the card has onClick or clickable; ClickableCard requires a `label` (take it from ariaLabel). variant (default | installed | available) maps to Card variant by intent: default -> "muted", installed -> "default", available -> "muted". state (loading | active | disabled | warning) has no Card counterpart; express it in the content, or use ClickableCard isDisabled for disabled. direction="row" has no Card counterpart; wrap the children in an HStack. hoverable, clickable, onKeyDown, role and tabIndex: ClickableCard owns hover, focus and keyboard activation. The .base-card classes and the --corner-accent-color property are gone. +- Button → Button (@lablup/ui-common/Button); or IconButton (@lablup/ui-common/IconButton) when iconOnly is set; the accessible name moves from ariaLabel to label. `label` is required. A non-string child needs `label` for the accessible name and the node as children. variant="success" has no Button variant; use primary. iconPosition="right" becomes `endContent` (an Icon or Badge element only). shape="circle", inline and active have no counterpart. The .button / .button--* classes are gone; Astryx's stable class is .astryx-button. +- ProgressBar → ProgressBar (@lablup/ui-common/ProgressBar). value={null} becomes isIndeterminate (and no value). `label` is required and is the accessible name; the old visible `label` text maps to it with isLabelHidden when it was not shown. size and animated have no counterpart. The .progress-bar classes are gone. +- Select → Selector (@lablup/ui-common/Selector). `label` is required and is a string; a node label needs a string for the accessible name. invalid becomes status={{ type: "error" }}. fullWidth, onBlur and aria-describedby (use `description`) have no direct counterpart. Selector is not generic over the value type; onChange receives a string. The .select classes are gone. +- StatusTag → StatusDot (@lablup/ui-common/StatusDot). StatusDot renders only the dot; `label` becomes its accessible name. Keep the visible text: <HStack gap={1}><StatusDot variant=... label={label} /><Text>{label}</Text></HStack>. size has no counterpart. The .status-tag classes and data-testid="status-tag-indicator" are gone. +- Products' own `--token-*` reads were not rewritten: they belong to your token system. `legacy-tokens.css` keeps them resolving until 0.3. diff --git a/packages/cli/test/upgrade/fixtures/local-barrel/input/package.json b/packages/cli/test/upgrade/fixtures/local-barrel/input/package.json new file mode 100644 index 0000000..93a40c5 --- /dev/null +++ b/packages/cli/test/upgrade/fixtures/local-barrel/input/package.json @@ -0,0 +1,9 @@ +{ + "name": "local-barrel-app", + "private": true, + "type": "module", + "dependencies": { + "@lablup/ui-common": "0.1.0-alpha.23", + "react": "^19.2.0" + } +} diff --git a/packages/cli/test/upgrade/fixtures/local-barrel/input/src/components/common/Select/index.tsx b/packages/cli/test/upgrade/fixtures/local-barrel/input/src/components/common/Select/index.tsx new file mode 100644 index 0000000..b435d71 --- /dev/null +++ b/packages/cli/test/upgrade/fixtures/local-barrel/input/src/components/common/Select/index.tsx @@ -0,0 +1,7 @@ +import { Select as BaseSelect } from "@lablup/ui-common/components/Select"; +import type { SelectProps } from "@lablup/ui-common/components/Select"; + +// Supplies the empty-search line the package cannot localise. +export function Select({ noOptionsLabel, ...rest }: SelectProps) { + return <BaseSelect {...rest} noOptionsLabel={noOptionsLabel ?? "Nothing matches"} />; +} diff --git a/packages/cli/test/upgrade/fixtures/local-barrel/input/src/components/common/index.ts b/packages/cli/test/upgrade/fixtures/local-barrel/input/src/components/common/index.ts new file mode 100644 index 0000000..6e47fd7 --- /dev/null +++ b/packages/cli/test/upgrade/fixtures/local-barrel/input/src/components/common/index.ts @@ -0,0 +1,7 @@ +// The app's one import point for shared UI. +export { Button } from "@lablup/ui-common/components/Button"; +export { BaseCard } from "@lablup/ui-common/components/BaseCard"; +export type { StatusKind } from "@lablup/ui-common/components/StatusTag"; +export { Select } from "./Select"; +export type { SortDirection } from "./table"; +export * from "./status"; diff --git a/packages/cli/test/upgrade/fixtures/local-barrel/input/src/components/common/status.ts b/packages/cli/test/upgrade/fixtures/local-barrel/input/src/components/common/status.ts new file mode 100644 index 0000000..9ed3057 --- /dev/null +++ b/packages/cli/test/upgrade/fixtures/local-barrel/input/src/components/common/status.ts @@ -0,0 +1,4 @@ +import { StatusTag, ProgressBar } from "@lablup/ui-common"; + +export { StatusTag }; +export const Meter = ProgressBar; diff --git a/packages/cli/test/upgrade/fixtures/local-barrel/input/src/components/common/table.ts b/packages/cli/test/upgrade/fixtures/local-barrel/input/src/components/common/table.ts new file mode 100644 index 0000000..b80c7d3 --- /dev/null +++ b/packages/cli/test/upgrade/fixtures/local-barrel/input/src/components/common/table.ts @@ -0,0 +1,3 @@ +import type { SortDirection } from "@lablup/ui-common/components/DataTable"; + +export type { SortDirection }; diff --git a/packages/cli/test/upgrade/fixtures/local-barrel/input/src/pages/Overview.tsx b/packages/cli/test/upgrade/fixtures/local-barrel/input/src/pages/Overview.tsx new file mode 100644 index 0000000..cd80ebe --- /dev/null +++ b/packages/cli/test/upgrade/fixtures/local-barrel/input/src/pages/Overview.tsx @@ -0,0 +1,18 @@ +import { useState } from "react"; +import { BaseCard, Button, Meter, Select, StatusTag, type StatusKind } from "@/components/common"; +import type { SortDirection } from "../components/common"; + +export function Overview({ kind, sort }: { kind: StatusKind; sort: SortDirection }) { + const [region, setRegion] = useState("kr"); + return ( + <BaseCard onClick={() => setRegion("kr")} ariaLabel="Open the overview"> + <StatusTag state="running" label={kind} /> + <Meter value={40} variant="success" /> + <Button variant="danger" size="small" onClick={() => setRegion("us")}> + Reset + </Button> + <Select value={region} onChange={setRegion} options={[]} /> + <span>{sort}</span> + </BaseCard> + ); +} diff --git a/packages/cli/test/upgrade/fixtures/local-barrel/input/src/pages/Settings.tsx b/packages/cli/test/upgrade/fixtures/local-barrel/input/src/pages/Settings.tsx new file mode 100644 index 0000000..74e3aa4 --- /dev/null +++ b/packages/cli/test/upgrade/fixtures/local-barrel/input/src/pages/Settings.tsx @@ -0,0 +1,12 @@ +import { Button } from "~/components/common"; +import { BaseCard } from "../components/common"; + +export function Settings({ save }: { save: () => void }) { + return ( + <BaseCard variant="outlined"> + <Button variant="primary" loading={false} onClick={save}> + Save + </Button> + </BaseCard> + ); +} diff --git a/packages/cli/test/upgrade/fixtures/local-barrel/input/tsconfig.json b/packages/cli/test/upgrade/fixtures/local-barrel/input/tsconfig.json new file mode 100644 index 0000000..542a54c --- /dev/null +++ b/packages/cli/test/upgrade/fixtures/local-barrel/input/tsconfig.json @@ -0,0 +1,10 @@ +{ + // Feature code imports shared UI as "@/components/common". + "compilerOptions": { + "jsx": "react-jsx", + "paths": { + "@/*": ["./src/*"], + }, + }, + "include": ["src"], +} diff --git a/test/upgrade/fixtures/root-barrel/expected/package.json b/packages/cli/test/upgrade/fixtures/root-barrel/expected/package.json similarity index 75% rename from test/upgrade/fixtures/root-barrel/expected/package.json rename to packages/cli/test/upgrade/fixtures/root-barrel/expected/package.json index b672d4e..f6611e0 100644 --- a/test/upgrade/fixtures/root-barrel/expected/package.json +++ b/packages/cli/test/upgrade/fixtures/root-barrel/expected/package.json @@ -7,5 +7,8 @@ "@stylexjs/stylex": "^0.19.0", "react": "^19.2.0", "react-dom": "^19.2.0" + }, + "devDependencies": { + "@lablup/ui-common-cli": "0.2.0-alpha.0" } } diff --git a/test/upgrade/fixtures/root-barrel/expected/src/chat/InputPopup.tsx b/packages/cli/test/upgrade/fixtures/root-barrel/expected/src/chat/InputPopup.tsx similarity index 100% rename from test/upgrade/fixtures/root-barrel/expected/src/chat/InputPopup.tsx rename to packages/cli/test/upgrade/fixtures/root-barrel/expected/src/chat/InputPopup.tsx diff --git a/test/upgrade/fixtures/root-barrel/expected/src/pages/ModelsPage.test.tsx b/packages/cli/test/upgrade/fixtures/root-barrel/expected/src/pages/ModelsPage.test.tsx similarity index 100% rename from test/upgrade/fixtures/root-barrel/expected/src/pages/ModelsPage.test.tsx rename to packages/cli/test/upgrade/fixtures/root-barrel/expected/src/pages/ModelsPage.test.tsx diff --git a/test/upgrade/fixtures/root-barrel/expected/src/pages/ModelsPage.tsx b/packages/cli/test/upgrade/fixtures/root-barrel/expected/src/pages/ModelsPage.tsx similarity index 100% rename from test/upgrade/fixtures/root-barrel/expected/src/pages/ModelsPage.tsx rename to packages/cli/test/upgrade/fixtures/root-barrel/expected/src/pages/ModelsPage.tsx diff --git a/test/upgrade/fixtures/root-barrel/expected/src/themes/violet.css b/packages/cli/test/upgrade/fixtures/root-barrel/expected/src/themes/violet.css similarity index 100% rename from test/upgrade/fixtures/root-barrel/expected/src/themes/violet.css rename to packages/cli/test/upgrade/fixtures/root-barrel/expected/src/themes/violet.css diff --git a/test/upgrade/fixtures/root-barrel/expected/ui-common-upgrade-report.md b/packages/cli/test/upgrade/fixtures/root-barrel/expected/ui-common-upgrade-report.md similarity index 76% rename from test/upgrade/fixtures/root-barrel/expected/ui-common-upgrade-report.md rename to packages/cli/test/upgrade/fixtures/root-barrel/expected/ui-common-upgrade-report.md index 608a87b..bcb56c5 100644 --- a/test/upgrade/fixtures/root-barrel/expected/ui-common-upgrade-report.md +++ b/packages/cli/test/upgrade/fixtures/root-barrel/expected/ui-common-upgrade-report.md @@ -1,8 +1,13 @@ # ui-common upgrade report -`ui-common upgrade` 0.1.0-alpha.7 → 0.2.0-alpha.0 (installed @lablup/ui-common <version>). +`ui-common upgrade` 0.1.0-alpha.7 → 0.2.0-alpha.0 (@lablup/ui-common-cli <version>). -Scanned 4 files under `src`. +Ran the codemods over 4 files under `src`; searched 4 files under the project root for manual-review findings. + +## Action required + +- **Load @lablup/ui-common's stylesheets.** Nothing in this project loads them, and the upgrade found no entry script to import them from (no index.html module script, package.json `main`, or `src/main.*` / `src/index.*`). 0.1 components loaded their own CSS; 0.2 components load none, so the app renders unstyled until its entry stylesheet starts with: `@layer reset, theme, base, astryx-base, astryx-theme, ui-common, components, utilities; @import "@lablup/ui-common/reset.css"; @import "@lablup/ui-common/astryx.css"; @import "@lablup/ui-common/theme/lablup/theme.css"; @import "@lablup/ui-common/ui-common.css"; @import "@lablup/ui-common/legacy-tokens.css";` +- **Wrap the app in `<Theme theme={lablupTheme}>` (`import { Theme } from "@lablup/ui-common"`, `import { lablupTheme } from "@lablup/ui-common/theme/lablup/built"`).** No module uses `<Theme>`, and the upgrade found no `createRoot(…).render(…)` to wrap. Without it Astryx components get no theme; pass `mode` ("light" | "dark" | "system", the default) where the app switches colour schemes. ## Summary @@ -11,7 +16,7 @@ Scanned 4 files under `src`. | Files changed | 1 | | package.json changed | yes | | TODO markers left in code | 10 | -| Manual-review findings | 9 | +| Manual-review findings | 10 | ## Steps @@ -24,6 +29,7 @@ Scanned 4 files under `src`. ## package.json - dependencies["@lablup/ui-common"]: "0.1.0-alpha.7" → "0.2.0-alpha.0". +- added @lablup/ui-common-cli 0.2.0-alpha.0 to devDependencies: the `ui-common` bin ships in its own package since 0.2, released at the same version as @lablup/ui-common. - added @stylexjs/stylex ^0.19.0 to dependencies. ## Manual review @@ -87,6 +93,22 @@ Astryx declares the same name. Whichever rule wins the cascade now restyles both | `src/themes/violet.css:3` | `[data-theme="violet-light"] { --color-border: #e0dcf5 }` | --color-border | | `src/themes/violet.css:4` | `[data-theme="violet-light"] { --color-text-primary: #1b1535 }` | --color-text-primary | +### 0.1 theme switches and theme selectors (1) + +0.1 switched themes by stylesheet and `html[data-theme="orange-…"]`. In 0.2 `<Theme theme={lablupTheme} mode=…>` owns `html[data-theme]` and sets it to `light` or `dark`, so code that writes another value fights it, and selectors on another value never match. + +| Where | What | Detail | +|---|---|---| +| `src/themes/violet.css:1` | `[data-theme="violet-light"] {` | data-theme = "violet-light" (2 selectors in this file): `<Theme>` only ever sets `html[data-theme]` to "light" or "dark": select on `html[data-theme="dark"]` (or "light"), and put brand colours in the theme. | + +### 0.1 class names your own CSS also defines (lower confidence) (0) + +None. + +### Local wrappers around 0.1 components (0) + +None. + ### 0.1 stylesheet paths left in place (0) None. diff --git a/test/upgrade/fixtures/root-barrel/input/package.json b/packages/cli/test/upgrade/fixtures/root-barrel/input/package.json similarity index 100% rename from test/upgrade/fixtures/root-barrel/input/package.json rename to packages/cli/test/upgrade/fixtures/root-barrel/input/package.json diff --git a/test/upgrade/fixtures/root-barrel/input/src/chat/InputPopup.tsx b/packages/cli/test/upgrade/fixtures/root-barrel/input/src/chat/InputPopup.tsx similarity index 100% rename from test/upgrade/fixtures/root-barrel/input/src/chat/InputPopup.tsx rename to packages/cli/test/upgrade/fixtures/root-barrel/input/src/chat/InputPopup.tsx diff --git a/test/upgrade/fixtures/root-barrel/input/src/pages/ModelsPage.test.tsx b/packages/cli/test/upgrade/fixtures/root-barrel/input/src/pages/ModelsPage.test.tsx similarity index 100% rename from test/upgrade/fixtures/root-barrel/input/src/pages/ModelsPage.test.tsx rename to packages/cli/test/upgrade/fixtures/root-barrel/input/src/pages/ModelsPage.test.tsx diff --git a/test/upgrade/fixtures/root-barrel/input/src/pages/ModelsPage.tsx b/packages/cli/test/upgrade/fixtures/root-barrel/input/src/pages/ModelsPage.tsx similarity index 100% rename from test/upgrade/fixtures/root-barrel/input/src/pages/ModelsPage.tsx rename to packages/cli/test/upgrade/fixtures/root-barrel/input/src/pages/ModelsPage.tsx diff --git a/test/upgrade/fixtures/root-barrel/input/src/themes/violet.css b/packages/cli/test/upgrade/fixtures/root-barrel/input/src/themes/violet.css similarity index 100% rename from test/upgrade/fixtures/root-barrel/input/src/themes/violet.css rename to packages/cli/test/upgrade/fixtures/root-barrel/input/src/themes/violet.css diff --git a/test/upgrade/fixtures/subpath-barrel/expected/package.json b/packages/cli/test/upgrade/fixtures/subpath-barrel/expected/package.json similarity index 86% rename from test/upgrade/fixtures/subpath-barrel/expected/package.json rename to packages/cli/test/upgrade/fixtures/subpath-barrel/expected/package.json index 73f1af1..ff94cfb 100644 --- a/test/upgrade/fixtures/subpath-barrel/expected/package.json +++ b/packages/cli/test/upgrade/fixtures/subpath-barrel/expected/package.json @@ -9,6 +9,7 @@ "react": "^19.2.0" }, "devDependencies": { + "@lablup/ui-common-cli": "0.2.0-alpha.0", "vitest": "^4.0.0" } } diff --git a/test/upgrade/fixtures/subpath-barrel/expected/src/components/common/DataTableWrapper.tsx b/packages/cli/test/upgrade/fixtures/subpath-barrel/expected/src/components/common/DataTableWrapper.tsx similarity index 100% rename from test/upgrade/fixtures/subpath-barrel/expected/src/components/common/DataTableWrapper.tsx rename to packages/cli/test/upgrade/fixtures/subpath-barrel/expected/src/components/common/DataTableWrapper.tsx diff --git a/test/upgrade/fixtures/subpath-barrel/input/src/components/common/Select.test.tsx b/packages/cli/test/upgrade/fixtures/subpath-barrel/expected/src/components/common/Select.test.tsx similarity index 70% rename from test/upgrade/fixtures/subpath-barrel/input/src/components/common/Select.test.tsx rename to packages/cli/test/upgrade/fixtures/subpath-barrel/expected/src/components/common/Select.test.tsx index 2442573..c6ae01b 100644 --- a/test/upgrade/fixtures/subpath-barrel/input/src/components/common/Select.test.tsx +++ b/packages/cli/test/upgrade/fixtures/subpath-barrel/expected/src/components/common/Select.test.tsx @@ -2,6 +2,7 @@ import { render } from "@testing-library/react"; import { Select } from "./index"; it("opens", () => { + // TODO(ui-common-upgrade): `label` is required and is a string; a node label needs a string for the accessible name. const { container } = render(<Select value="a" onChange={() => {}} options={[]} />); const trigger = container.querySelector(".select__trigger"); expect(trigger).not.toBeNull(); diff --git a/test/upgrade/fixtures/subpath-barrel/expected/src/components/common/index.ts b/packages/cli/test/upgrade/fixtures/subpath-barrel/expected/src/components/common/index.ts similarity index 58% rename from test/upgrade/fixtures/subpath-barrel/expected/src/components/common/index.ts rename to packages/cli/test/upgrade/fixtures/subpath-barrel/expected/src/components/common/index.ts index de92847..62a7e50 100644 --- a/test/upgrade/fixtures/subpath-barrel/expected/src/components/common/index.ts +++ b/packages/cli/test/upgrade/fixtures/subpath-barrel/expected/src/components/common/index.ts @@ -1,16 +1,16 @@ // Shared components: the app imports these from "@/components/common". -// TODO(ui-common-upgrade): re-exported under the 0.1 name, but the component is Astryx's now; modules importing it from here still pass 0.1 props and need the same migration. +// TODO(ui-common-upgrade): re-exported under the 0.1 name, but the component is Astryx's now. The upgrade migrated the elements of it in the modules it scanned that import it from here; any other importer still passes 0.1 props. export { Button } from "@lablup/ui-common/Button"; -// TODO(ui-common-upgrade): re-exported under the 0.1 name, but the component is Astryx's now; modules importing it from here still pass 0.1 props and need the same migration. +// TODO(ui-common-upgrade): re-exported under the 0.1 name, but the component is Astryx's now. The upgrade migrated the elements of it in the modules it scanned that import it from here; any other importer still passes 0.1 props. export { Selector as Select } from "@lablup/ui-common/Selector"; export type { SelectorOptionData as SelectOption, SelectorProps as SelectProps } from "@lablup/ui-common/Selector"; -// TODO(ui-common-upgrade): re-exported under the 0.1 name, but the component is Astryx's now; modules importing it from here still pass 0.1 props and need the same migration. +// TODO(ui-common-upgrade): re-exported under the 0.1 name, but the component is Astryx's now. The upgrade migrated the elements of it in the modules it scanned that import it from here; any other importer still passes 0.1 props. export { Skeleton } from "@lablup/ui-common/Skeleton"; export { SkeletonCard } from "@lablup/ui-common/components/Skeleton"; export { PageHeader } from "@lablup/ui-common/components/PageHeader"; -// TODO(ui-common-upgrade): re-exported under the 0.1 name, but the component is Astryx's now; modules importing it from here still pass 0.1 props and need the same migration. +// TODO(ui-common-upgrade): re-exported under the 0.1 name, but the component is Astryx's now. The upgrade migrated the elements of it in the modules it scanned that import it from here; any other importer still passes 0.1 props. export { StatusDot as StatusTag } from "@lablup/ui-common/StatusDot"; export { usePrefersReducedMotion } from "@lablup/ui-common"; export { DataTableWrapper } from "./DataTableWrapper"; diff --git a/test/upgrade/fixtures/subpath-barrel/expected/src/reports/ReportPreviewFrame.tsx b/packages/cli/test/upgrade/fixtures/subpath-barrel/expected/src/reports/ReportPreviewFrame.tsx similarity index 100% rename from test/upgrade/fixtures/subpath-barrel/expected/src/reports/ReportPreviewFrame.tsx rename to packages/cli/test/upgrade/fixtures/subpath-barrel/expected/src/reports/ReportPreviewFrame.tsx diff --git a/test/upgrade/fixtures/subpath-barrel/expected/src/styles/families.css b/packages/cli/test/upgrade/fixtures/subpath-barrel/expected/src/styles/families.css similarity index 100% rename from test/upgrade/fixtures/subpath-barrel/expected/src/styles/families.css rename to packages/cli/test/upgrade/fixtures/subpath-barrel/expected/src/styles/families.css diff --git a/test/upgrade/fixtures/subpath-barrel/expected/src/styles/theme.ts b/packages/cli/test/upgrade/fixtures/subpath-barrel/expected/src/styles/theme.ts similarity index 100% rename from test/upgrade/fixtures/subpath-barrel/expected/src/styles/theme.ts rename to packages/cli/test/upgrade/fixtures/subpath-barrel/expected/src/styles/theme.ts diff --git a/test/upgrade/fixtures/subpath-barrel/expected/src/styles/ui-common-entry.css b/packages/cli/test/upgrade/fixtures/subpath-barrel/expected/src/styles/ui-common-entry.css similarity index 100% rename from test/upgrade/fixtures/subpath-barrel/expected/src/styles/ui-common-entry.css rename to packages/cli/test/upgrade/fixtures/subpath-barrel/expected/src/styles/ui-common-entry.css diff --git a/test/upgrade/fixtures/subpath-barrel/expected/ui-common-upgrade-report.md b/packages/cli/test/upgrade/fixtures/subpath-barrel/expected/ui-common-upgrade-report.md similarity index 69% rename from test/upgrade/fixtures/subpath-barrel/expected/ui-common-upgrade-report.md rename to packages/cli/test/upgrade/fixtures/subpath-barrel/expected/ui-common-upgrade-report.md index 8fa34b7..0a12f4d 100644 --- a/test/upgrade/fixtures/subpath-barrel/expected/ui-common-upgrade-report.md +++ b/packages/cli/test/upgrade/fixtures/subpath-barrel/expected/ui-common-upgrade-report.md @@ -1,17 +1,21 @@ # ui-common upgrade report -`ui-common upgrade` 0.1.0-alpha.19 → 0.2.0-alpha.0 (installed @lablup/ui-common <version>). +`ui-common upgrade` 0.1.0-alpha.19 → 0.2.0-alpha.0 (@lablup/ui-common-cli <version>). -Scanned 6 files under `src`. +Ran the codemods over 6 files under `src`; searched 6 files under the project root for manual-review findings. + +## Action required + +- **Wrap the app in `<Theme theme={lablupTheme}>` (`import { Theme } from "@lablup/ui-common"`, `import { lablupTheme } from "@lablup/ui-common/theme/lablup/built"`).** No module uses `<Theme>`, and the upgrade found no `createRoot(…).render(…)` to wrap. Without it Astryx components get no theme; pass `mode` ("light" | "dark" | "system", the default) where the app switches colour schemes. ## Summary | | Count | |---|---:| -| Files changed | 4 | +| Files changed | 5 | | package.json changed | yes | -| TODO markers left in code | 11 | -| Manual-review findings | 5 | +| TODO markers left in code | 12 | +| Manual-review findings | 7 | ## Steps @@ -20,6 +24,7 @@ Scanned 6 files under `src`. ## Changed files - `src/components/common/DataTableWrapper.tsx`: +35 −23, components +- `src/components/common/Select.test.tsx`: +1 −0, components - `src/components/common/index.ts`: +13 −6, components - `src/styles/theme.ts`: +1 −2, script-stylesheet-imports - `src/styles/ui-common-entry.css` (new): +15 −0, stylesheet-entry @@ -27,13 +32,14 @@ Scanned 6 files under `src`. ## package.json - dependencies["@lablup/ui-common"]: "^0.1.0-alpha.19" → "^0.2.0-alpha.0". +- added @lablup/ui-common-cli 0.2.0-alpha.0 to devDependencies: the `ui-common` bin ships in its own package since 0.2, released at the same version as @lablup/ui-common. - added @stylexjs/stylex ^0.19.0 to dependencies. - added @astryxdesign/lab 0.6.2-canary.c9fb1ad to dependencies: a Drawer moved to @lablup/ui-common/lab, and ui-common pins the lab canary exactly. - no package manager was detected, so point @astryxdesign/lab's @astryxdesign/core peer at ui-common's by hand (pnpm: `overrides: { "@astryxdesign/lab>@astryxdesign/core": "0.6.2" }` in pnpm-workspace.yaml; npm: `"overrides": {"@astryxdesign/lab":{"@astryxdesign/core":"0.6.2"}}` in the root package.json) and check that `why @astryxdesign/core` lists one version. ## Manual review -### TODO markers (11) +### TODO markers (12) Each is a `TODO(ui-common-upgrade)` comment in the code, above the call it is about. Resolve it, then delete the comment. @@ -44,10 +50,11 @@ Each is a `TODO(ui-common-upgrade)` comment in the code, above the call it is ab - `src/components/common/DataTableWrapper.tsx:54` onRowClick, isRowClickable and rowClassName: use the row-interaction plugin or children mode. - `src/components/common/DataTableWrapper.tsx:69` lab Drawer renders no header: render the title, subtitle and footer inside children. - `src/components/common/DataTableWrapper.tsx:70` preventDismiss and onDismissAttempt: decline the close in onOpenChange. -- `src/components/common/index.ts:2` re-exported under the 0.1 name, but the component is Astryx's now; modules importing it from here still pass 0.1 props and need the same migration. -- `src/components/common/index.ts:4` re-exported under the 0.1 name, but the component is Astryx's now; modules importing it from here still pass 0.1 props and need the same migration. -- `src/components/common/index.ts:8` re-exported under the 0.1 name, but the component is Astryx's now; modules importing it from here still pass 0.1 props and need the same migration. -- `src/components/common/index.ts:13` re-exported under the 0.1 name, but the component is Astryx's now; modules importing it from here still pass 0.1 props and need the same migration. +- `src/components/common/Select.test.tsx:5` `label` is required and is a string; a node label needs a string for the accessible name. +- `src/components/common/index.ts:2` re-exported under the 0.1 name, but the component is Astryx's now. The upgrade migrated the elements of it in the modules it scanned that import it from here; any other importer still passes 0.1 props. +- `src/components/common/index.ts:4` re-exported under the 0.1 name, but the component is Astryx's now. The upgrade migrated the elements of it in the modules it scanned that import it from here; any other importer still passes 0.1 props. +- `src/components/common/index.ts:8` re-exported under the 0.1 name, but the component is Astryx's now. The upgrade migrated the elements of it in the modules it scanned that import it from here; any other importer still passes 0.1 props. +- `src/components/common/index.ts:13` re-exported under the 0.1 name, but the component is Astryx's now. The upgrade migrated the elements of it in the modules it scanned that import it from here; any other importer still passes 0.1 props. ### CSS selectors on 0.1 class names (2) @@ -72,7 +79,7 @@ Query by role, label or data-testid instead. | Where | What | Detail | |---|---|---| -| `src/components/common/Select.test.tsx:6` | `const trigger = container.querySelector(".select__trigger");` | .select__trigger (Select): gone | +| `src/components/common/Select.test.tsx:7` | `const trigger = container.querySelector(".select__trigger");` | .select__trigger (Select): gone | ### Module mocks of @lablup/ui-common (0) @@ -86,12 +93,30 @@ Astryx declares the same name. Whichever rule wins the cascade now restyles both |---|---|---| | `src/styles/families.css:6` | `:root { --color-text-secondary: #6b7280 }` | --color-text-secondary | +### 0.1 theme switches and theme selectors (2) + +0.1 switched themes by stylesheet and `html[data-theme="orange-…"]`. In 0.2 `<Theme theme={lablupTheme} mode=…>` owns `html[data-theme]` and sets it to `light` or `dark`, so code that writes another value fights it, and selectors on another value never match. + +| Where | What | Detail | +|---|---|---| +| `src/styles/families.css:1` | `[data-theme="orange-light"] .tabs__tab--active {` | data-theme = "orange-light": `<Theme>` only ever sets `html[data-theme]` to "light" or "dark": select on `html[data-theme="dark"]` (or "light"), and put brand colours in the theme. | +| `src/styles/theme.ts:5` | `export const THEMES = ["orange-light", "orange-dark"] as const;` | 0.1 theme name "orange-light". `<Theme>` sets `html[data-theme]` to "light" or "dark" itself: pass the mode as `<Theme theme={lablupTheme} mode="light" \| "dark" \| "system">` instead of writing the attribute. | + +### 0.1 class names your own CSS also defines (lower confidence) (0) + +None. + +### Local wrappers around 0.1 components (0) + +None. + ### 0.1 stylesheet paths left in place (0) None. ## Notes +- Imports through `@/` (1) did not resolve: the upgrade reads relative imports and tsconfig `paths`, not bundler aliases. Elements of 0.1 components imported through a project barrel that way were not migrated; check those modules by hand. - DataTable → Table (@lablup/ui-common/Table). Sorting, column resizing, column visibility and persisted column state are Table plugins (`plugins`), not props. loading, loadingState and emptyState: render them around the Table. onRowClick, isRowClickable and rowClassName: use the row-interaction plugin or children mode. The .data-table classes are gone. - Drawer → Drawer (@lablup/ui-common/lab). lab Drawer renders no header: render the title, subtitle and footer inside children. closeLabel, ariaLabelledBy and ariaDescribedBy have no counterpart. preventDismiss and onDismissAttempt: decline the close in onOpenChange. The .drawer classes are gone. - EmptyState → EmptyState (@lablup/ui-common/EmptyState). primaryAction and secondaryAction become `actions`, a node: <Button variant="primary" label={a.label} onClick={a.onClick} /> and a secondary Button, or a Link for a secondaryAction with href. showIllustration={false}: omit `icon`. children has no slot; put it in `actions` or below the EmptyState. The title renders as an h3 by default; set headingLevel to fit the outline. The .empty-state classes are gone. diff --git a/test/upgrade/fixtures/subpath-barrel/input/package.json b/packages/cli/test/upgrade/fixtures/subpath-barrel/input/package.json similarity index 100% rename from test/upgrade/fixtures/subpath-barrel/input/package.json rename to packages/cli/test/upgrade/fixtures/subpath-barrel/input/package.json diff --git a/test/upgrade/fixtures/subpath-barrel/input/src/components/common/DataTableWrapper.tsx b/packages/cli/test/upgrade/fixtures/subpath-barrel/input/src/components/common/DataTableWrapper.tsx similarity index 100% rename from test/upgrade/fixtures/subpath-barrel/input/src/components/common/DataTableWrapper.tsx rename to packages/cli/test/upgrade/fixtures/subpath-barrel/input/src/components/common/DataTableWrapper.tsx diff --git a/test/upgrade/fixtures/subpath-barrel/expected/src/components/common/Select.test.tsx b/packages/cli/test/upgrade/fixtures/subpath-barrel/input/src/components/common/Select.test.tsx similarity index 100% rename from test/upgrade/fixtures/subpath-barrel/expected/src/components/common/Select.test.tsx rename to packages/cli/test/upgrade/fixtures/subpath-barrel/input/src/components/common/Select.test.tsx diff --git a/test/upgrade/fixtures/subpath-barrel/input/src/components/common/index.ts b/packages/cli/test/upgrade/fixtures/subpath-barrel/input/src/components/common/index.ts similarity index 100% rename from test/upgrade/fixtures/subpath-barrel/input/src/components/common/index.ts rename to packages/cli/test/upgrade/fixtures/subpath-barrel/input/src/components/common/index.ts diff --git a/test/upgrade/fixtures/subpath-barrel/input/src/reports/ReportPreviewFrame.tsx b/packages/cli/test/upgrade/fixtures/subpath-barrel/input/src/reports/ReportPreviewFrame.tsx similarity index 100% rename from test/upgrade/fixtures/subpath-barrel/input/src/reports/ReportPreviewFrame.tsx rename to packages/cli/test/upgrade/fixtures/subpath-barrel/input/src/reports/ReportPreviewFrame.tsx diff --git a/test/upgrade/fixtures/subpath-barrel/input/src/styles/families.css b/packages/cli/test/upgrade/fixtures/subpath-barrel/input/src/styles/families.css similarity index 100% rename from test/upgrade/fixtures/subpath-barrel/input/src/styles/families.css rename to packages/cli/test/upgrade/fixtures/subpath-barrel/input/src/styles/families.css diff --git a/test/upgrade/fixtures/subpath-barrel/input/src/styles/theme.ts b/packages/cli/test/upgrade/fixtures/subpath-barrel/input/src/styles/theme.ts similarity index 100% rename from test/upgrade/fixtures/subpath-barrel/input/src/styles/theme.ts rename to packages/cli/test/upgrade/fixtures/subpath-barrel/input/src/styles/theme.ts diff --git a/packages/cli/test/upgrade/fixtures/unwired/expected/index.html b/packages/cli/test/upgrade/fixtures/unwired/expected/index.html new file mode 100644 index 0000000..5f12e17 --- /dev/null +++ b/packages/cli/test/upgrade/fixtures/unwired/expected/index.html @@ -0,0 +1,11 @@ +<!doctype html> +<html lang="en"> + <head> + <meta charset="UTF-8" /> + <title>App + + +
+ + + diff --git a/packages/cli/test/upgrade/fixtures/unwired/expected/package.json b/packages/cli/test/upgrade/fixtures/unwired/expected/package.json new file mode 100644 index 0000000..508b181 --- /dev/null +++ b/packages/cli/test/upgrade/fixtures/unwired/expected/package.json @@ -0,0 +1,14 @@ +{ + "name": "unwired-app", + "private": true, + "type": "module", + "dependencies": { + "@lablup/ui-common": "0.2.0-alpha.0", + "@stylexjs/stylex": "^0.19.0", + "react": "^19.2.0", + "react-dom": "^19.2.0" + }, + "devDependencies": { + "@lablup/ui-common-cli": "0.2.0-alpha.0" + } +} diff --git a/packages/cli/test/upgrade/fixtures/unwired/expected/src/App.tsx b/packages/cli/test/upgrade/fixtures/unwired/expected/src/App.tsx new file mode 100644 index 0000000..55da74c --- /dev/null +++ b/packages/cli/test/upgrade/fixtures/unwired/expected/src/App.tsx @@ -0,0 +1,5 @@ +import { Button } from "@lablup/ui-common/Button"; + +export function App() { + return ( + ); +} diff --git a/packages/cli/test/upgrade/fixtures/unwired/input/src/index.css b/packages/cli/test/upgrade/fixtures/unwired/input/src/index.css new file mode 100644 index 0000000..b9cdb7c --- /dev/null +++ b/packages/cli/test/upgrade/fixtures/unwired/input/src/index.css @@ -0,0 +1,13 @@ +@layer components { + .app { + padding: 8px; + } +} + +[data-theme="orange-dark"] .app { + background: black; +} + +html[data-theme="dark"] .app { + color: white; +} diff --git a/packages/cli/test/upgrade/fixtures/unwired/input/src/main.tsx b/packages/cli/test/upgrade/fixtures/unwired/input/src/main.tsx new file mode 100644 index 0000000..59d8906 --- /dev/null +++ b/packages/cli/test/upgrade/fixtures/unwired/input/src/main.tsx @@ -0,0 +1,12 @@ +// The app entry: 0.1 components loaded their own CSS, so nothing here +// loads ui-common's. +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { App } from "./App"; +import "./index.css"; + +createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/packages/cli/test/upgrade/fixtures/unwired/input/src/themeToggle.ts b/packages/cli/test/upgrade/fixtures/unwired/input/src/themeToggle.ts new file mode 100644 index 0000000..28ad17b --- /dev/null +++ b/packages/cli/test/upgrade/fixtures/unwired/input/src/themeToggle.ts @@ -0,0 +1,9 @@ +export function applyScheme(dark: boolean) { + document.documentElement.dataset.theme = dark ? "orange-dark" : "orange-light"; +} + +export function applyMode(dark: boolean) { + // Already a 0.2 mode value: nothing to report. + document.documentElement.setAttribute("data-theme", "dark"); + if (!dark) document.documentElement.setAttribute("data-theme", "light"); +} diff --git a/packages/cli/test/upgrade/local-modules.test.ts b/packages/cli/test/upgrade/local-modules.test.ts new file mode 100644 index 0000000..3a45fad --- /dev/null +++ b/packages/cli/test/upgrade/local-modules.test.ts @@ -0,0 +1,186 @@ +/** + * How the upgrade reads a project's own modules: import resolution + * (relative, tsconfig `paths` and `baseUrl`), and which exports stand for a + * 0.1 component (a re-export) or wrap one (a wrapper, reported only). + */ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; + +import jscodeshift from "jscodeshift"; +import { afterEach, describe, expect, it } from "vitest"; + +import { createResolver, parseJsonc } from "../../cli/resolve.mjs"; +import { localExports, wrapperFindings } from "../../codemods/0.2/local-modules.mjs"; + +const temps: string[] = []; +afterEach(() => { + for (const dir of temps.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +function project(files: Record) { + const dir = mkdtempSync(join(tmpdir(), "uic-local-")); + temps.push(dir); + for (const [file, text] of Object.entries(files)) { + mkdirSync(dirname(join(dir, file)), { recursive: true }); + writeFileSync(join(dir, file), text); + } + const resolver = createResolver(dir); + const ctx = { + source: (path: string) => { + try { + return readFileSync(path, "utf8"); + } catch { + return null; + } + }, + resolveImport: resolver.resolveImport, + }; + return { dir, ctx, resolver }; +} + +describe("import resolution", () => { + it("reads tsconfig paths and baseUrl, comments and trailing commas included", () => { + const { dir, resolver } = project({ + "tsconfig.json": `{ + // aliases + "compilerOptions": { "baseUrl": "./src", "paths": { "@/*": ["./*"], }, }, +}`, + "src/ui/index.ts": "export {};\n", + "src/lib/format.ts": "export {};\n", + "src/pages/A.tsx": "", + "src/styles/app.css": "", + }); + const from = join(dir, "src/pages/A.tsx"); + expect(resolver.resolveImport(from, "@/ui")).toBe(join(dir, "src/ui/index.ts")); + expect(resolver.resolveImport(from, "lib/format")).toBe( + join(dir, "src/lib/format.ts"), + ); + expect(resolver.resolveImport(from, "../lib/format.js")).toBe( + join(dir, "src/lib/format.ts"), + ); + // An alias to a stylesheet resolves to no script, and is not "unresolved". + expect(resolver.resolveImport(from, "@/styles/app.css")).toBeNull(); + expect(resolver.resolveImport(from, "~/ui")).toBeNull(); + expect([...resolver.unresolved.keys()]).toEqual(["~/"]); + // A probe finds the same files but records no miss. + expect(resolver.resolveImport(from, "#/ui", { probe: true })).toBeNull(); + expect([...resolver.unresolved.keys()]).toEqual(["~/"]); + }); + + it("parses JSONC without touching strings", () => { + expect(parseJsonc('{ "a": "http://x/*y*/", /* c */ "b": [1,], }')).toEqual({ + a: "http://x/*y*/", + b: [1], + }); + }); +}); + +describe("local exports", () => { + const j = jscodeshift.withParser("tsx"); + + it("follows re-exports through barrels, export * and import-then-export", () => { + const { dir, ctx } = project({ + "src/ui/index.ts": `export * from "./leaf";\nexport { Pill as Chip } from "./leaf";\n`, + "src/ui/leaf.ts": `import { Badge as Pill, Tooltip } from "@lablup/ui-common"; +export { Pill }; +export default Tooltip; +export * from "@lablup/ui-common/components/Drawer"; +`, + }); + const entries = localExports(j, ctx, join(dir, "src/ui/index.ts")); + expect(entries.get("Pill")).toEqual({ kind: "component", component: "Badge" }); + expect(entries.get("Chip")).toEqual({ kind: "component", component: "Badge" }); + expect(entries.get("Drawer")).toEqual({ kind: "component", component: "Drawer" }); + expect(entries.get("DrawerProps")).toMatchObject({ kind: "type", removed: false }); + // export * never re-exports a default. + expect(entries.has("default")).toBe(false); + expect(localExports(j, ctx, join(dir, "src/ui/leaf.ts")).get("default")).toEqual({ + kind: "component", + component: "Tooltip", + }); + }); + + it("follows a barrel whose only re-exports go through tsconfig baseUrl", () => { + const { dir, ctx } = project({ + "tsconfig.json": `{ "compilerOptions": { "baseUrl": "./src" } }`, + "src/index.ts": `export * from "components/common";\n`, + "src/components/common/index.ts": `export { Drawer as Panel } from "@lablup/ui-common";\n`, + }); + expect(localExports(j, ctx, join(dir, "src/index.ts")).get("Panel")).toEqual({ + kind: "component", + component: "Drawer", + }); + }); + + it("follows a barrel whose only re-exports go through an @-scoped paths alias", () => { + const { dir, ctx } = project({ + "tsconfig.json": `{ "compilerOptions": { "paths": { "@ui/*": ["./src/ui/*"] } } }`, + "src/index.ts": `export * from "@ui/common";\n`, + "src/ui/common.ts": `export { Drawer } from "@lablup/ui-common";\n`, + }); + expect(localExports(j, ctx, join(dir, "src/index.ts")).get("Drawer")).toEqual({ + kind: "component", + component: "Drawer", + }); + }); + + it("survives an import cycle", () => { + const { dir, ctx } = project({ + "a.ts": `export * from "./b";\nexport { Button } from "@lablup/ui-common";\n`, + "b.ts": `export * from "./a";\n`, + }); + expect(localExports(j, ctx, join(dir, "a.ts")).get("Button")).toEqual({ + kind: "component", + component: "Button", + }); + }); + + it("tells a wrapper from a component that only renders one", () => { + const { dir, ctx } = project({ + "ui.tsx": `import { Drawer as Base, DataTable } from "@lablup/ui-common"; +import type { DrawerProps } from "@lablup/ui-common"; +import { memo } from "react"; + +interface PanelProps extends Omit { heading: string } + +// Typed on the 0.1 props: callers pass Drawer's props. +export function Panel({ heading, isOpen, onClose }: PanelProps) { + return ; +} + +// Spreads its props on: callers pass Drawer's props. +export const Sheet = (props: { size: string }) => ; + +function ListComponent({ ...rest }: DrawerProps) { + return ; +} +// memo() of a component declared on its own. +export const List = memo(ListComponent); + +// Its own props, its own API: not a wrapper. +export function UsersTable({ rows }: { rows: string[] }) { + return r} ariaLabel="Users" />; +} +`, + }); + const entries = localExports(j, ctx, join(dir, "ui.tsx")); + expect(entries.get("Panel")).toMatchObject({ + kind: "wrapper", + components: ["Drawer"], + }); + expect(entries.get("Sheet")).toMatchObject({ + kind: "wrapper", + components: ["Drawer"], + }); + expect(entries.get("List")).toMatchObject({ + kind: "wrapper", + components: ["Drawer"], + }); + expect(entries.has("UsersTable")).toBe(false); + const findings = wrapperFindings(ctx, (f: string) => f.slice(dir.length + 1)); + expect( + findings.map((f: { text: string; line: number }) => `${f.text}:${f.line}`), + ).toEqual(["Panel:8", "Sheet:13", "List:19"]); + }); +}); diff --git a/test/upgrade/package-json.test.ts b/packages/cli/test/upgrade/package-json.test.ts similarity index 92% rename from test/upgrade/package-json.test.ts rename to packages/cli/test/upgrade/package-json.test.ts index 520d8bb..46f2a0c 100644 --- a/test/upgrade/package-json.test.ts +++ b/packages/cli/test/upgrade/package-json.test.ts @@ -4,7 +4,7 @@ */ import { describe, expect, it } from "vitest"; -import { transformPackageJson } from "../../codemods/0.2/package-json.mjs"; +import { CLI_PACKAGE, transformPackageJson } from "../../codemods/0.2/package-json.mjs"; import { LAB_PACKAGE, stylexPeer } from "../../codemods/0.2/map.mjs"; const UIC = "@lablup/ui-common"; @@ -42,7 +42,11 @@ describe("a library (ui-common as a peer)", () => { it("leaves both alone when both are declared", () => { const input = { peerDependencies: { [UIC]: "^0.2.0", [stylex.name]: "^0.19.0" }, - devDependencies: { [UIC]: "0.2.0", [stylex.name]: "0.19.0" }, + devDependencies: { + [UIC]: "0.2.0", + [CLI_PACKAGE]: "0.2.0", + [stylex.name]: "0.19.0", + }, }; expect( transformPackageJson(`${JSON.stringify(input, null, 2)}\n`, { diff --git a/test/upgrade/stylesheets.test.ts b/packages/cli/test/upgrade/stylesheets.test.ts similarity index 64% rename from test/upgrade/stylesheets.test.ts rename to packages/cli/test/upgrade/stylesheets.test.ts index 7426a9e..6374bf4 100644 --- a/test/upgrade/stylesheets.test.ts +++ b/packages/cli/test/upgrade/stylesheets.test.ts @@ -7,10 +7,14 @@ import { readFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; +import jscodeshift from "jscodeshift"; import { compileString } from "sass"; import { describe, expect, it } from "vitest"; -import { transformStylesheet } from "../../codemods/0.2/stylesheets.mjs"; +import { + transformScriptImports, + transformStylesheet, +} from "../../codemods/0.2/stylesheets.mjs"; const ctx = { flags: { packages: new Map() } }; const LAYER = @@ -87,3 +91,48 @@ $pad: 2px; expect(out.startsWith(`@charset "utf-8";\n${LAYER}`)).toBe(true); }); }); + +describe("the stylesheet entry import in a script", () => { + const j = jscodeshift.withParser("tsx"); + const scriptCtx = { + flags: { packages: new Map() }, + createFile: (path: string) => path, + }; + const script = (source: string) => { + const out = transformScriptImports( + { path: "src/main.tsx", source }, + { jscodeshift: j }, + scriptCtx, + ); + if (out == null) throw new Error("the codemod left the file alone"); + return out as string; + }; + const order = (out: string) => + [...out.matchAll(/^import .*?["']([^"']+)["'];$/gm)].map((m) => m[1]); + + it("moves ahead of @lablup/ui-common modules, stylesheets and the app's own modules", () => { + const out = script(`// entry +import { createRoot } from "react-dom/client"; +import { Theme } from "@lablup/ui-common"; +import "./app.css"; +import { App } from "./App"; +import "@lablup/ui-common/styles/base.css"; +`); + expect(order(out)).toEqual([ + "react-dom/client", + "./ui-common-entry.css", + "@lablup/ui-common", + "./app.css", + "./App", + ]); + expect(out.startsWith("// entry\n")).toBe(true); + }); + + it("stays where it is when nothing that loads styles comes first", () => { + const out = script(`import { createRoot } from "react-dom/client"; +import "@lablup/ui-common/styles/base.css"; +import { App } from "./App"; +`); + expect(order(out)).toEqual(["react-dom/client", "./ui-common-entry.css", "./App"]); + }); +}); diff --git a/test/upgrade/upgrade.test.ts b/packages/cli/test/upgrade/upgrade.test.ts similarity index 56% rename from test/upgrade/upgrade.test.ts rename to packages/cli/test/upgrade/upgrade.test.ts index ebf4fd7..f90ccb9 100644 --- a/test/upgrade/upgrade.test.ts +++ b/packages/cli/test/upgrade/upgrade.test.ts @@ -6,7 +6,7 @@ * * Each fixture is copied to a temp dir, upgraded, and compared file by file * with `expected/` (report and package.json included). Regenerate after an - * intended change with `UPDATE_FIXTURES=1 pnpm vitest run test/upgrade`, then + * intended change with `UPDATE_FIXTURES=1 pnpm vitest run packages/cli/test/upgrade`, then * read the diff. */ import { @@ -53,10 +53,7 @@ function tree(dir: string): string[] { /** The installed version appears in the report header; keep fixtures stable across releases. */ function normalize(file: string, text: string) { return file.endsWith(".md") - ? text.replace( - /installed @lablup\/ui-common [^)]+\)/, - "installed @lablup/ui-common )", - ) + ? text.replace(/@lablup\/ui-common-cli [^)]+\)/, "@lablup/ui-common-cli )") : text; } @@ -69,6 +66,9 @@ function copyFixture(name: string) { const quiet = { log: () => {}, warn: () => {} }; +/** The block the fixture/ consumer carries, as a pnpm 11 project needs it. */ +const ALLOW_BUILDS = `allowBuilds:\n "@astryxdesign/core": false\n "@astryxdesign/cli": false`; + const cases = readdirSync(FIXTURES).filter((name) => existsSync(join(FIXTURES, name, "input")), ); @@ -158,8 +158,24 @@ describe("ui-common upgrade 0.1 -> 0.2", () => { expect(readFileSync(join(dir, "reports/dry.md"), "utf8")).toContain("dry run"); }); + it("pins @lablup/ui-common-cli to the target, wherever the project lists it", async () => { + const dir = copyFixture("root-barrel"); + const pkgFile = join(dir, "package.json"); + const pkg = JSON.parse(readFileSync(pkgFile, "utf8")); + pkg.dependencies["@lablup/ui-common-cli"] = "^0.1.0"; + writeFileSync(pkgFile, `${JSON.stringify(pkg, null, 2)}\n`); + const result = await runUpgrade({ cwd: dir, paths: ["src"], to: TO, ...quiet }); + expect(result.code, JSON.stringify(result.errors)).toBe(0); + const after = JSON.parse(readFileSync(pkgFile, "utf8")); + expect(after.dependencies["@lablup/ui-common-cli"]).toBe(TO); + expect(after.devDependencies?.["@lablup/ui-common-cli"]).toBeUndefined(); + }); + describe("points lab's core peer at ui-common's core when it adds lab", () => { - const own = JSON.parse(readFileSync(join(here, "../../package.json"), "utf8")) as { + // The repository root: the @lablup/ui-common the CLI ships with. + const own = JSON.parse( + readFileSync(join(here, "../../../../package.json"), "utf8"), + ) as { dependencies: Record; }; const pin = own.dependencies["@astryxdesign/core"]; @@ -182,7 +198,7 @@ describe("ui-common upgrade 0.1 -> 0.2", () => { const result = await runUpgrade({ cwd: dir, paths: ["src"], to: TO, ...quiet }); expect(result.code, JSON.stringify(result.errors)).toBe(0); expect(readFileSync(join(dir, "pnpm-workspace.yaml"), "utf8")).toBe( - `overrides:\n "@astryxdesign/lab>@astryxdesign/core": "${pin}"\n`, + `${ALLOW_BUILDS}\n\noverrides:\n "@astryxdesign/lab>@astryxdesign/core": "${pin}"\n`, ); expect( JSON.parse(readFileSync(join(dir, "package.json"), "utf8")).overrides, @@ -200,7 +216,7 @@ describe("ui-common upgrade 0.1 -> 0.2", () => { const result = await runUpgrade({ cwd: dir, paths: ["src"], to: TO, ...quiet }); expect(result.code, JSON.stringify(result.errors)).toBe(0); expect(readFileSync(join(workspace, "pnpm-workspace.yaml"), "utf8")).toBe( - `packages:\n - app\n\noverrides:\n "@astryxdesign/lab>@astryxdesign/core": "${pin}"\n`, + `packages:\n - app\n\n${ALLOW_BUILDS}\n\noverrides:\n "@astryxdesign/lab>@astryxdesign/core": "${pin}"\n`, ); expect(existsSync(join(dir, "pnpm-workspace.yaml"))).toBe(false); }); @@ -225,6 +241,44 @@ describe("ui-common upgrade 0.1 -> 0.2", () => { }); }); + describe("pnpm allowBuilds", () => { + it("matches the block the repository's fixture/ consumer carries", () => { + const fixture = readFileSync( + join(here, "../../../../fixture/pnpm-workspace.yaml"), + "utf8", + ); + const keys = [ + ...fixture.matchAll(/^\s+"(@astryxdesign\/[\w-]+)":\s*false/gm), + ].map((m) => m[1]); + expect(keys).toEqual(["@astryxdesign/core", "@astryxdesign/cli"]); + expect(ALLOW_BUILDS).toBe( + `allowBuilds:\n${keys.map((k) => ` "${k}": false`).join("\n")}`, + ); + }); + + it("declines only what is undecided, pnpm's placeholder included", async () => { + const dir = copyFixture("root-barrel"); + writeFileSync(join(dir, "pnpm-lock.yaml"), "lockfileVersion: '9.0'\n"); + const yaml = `# workspace settings\nallowBuilds:\n esbuild: true\n '@astryxdesign/core': true\n '@astryxdesign/cli': set this to true or false\n`; + writeFileSync(join(dir, "pnpm-workspace.yaml"), yaml); + const result = await runUpgrade({ cwd: dir, paths: ["src"], to: TO, ...quiet }); + expect(result.code, JSON.stringify(result.errors)).toBe(0); + expect(readFileSync(join(dir, "pnpm-workspace.yaml"), "utf8")).toBe( + `# workspace settings\nallowBuilds:\n esbuild: true\n '@astryxdesign/core': true\n '@astryxdesign/cli': false\n`, + ); + expect(result.report).toContain( + 'allowBuilds["@astryxdesign/cli"] was "set this to true or false"', + ); + }); + + it("leaves an npm project's files alone", async () => { + const dir = copyFixture("root-barrel"); + writeFileSync(join(dir, "package-lock.json"), "{}\n"); + await runUpgrade({ cwd: dir, paths: ["src"], to: TO, ...quiet }); + expect(existsSync(join(dir, "pnpm-workspace.yaml"))).toBe(false); + }); + }); + describe("never overwrites a file this run did not produce", () => { const own = "/* the project's own entry */\n@import './brand.css';\n"; @@ -288,6 +342,132 @@ describe("ui-common upgrade 0.1 -> 0.2", () => { }); }); + describe("looks for findings across the whole project", () => { + const extra = { + "e2e/models.spec.ts": `test("bar", async ({ page }) => {\n await expect(page.locator(".progress-bar__fill")).toBeVisible();\n});\n`, + "scripts/check-theme.mjs": `const sheet = "@lablup/ui-common/styles/themes/orange-light.css";\n`, + // A nested package is another project; build output is not source. + "docs-site/package.json": "{}\n", + "docs-site/src/site.css": ".badge { color: red; }\n", + "dist/bundle.css": ".badge { color: red; }\n", + }; + + it("tests, e2e specs and scripts outside src, read but not written", async () => { + const dir = copyFixture("root-barrel"); + for (const [file, text] of Object.entries(extra)) { + mkdirSync(dirname(join(dir, file)), { recursive: true }); + writeFileSync(join(dir, file), text); + } + const result = await runUpgrade({ cwd: dir, paths: ["src"], to: TO, ...quiet }); + expect(result.code, JSON.stringify(result.errors)).toBe(0); + const where = (result.findings ?? []).map((f) => `${f.category} ${f.file}`); + expect(where).toContain("test-query e2e/models.spec.ts"); + expect(where).toContain("stylesheet-path scripts/check-theme.mjs"); + expect(where.filter((w) => /docs-site|dist\//.test(w))).toEqual([]); + for (const [file, text] of Object.entries(extra)) + expect(readFileSync(join(dir, file), "utf8"), file).toBe(text); + expect(result.report).toContain( + "under the project root for manual-review findings", + ); + }); + + it("--scan narrows it", async () => { + const dir = copyFixture("root-barrel"); + for (const [file, text] of Object.entries(extra)) { + mkdirSync(dirname(join(dir, file)), { recursive: true }); + writeFileSync(join(dir, file), text); + } + const result = await runUpgrade({ + cwd: dir, + paths: ["src"], + scan: ["src", "scripts"], + to: TO, + ...quiet, + }); + const files = new Set((result.findings ?? []).map((f) => f.file.split("/")[0])); + expect([...files].sort()).toEqual(["scripts", "src"]); + }); + }); + + it("lists 0.1 class names the project's own CSS defines apart", async () => { + const dir = copyFixture("root-barrel"); + const files = { + // The project's own tab strip, rendered and styled by the project. + "src/tabs/Tabs.tsx": `export const Tab = () => ;\n`, + "src/tabs/Tabs.css": + ".tabs__tab {\n color: red;\n}\n.tabs__tab:hover {\n color: blue;\n}\n", + "src/tabs/Tabs.test.tsx": `it("x", () => {\n expect(document.querySelector(".tabs__tab")).toBeNull();\n});\n`, + // Overrides of ui-common's own select: nothing here renders it, so + // even a rule of its own is not a definition. + "src/pages/page.css": + ".page .select__trigger {\n color: red;\n}\n.select__trigger {\n color: blue;\n}\n", + }; + for (const [file, text] of Object.entries(files)) { + mkdirSync(dirname(join(dir, file)), { recursive: true }); + writeFileSync(join(dir, file), text); + } + const result = await runUpgrade({ cwd: dir, paths: ["src"], to: TO, ...quiet }); + const at = (file: string) => + (result.findings ?? []).filter((f) => f.file === file).map((f) => f.category); + expect(at("src/tabs/Tabs.css")).toEqual(["own-class", "own-class"]); + expect(at("src/tabs/Tabs.test.tsx")).toEqual(["own-class"]); + expect(at("src/pages/page.css")).toEqual(["css-selector", "css-selector"]); + expect(result.report).toContain( + "### 0.1 class names your own CSS also defines (lower confidence)", + ); + expect(result.report).toContain("| test query: .tabs__tab"); + }); + + describe("", () => { + it("goes on the root the entry renders when several modules render one", async () => { + const dir = copyFixture("unwired"); + writeFileSync( + join(dir, "src/popup.tsx"), + `import { createRoot } from "react-dom/client";\ncreateRoot(document.body).render(
);\n`, + ); + // A test's root is not a candidate at all. + writeFileSync( + join(dir, "src/App.test.tsx"), + `import { createRoot } from "react-dom/client";\ncreateRoot(document.body).render(

);\n`, + ); + const result = await runUpgrade({ cwd: dir, paths: ["src"], to: TO, ...quiet }); + expect(readFileSync(join(dir, "src/main.tsx"), "utf8")).toContain( + "", + ); + expect(result.report).not.toContain("App.test.tsx also"); + expect(readFileSync(join(dir, "src/popup.tsx"), "utf8")).not.toContain(" { + const dir = copyFixture("unwired"); + rmSync(join(dir, "index.html")); + rmSync(join(dir, "src/main.tsx")); + for (const name of ["a", "b"]) + writeFileSync( + join(dir, `src/${name}.tsx`), + `import { createRoot } from "react-dom/client";\ncreateRoot(document.body).render(

);\n`, + ); + const result = await runUpgrade({ cwd: dir, paths: ["src"], to: TO, ...quiet }); + expect(readFileSync(join(dir, "src/a.tsx"), "utf8")).not.toContain("`.*2 modules render a root \(src\/a\.tsx, src\/b\.tsx\)/, + ); + }); + + it("is not added when a module already uses one", async () => { + const dir = copyFixture("unwired"); + writeFileSync( + join(dir, "src/Shell.tsx"), + `import { Theme } from "@lablup/ui-common";\nexport const Shell = ({ children }: any) => {children};\n`, + ); + const result = await runUpgrade({ cwd: dir, paths: ["src"], to: TO, ...quiet }); + expect(readFileSync(join(dir, "src/main.tsx"), "utf8")).not.toContain(" { // css-entry declares "@lablup/ui-common": "0.1.0-alpha.23". const dir = copyFixture("css-entry"); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1f638bd..af7644e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,9 +8,6 @@ importers: .: dependencies: - '@astryxdesign/cli': - specifier: 0.6.2 - version: 0.6.2(@astryxdesign/core@0.6.2(@stylexjs/stylex@0.19.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@astryxdesign/lab@0.6.2-canary.c9fb1ad(@astryxdesign/core@0.6.2(@stylexjs/stylex@0.19.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@stylexjs/stylex@0.19.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@astryxdesign/theme-neutral@0.6.2(@astryxdesign/core@0.6.2(@stylexjs/stylex@0.19.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8))(gpt-tokenizer@3.4.0)(supports-color@8.1.1) '@astryxdesign/core': specifier: 0.6.2 version: 0.6.2(@stylexjs/stylex@0.19.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -32,19 +29,13 @@ importers: intl-messageformat: specifier: ^11.2.9 version: 11.2.15 - jscodeshift: - specifier: ^17.4.0 - version: 17.4.0(supports-color@8.1.1) lucide-react: specifier: ^1.18.0 version: 1.48.0(react@19.2.8) - postcss: - specifier: ^8.5.25 - version: 8.5.25 - postcss-selector-parser: - specifier: ^7.1.6 - version: 7.1.6 devDependencies: + '@astryxdesign/cli': + specifier: 0.6.2 + version: 0.6.2(@astryxdesign/core@0.6.2(@stylexjs/stylex@0.19.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@astryxdesign/lab@0.6.2-canary.c9fb1ad(@astryxdesign/core@0.6.2(@stylexjs/stylex@0.19.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@stylexjs/stylex@0.19.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@astryxdesign/theme-neutral@0.6.2(@astryxdesign/core@0.6.2(@stylexjs/stylex@0.19.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8))(gpt-tokenizer@3.4.0)(supports-color@8.1.1) '@astryxdesign/lab': specifier: 0.6.2-canary.c9fb1ad version: 0.6.2-canary.c9fb1ad(@astryxdesign/core@0.6.2(@stylexjs/stylex@0.19.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@stylexjs/stylex@0.19.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -118,6 +109,25 @@ importers: specifier: ^4.1.8 version: 4.1.10(@types/node@22.20.1)(jsdom@29.1.1)(vite@8.2.0(@types/node@22.20.1)(jiti@2.7.0)(sass@1.105.0)) + packages/cli: + dependencies: + '@astryxdesign/cli': + specifier: 0.6.2 + version: 0.6.2(@astryxdesign/core@0.6.2(@stylexjs/stylex@0.19.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@astryxdesign/lab@0.6.2-canary.c9fb1ad(@astryxdesign/core@0.6.2(@stylexjs/stylex@0.19.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@stylexjs/stylex@0.19.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@astryxdesign/theme-neutral@0.6.2(@astryxdesign/core@0.6.2(@stylexjs/stylex@0.19.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8))(gpt-tokenizer@3.4.0)(supports-color@8.1.1) + jscodeshift: + specifier: ^17.4.0 + version: 17.4.0(supports-color@8.1.1) + postcss: + specifier: ^8.5.25 + version: 8.5.25 + postcss-selector-parser: + specifier: ^7.1.6 + version: 7.1.6 + devDependencies: + '@lablup/ui-common': + specifier: workspace:* + version: link:../.. + packages: '@adobe/css-tools@4.5.0': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c8ac3c8..e36de36 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,6 +2,12 @@ # published package; consumers resolve @lablup/ui-common's dependencies under # their own pnpm settings. +# The library is the repository root; its CLI is packages/cli +# (@lablup/ui-common-cli), published separately at the same version. +packages: + - "." + - "packages/*" + # @astryxdesign/lab is a canary build. Canary releases never age out of a # release-age quarantine by design, so it is excluded for as long as the exact # pin exists (the same exclusion backend.ai-webui carries). diff --git a/scripts/check-boundary.mjs b/scripts/check-boundary.mjs index 9275a5f..98dac32 100644 --- a/scripts/check-boundary.mjs +++ b/scripts/check-boundary.mjs @@ -90,16 +90,25 @@ for (const file of files) { * not been added here fails closed rather than slipping through because nobody * remembered to blocklist it. */ -// Public repositories, plus the sibling package name that the boundary rules -// above have to spell out in order to forbid it. Anything absent fails closed. -const ALLOWED_LABLUP_REFERENCES = ["all-smi", "backend.ai", "ui-common", "ui-ai"]; +// Public repositories, this repository's CLI package, plus the sibling package +// name that the boundary rules above have to spell out in order to forbid it. +// Anything absent fails closed. +const ALLOWED_LABLUP_REFERENCES = [ + "all-smi", + "backend.ai", + "ui-common", + "ui-common-cli", + "ui-ai", +]; const DISCLOSURE = [ { // `theme/lablup/...` is this package's own brand-theme path (source dir // and subpath), not a repository, so a `theme/` prefix is not a match. + // A period that ends a sentence ("depends on @lablup/ui-common.") is not + // part of the name; one followed by a word character is. pattern: new RegExp( - String.raw`(? sheet.css).join("\n"); if (bundled.trim() === "") { console.error(`No stylesheet found under ${fixtureDist}. Build the fixture first.`); process.exit(1); @@ -88,6 +102,19 @@ for (const { name, marker } of SHEETS) { } } +for (const { path, css } of sheets) { + const order = effectiveLayerOrder(css); + if (!order.includes("ui-common")) continue; + const known = order.filter((name) => LAYER_ORDER.includes(name)); + if (known.join(", ") !== LAYER_ORDER.join(", ")) { + missing.push( + `${relative(fixtureDist, path)} establishes the layers as "${order.join(", ")}", ` + + `not "${LAYER_ORDER.join(", ")}", so ui-common does not sit between ` + + `Astryx's layers and the app's`, + ); + } +} + if (missing.length > 0) { console.error(`Consumer stylesheet check failed (${missing.length}):\n`); for (const line of missing) console.error(` ${line}`); @@ -95,5 +122,6 @@ if (missing.length > 0) { } console.log( - `Consumer stylesheets present: ${[...COMPONENTS, ...SHEETS.map((s) => s.name)].join(", ")}.`, + `Consumer stylesheets present: ${[...COMPONENTS, ...SHEETS.map((s) => s.name)].join(", ")}. ` + + `Layer order: ${LAYER_ORDER.join(", ")}.`, ); diff --git a/scripts/check-pack.mjs b/scripts/check-pack.mjs index da11f49..8575497 100644 --- a/scripts/check-pack.mjs +++ b/scripts/check-pack.mjs @@ -13,18 +13,31 @@ * by nothing, and named by no exports entry, so a consumer had no supported * way to load it. Checking that advertised paths resolve says nothing about * files that arrive advertised by no one. + * + * Two tarballs come out of this repository, at one version: the library + * (@lablup/ui-common, the root) and its command line (@lablup/ui-common-cli, + * packages/cli). Both are packed with pnpm, as the publish workflow packs + * them, so the manifests checked are the published ones, with `workspace:` + * ranges already rewritten. The library must carry none of the CLI; the CLI + * must carry nothing of the library's build. */ import { execFileSync } from "node:child_process"; +import { mkdtempSync, rmSync } from "node:fs"; import { readFile, readdir } from "node:fs/promises"; +import { builtinModules } from "node:module"; +import { tmpdir } from "node:os"; import { fileURLToPath } from "node:url"; -import { dirname, resolve } from "node:path"; +import { dirname, join, resolve } from "node:path"; import { dirname as posixDirname, join as posixJoin, normalize as posixNormalize, } from "node:path/posix"; +import { LAYER_ORDER_STATEMENT, startsWithLayerOrder } from "./layer-order.mjs"; + const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const cliRoot = resolve(root, "packages/cli"); const FORBIDDEN_IN_TARBALL = [ { pattern: /(^|\/)src\//, reason: "package source must not ship" }, @@ -45,26 +58,65 @@ const FORBIDDEN_IN_TARBALL = [ }, ]; -function packFileList() { - const raw = execFileSync("npm", ["pack", "--dry-run", "--json"], { - cwd: root, +/** What only the CLI package ships. */ +const CLI_ONLY = [ + { + pattern: /^(bin|cli|codemods|migration)\//, + reason: "the CLI ships in @lablup/ui-common-cli", + }, +]; +/** The CLI's toolchain, which must stay out of a consumer's production install. */ +const CLI_DEPENDENCIES = [ + "@astryxdesign/cli", + "jscodeshift", + "postcss", + "postcss-selector-parser", +]; + +const scratch = mkdtempSync(join(tmpdir(), "uic-check-pack-")); +process.on("exit", () => rmSync(scratch, { recursive: true, force: true })); + +/** + * Pack `dir` with pnpm and return the packed paths and the packed manifest. + * + * @param {string} dir + */ +function pack(dir) { + const raw = execFileSync("pnpm", ["pack", "--pack-destination", scratch, "--json"], { + cwd: dir, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], }); const parsed = JSON.parse(raw); - return parsed[0].files.map((f) => f.path); + const manifest = JSON.parse( + execFileSync("tar", ["-xzOf", parsed.filename, "package/package.json"], { + encoding: "utf8", + }), + ); + return { files: parsed.files.map((f) => f.path), manifest }; } -const packed = packFileList(); const failures = []; +const { files: packed, manifest: pkg } = pack(root); + for (const file of packed) { - for (const { pattern, reason } of FORBIDDEN_IN_TARBALL) { + for (const { pattern, reason } of [...FORBIDDEN_IN_TARBALL, ...CLI_ONLY]) { if (pattern.test(file)) failures.push(`unexpected file "${file}": ${reason}`); } } +if (pkg.bin) + failures.push( + `@lablup/ui-common declares a bin; it belongs to @lablup/ui-common-cli`, + ); +for (const name of CLI_DEPENDENCIES) { + if (pkg.dependencies?.[name]) { + failures.push( + `@lablup/ui-common depends on "${name}", which only the CLI needs; it belongs to @lablup/ui-common-cli`, + ); + } +} -const pkg = JSON.parse(await readFile(resolve(root, "package.json"), "utf8")); const packedSet = new Set(packed); /** Turn an exports target into a predicate over packed file paths. */ @@ -142,6 +194,24 @@ for (const stylesheet of packed.filter((f) => f.endsWith(".css"))) { ); } +/** + * Every packed stylesheet opens with the full cascade layer order. A layer's + * position is fixed by the first sheet that names it, and a consumer usually + * loads a component's sheet (through its module) before its own entry + * stylesheet, so a packed sheet that opens `@layer ui-common{…}` first makes + * ui-common the lowest layer, below Astryx's base and theme. The build + * prepends the statement (`prependLayerOrder` in vite.config.ts). + */ +for (const stylesheet of packed.filter((f) => f.endsWith(".css"))) { + const css = await readFile(resolve(root, stylesheet), "utf8"); + if (!startsWithLayerOrder(css)) { + failures.push( + `"${stylesheet}" does not start with "${LAYER_ORDER_STATEMENT}", so loaded ` + + `before the app's own order statement it can reorder the cascade layers`, + ); + } +} + /** * Every bare specifier a packed module imports has to be something the * consumer is guaranteed to install: a dependency or a peer. Astryx and @@ -232,14 +302,92 @@ for (const { what, file, mustContain } of THIRD_PARTY_NOTICES) { } } +/** + * The CLI tarball: its bin, its modules and the data they read, nothing of the + * library's build, and a manifest a consumer can install next to the library. + */ +const { files: cliPacked, manifest: cliPkg } = pack(cliRoot); +const cliPackedSet = new Set(cliPacked); + +for (const file of cliPacked) { + for (const { pattern, reason } of [ + ...FORBIDDEN_IN_TARBALL, + { pattern: /^dist\//, reason: "the library's build ships in @lablup/ui-common" }, + ]) { + if (pattern.test(file)) failures.push(`unexpected CLI file "${file}": ${reason}`); + } +} +for (const required of [ + "migration/0.1-to-0.2.json", + "codemods/registry.mjs", + "cli/main.mjs", + "LICENSE", + "NOTICE", +]) { + if (!cliPackedSet.has(required)) failures.push(`the CLI tarball lacks "${required}"`); +} +const cliBin = cliPkg.bin?.["ui-common"]?.replace(/^\.\//, ""); +if (!cliBin) failures.push(`@lablup/ui-common-cli declares no "ui-common" bin`); +else if (!cliPackedSet.has(cliBin)) + failures.push(`the "ui-common" bin "${cliBin}" is not packed`); +if (cliPkg.version !== pkg.version) { + failures.push( + `@lablup/ui-common-cli is ${cliPkg.version} but @lablup/ui-common is ${pkg.version}; they are released in lockstep`, + ); +} +const cliPeer = cliPkg.peerDependencies?.["@lablup/ui-common"]; +if (cliPeer !== pkg.version) { + failures.push( + `@lablup/ui-common-cli's @lablup/ui-common peer is "${cliPeer}", not exactly ${pkg.version}`, + ); +} +for (const field of ["dependencies", "peerDependencies", "optionalDependencies"]) { + for (const [name, range] of Object.entries(cliPkg[field] ?? {})) { + if (String(range).startsWith("workspace:")) + failures.push(`@lablup/ui-common-cli ${field}["${name}"] is still "${range}"`); + } +} + +// Every import in a packed module resolves: a relative one to a packed file, +// a bare one to a dependency or peer the consumer is guaranteed to install. +const cliDeclared = new Set([ + ...Object.keys(cliPkg.dependencies ?? {}), + ...Object.keys(cliPkg.peerDependencies ?? {}), +]); +const builtins = new Set(builtinModules); +const anyImport = + /(?:\bimport\s*(?:[\w*{}\s,$]+\s*from\s*)?|\bexport\s*[\w*{}\s,$]+\s*from\s*|\bimport\s*\()\s*["']([.@\w][\w@./:-]*)["']/g; +for (const file of cliPacked.filter((f) => f.endsWith(".mjs"))) { + const code = await readFile(resolve(cliRoot, file), "utf8"); + for (const [, specifier] of code.matchAll(anyImport)) { + if (specifier.startsWith(".")) { + const target = posixNormalize(posixJoin(posixDirname(file), specifier)); + if (!cliPackedSet.has(target)) { + failures.push(`CLI "${file}" imports "${specifier}", which is not packed`); + } + continue; + } + if (specifier.startsWith("node:") || builtins.has(specifier)) continue; + const name = packageOf(specifier); + if (!cliDeclared.has(name)) { + failures.push( + `CLI "${file}" imports "${specifier}", but "${name}" is neither a dependency nor a peer`, + ); + } + } +} + if (failures.length > 0) { console.error(`Packed artifact check failed (${failures.length}):\n`); for (const f of failures) console.error(` ${f}`); - console.error(`\nPacked ${packed.length} file(s).`); + console.error( + `\nPacked ${packed.length} file(s) in @lablup/ui-common, ${cliPacked.length} in @lablup/ui-common-cli.`, + ); process.exit(1); } console.log( - `Packed artifact clean: ${packed.length} file(s), ` + - `${Object.keys(pkg.exports ?? {}).length} export path(s) resolve.`, + `Packed artifact clean: @lablup/ui-common ${packed.length} file(s), ` + + `${Object.keys(pkg.exports ?? {}).length} export path(s) resolve; ` + + `@lablup/ui-common-cli ${cliPacked.length} file(s), bin and imports resolve.`, ); diff --git a/scripts/layer-order.d.mts b/scripts/layer-order.d.mts new file mode 100644 index 0000000..90e959a --- /dev/null +++ b/scripts/layer-order.d.mts @@ -0,0 +1,11 @@ +/** Types for the cascade layer order, so vite.config.ts and tests can import it. */ + +export declare const LAYER_ORDER: readonly string[]; + +export declare const LAYER_ORDER_STATEMENT: string; + +export declare function withLayerOrder(css: string): string; + +export declare function startsWithLayerOrder(css: string): boolean; + +export declare function effectiveLayerOrder(css: string): string[]; diff --git a/scripts/layer-order.mjs b/scripts/layer-order.mjs new file mode 100644 index 0000000..eaa1f86 --- /dev/null +++ b/scripts/layer-order.mjs @@ -0,0 +1,83 @@ +/** + * The cascade layer order ui-common is built for, in one place. + * + * `ui-common` must sit after Astryx's `astryx-base` and `astryx-theme` so the + * composites beat the primitives they restyle, and before the consumer's + * `components` and `utilities`. A layer's position is fixed by the first + * stylesheet that names it, and every component module imports its own + * stylesheet, so a consumer's order statement in the app entry CSS usually + * arrives after `@layer ui-common{…}` already did, which makes `ui-common` the + * lowest layer of all. The build therefore puts this statement at the top of + * every stylesheet the package ships (`vite.config.ts`, `layerOrderPreamble`). + * Repeating an identical statement is a no-op. + */ + +/** Layer names, lowest priority first. */ +export const LAYER_ORDER = Object.freeze([ + "reset", + "theme", + "base", + "astryx-base", + "astryx-theme", + "ui-common", + "components", + "utilities", +]); + +/** The statement, byte for byte what README and the fixture declare. */ +export const LAYER_ORDER_STATEMENT = `@layer ${LAYER_ORDER.join(", ")};`; + +/** + * `css` with the order statement in front. A leading `@charset` stays first, + * as the spec requires; a sheet that already starts with the statement is + * returned unchanged. A layer statement may precede `@import`, so the one-line + * Astryx mirrors take it too. + */ +export function withLayerOrder(css) { + const charset = css.match(/^@charset\s+"[^"]*";\s*/); + const head = charset ? charset[0] : ""; + const rest = css.slice(head.length); + if (rest.startsWith(LAYER_ORDER_STATEMENT)) return css; + return `${head}${LAYER_ORDER_STATEMENT}\n${rest}`; +} + +/** + * Whether `css` declares the full order before it opens or names any layer. + * Leading comments and an `@charset` are skipped. + */ +export function startsWithLayerOrder(css) { + const stripped = css + .replace(/^\uFEFF/, "") + .replace(/^(?:\s*\/\*[\s\S]*?\*\/)*\s*/, "") + .replace(/^@charset\s+"[^"]*";\s*/, ""); + return stripped.startsWith(LAYER_ORDER_STATEMENT); +} + +/** + * The order a stylesheet actually establishes for its top-level layers: each + * name in the order its first `@layer` rule (statement or block) names it. + * This is what the cascade uses, and it survives minifiers that rewrite or + * fold layer statements (Lightning CSS turns a leading statement into the + * blocks that follow it), so it is what a consumer bundle is checked by. + */ +export function effectiveLayerOrder(css) { + const source = css + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/g, '""'); + const order = []; + let depth = 0; + const token = /@layer\s*([^;{]*)([;{])|[{}]/g; + for (const match of source.matchAll(token)) { + if (match[0] === "{") depth += 1; + else if (match[0] === "}") depth -= 1; + else { + if (depth === 0) { + for (const name of match[1].split(",").map((n) => n.trim())) { + if (name && !order.includes(name)) order.push(name); + } + } + if (match[2] === "{") depth += 1; + } + } + return order; +} diff --git a/src/components/Modal/modalStack.ts b/src/components/Modal/modalStack.ts index 06c6432..ec9bdd3 100644 --- a/src/components/Modal/modalStack.ts +++ b/src/components/Modal/modalStack.ts @@ -20,6 +20,8 @@ import { } from "react"; import { devWarn } from "@astryxdesign/core/utils"; +import { registerInstance } from "../../instance"; + /** * Marks an open modal root. Scope document queries to it through this * constant rather than retyping the string. @@ -201,6 +203,7 @@ export function claimModalLevel( setIsTopmost: (isTopmost: boolean) => void, requestedZIndex?: number, ): ModalLevelEntry { + registerInstance(); const topmost = openModals.at(-1); const entry: ModalLevelEntry = { level: Math.min((topmost?.level ?? -1) + 1, MAX_MODAL_LEVEL), diff --git a/src/i18n/useUicTranslator.ts b/src/i18n/useUicTranslator.ts index 9548ade..ec6a49d 100644 --- a/src/i18n/useUicTranslator.ts +++ b/src/i18n/useUicTranslator.ts @@ -31,6 +31,7 @@ import { type InternationalizationContextValue, } from "@astryxdesign/core/i18n"; +import { registerInstance } from "../instance"; import { uiCommonCatalog } from "./catalog"; export type UicTranslate = (key: string, values?: Record) => string; @@ -66,6 +67,7 @@ function providedByConsumer( } export function useUicTranslator(catalog: Catalog = uiCommonCatalog): UicTranslate { + registerInstance(); const ctx = useContext(InternationalizationContext); return useCallback( (key, values) => { diff --git a/src/instance.test.ts b/src/instance.test.ts new file mode 100644 index 0000000..df0c9cf --- /dev/null +++ b/src/instance.test.ts @@ -0,0 +1,96 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { renderHook } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { useUicTranslator } from "./i18n/useUicTranslator"; + +import { + INSTANCE_KEY, + registerInstance, + registerInstanceIn, + type UiCommonInstance, +} from "./instance"; + +const ROOT = join(__dirname, ".."); + +function copy( + version: string, + astryxIdentity: unknown = "core", + url = `file:///node_modules/@lablup/ui-common@${version}/dist/instance.js`, +): UiCommonInstance { + return { version, astryxVersion: "0.6.2", url, astryxIdentity }; +} + +describe("registerInstanceIn", () => { + it("stays quiet for one copy, however often it registers", () => { + const host = {}; + const warn = vi.fn(); + const only = copy("0.2.0"); + registerInstanceIn(host, only, warn); + registerInstanceIn(host, only, warn); + expect(warn).not.toHaveBeenCalled(); + }); + + it("warns once when a second copy registers, naming both", () => { + const host = {}; + const warn = vi.fn(); + registerInstanceIn(host, copy("0.2.0"), warn); + registerInstanceIn(host, copy("0.2.1"), warn); + registerInstanceIn(host, copy("0.2.2"), warn); + expect(warn).toHaveBeenCalledTimes(1); + const message = warn.mock.calls[0]?.[0] as string; + expect(message).toMatch(/2 copies are loaded/); + expect(message).toContain("@lablup/ui-common@0.2.0/dist/instance.js"); + expect(message).toContain("@lablup/ui-common@0.2.1/dist/instance.js"); + expect(message).not.toMatch(/copies of @astryxdesign\/core/); + }); + + it("says so when the copies also run on two copies of Astryx core", () => { + const host = {}; + const warn = vi.fn(); + registerInstanceIn(host, copy("0.2.0", {}), warn); + registerInstanceIn(host, copy("0.2.0", {}), warn); + expect(warn.mock.calls[0]?.[0]).toMatch( + /2 copies of @astryxdesign\/core, so Theme, i18n and layer contexts/, + ); + }); + + it("tells two copies of the same version apart", () => { + const host = {}; + const warn = vi.fn(); + registerInstanceIn(host, copy("0.2.0", "core", "same"), warn); + registerInstanceIn(host, copy("0.2.0", "core", "same"), warn); + expect(warn).toHaveBeenCalledTimes(1); + }); +}); + +describe("registerInstance", () => { + it("registers this copy on globalThis the first time a component translates", () => { + renderHook(() => useUicTranslator()); + renderHook(() => useUicTranslator()); + const registry = ( + globalThis as { [INSTANCE_KEY]?: { instances: UiCommonInstance[] } } + )[INSTANCE_KEY]; + expect(registry?.instances).toHaveLength(1); + const [self] = registry?.instances ?? []; + const pkg = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8")); + expect(self?.version).toBe(pkg.version); + expect(self?.astryxVersion).toBe(pkg.dependencies["@astryxdesign/core"]); + expect(self?.url).toMatch(/instance\.ts$/); + }); + + it("is a no-op in production", () => { + const host = globalThis as { [INSTANCE_KEY]?: unknown }; + const saved = host[INSTANCE_KEY]; + delete host[INSTANCE_KEY]; + vi.stubEnv("NODE_ENV", "production"); + try { + registerInstance(); + expect(host[INSTANCE_KEY]).toBeUndefined(); + } finally { + vi.unstubAllEnvs(); + host[INSTANCE_KEY] = saved; + } + }); +}); diff --git a/src/instance.ts b/src/instance.ts new file mode 100644 index 0000000..90f91cf --- /dev/null +++ b/src/instance.ts @@ -0,0 +1,111 @@ +/** + * Dev-only warning for a second copy of ui-common, or of Astryx core, on the + * page. Internal: not exported from the package. + * + * Two copies split what one copy shares: the modal stack and every other + * module-level state, and, when each copy brings its own `@astryxdesign/core`, + * Astryx's React contexts (theme, i18n, layers), so components stop seeing the + * provider the app rendered. Nothing fails; things quietly look wrong. Each + * copy therefore records itself under one global symbol, and the first copy + * to find another there warns once. + * + * `registerInstance()` is called from the code paths that use state a second + * copy would split: the string translator (it reads Astryx's i18n context, so + * most components render through it) and the modal stack. It runs on first + * use rather than at module scope because the package declares its modules + * side-effect free (`sideEffects` in package.json), and bundlers drop a bare + * module-scope call from such a module. After the first call it is one + * boolean check. Production builds drop it with the rest of the + * `process.env.NODE_ENV` branch, as Astryx's own dev warnings are. + * + * It cannot see a second `@astryxdesign/core` that ui-common does not itself + * import, such as one `@astryxdesign/lab` resolves to without the override + * README describes: core has no global registration and lab exposes no handle + * on the core it runs on. Two ui-common copies that each bring their own core + * are detected, by comparing one of core's context objects. + */ +import { InternationalizationContext } from "@astryxdesign/core/i18n"; + +export const INSTANCE_KEY = Symbol.for("@lablup/ui-common/instance"); + +export interface UiCommonInstance { + /** ui-common's version, from package.json at build time. */ + version: string; + /** The Astryx core version ui-common pins. */ + astryxVersion: string; + /** Where this copy was loaded from. */ + url: string; + /** + * One of Astryx core's React contexts, as this copy imports it. Two copies + * holding different objects run on two copies of Astryx core. + */ + astryxIdentity: unknown; +} + +interface Registry { + instances: UiCommonInstance[]; + warned: boolean; +} + +type RegistryHost = { [INSTANCE_KEY]?: Registry }; + +/** What a new copy should say, or `null` when there is nothing to report. */ +export function describeDuplicates( + instances: readonly UiCommonInstance[], +): string | null { + if (instances.length < 2) return null; + const astryxCopies = new Set(instances.map((i) => i.astryxIdentity)).size; + const list = instances + .map( + (i) => + ` - @lablup/ui-common ${i.version} (Astryx ${i.astryxVersion}) at ${i.url}`, + ) + .join("\n"); + return ( + `@lablup/ui-common: ${instances.length} copies are loaded. They do not share ` + + `module state (the modal stack, among others)` + + (astryxCopies > 1 + ? `, and they run on ${astryxCopies} copies of @astryxdesign/core, so ` + + `Theme, i18n and layer contexts do not reach across them` + : "") + + `. Dedupe the dependency (\`pnpm why @lablup/ui-common\`, ` + + `\`npm ls @lablup/ui-common\`).\n${list}` + ); +} + +/** + * Record `self` under `host` and warn, once per page, when another copy is + * already there. Calling it again from the same copy does nothing. + */ +export function registerInstanceIn( + host: RegistryHost, + self: UiCommonInstance, + warn: (message: string) => void = console.warn, +): void { + const registry = (host[INSTANCE_KEY] ??= { instances: [], warned: false }); + if (registry.instances.includes(self)) return; + registry.instances.push(self); + if (registry.warned) return; + const message = describeDuplicates(registry.instances); + if (message === null) return; + registry.warned = true; + warn(message); +} + +const self: UiCommonInstance = { + version: __UI_COMMON_VERSION__, + astryxVersion: __ASTRYX_CORE_VERSION__, + url: import.meta.url, + astryxIdentity: InternationalizationContext, +}; + +let registered = false; + +/** Register this copy on `globalThis`, once. Dev only. */ +export function registerInstance(): void { + if (process.env.NODE_ENV !== "production") { + if (registered) return; + registered = true; + registerInstanceIn(globalThis as RegistryHost, self); + } +} diff --git a/src/layerOrder.test.ts b/src/layerOrder.test.ts new file mode 100644 index 0000000..2b52c2e --- /dev/null +++ b/src/layerOrder.test.ts @@ -0,0 +1,126 @@ +/** + * The cascade layer order and its injection into every shipped stylesheet. + * + * `scripts/layer-order.mjs` is the one definition. The build prepends its + * statement to every stylesheet in `dist` (asserted on the packed artifact by + * `scripts/check-pack.mjs`), and `scripts/check-fixture-styles.mjs` reads the + * order a consumer bundle actually ends up with. This covers the functions + * both rely on, and that the copies consumers read in the docs still spell the + * same statement. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +import { + LAYER_ORDER, + LAYER_ORDER_STATEMENT, + effectiveLayerOrder, + startsWithLayerOrder, + withLayerOrder, +} from "../scripts/layer-order.mjs"; + +const ROOT = join(__dirname, ".."); + +describe("LAYER_ORDER_STATEMENT", () => { + it("puts ui-common between Astryx's layers and the app's", () => { + expect(LAYER_ORDER_STATEMENT).toBe( + "@layer reset, theme, base, astryx-base, astryx-theme, ui-common, components, utilities;", + ); + const at = (name: string) => LAYER_ORDER.indexOf(name); + expect(at("ui-common")).toBeGreaterThan(at("astryx-theme")); + expect(at("astryx-theme")).toBeGreaterThan(at("astryx-base")); + expect(at("ui-common")).toBeLessThan(at("components")); + }); + + /** + * Docs a consumer copies from. The upgrade tool's own copies live with the + * tool and are held by its tests. + */ + it.each([ + "README.md", + "docs/astryx.md", + "astryx.integration.mjs", + "astryx/docs/ui-common.doc.mjs", + "fixture/src/index.css", + ])("%s spells the same statement", (file) => { + const text = readFileSync(join(ROOT, file), "utf8"); + const copies = [...text.matchAll(/@layer reset,[^;]*;/g)].map((m) => m[0]); + expect(copies.length).toBeGreaterThan(0); + for (const copy of copies) expect(copy).toBe(LAYER_ORDER_STATEMENT); + }); +}); + +describe("withLayerOrder", () => { + it("prepends the statement to a component sheet", () => { + const css = "@layer ui-common{.uic-x{display:flex}}"; + const out = withLayerOrder(css); + expect(out).toBe(`${LAYER_ORDER_STATEMENT}\n${css}`); + expect(startsWithLayerOrder(out)).toBe(true); + expect(startsWithLayerOrder(css)).toBe(false); + }); + + it("is idempotent", () => { + const once = withLayerOrder("@layer ui-common{}"); + expect(withLayerOrder(once)).toBe(once); + }); + + it("keeps @charset first", () => { + const out = withLayerOrder('@charset "UTF-8";\n.a{}'); + expect(out).toBe(`@charset "UTF-8";\n${LAYER_ORDER_STATEMENT}\n.a{}`); + expect(startsWithLayerOrder(out)).toBe(true); + }); + + it("goes ahead of an @import mirror, where a layer statement is allowed", () => { + const mirror = + '/* @generated by scripts/gen-exports.mjs. Do not edit. */\n@import "@astryxdesign/core/astryx.css";\n'; + const out = withLayerOrder(mirror); + expect(out.startsWith(LAYER_ORDER_STATEMENT)).toBe(true); + expect(out.endsWith(mirror)).toBe(true); + }); + + it("accepts leading comments before the statement", () => { + expect(startsWithLayerOrder(`/* a */\n/* b */\n${LAYER_ORDER_STATEMENT}`)).toBe( + true, + ); + }); +}); + +describe("effectiveLayerOrder", () => { + it("reads the order the bug produced: ui-common first", () => { + const bundle = [ + "@layer ui-common{.uic-a{display:flex}}", + "@layer reset{*{margin:0}}", + "@layer theme,base;", + "@layer astryx-base{.x{display:block}}", + "@layer astryx-theme{:root{--c:red}}", + "@layer components,utilities;", + ].join(""); + expect(effectiveLayerOrder(bundle)).toEqual([ + "ui-common", + "reset", + "theme", + "base", + "astryx-base", + "astryx-theme", + "components", + "utilities", + ]); + }); + + it("reads a leading statement, and one a minifier folded into blocks", () => { + const plain = `${LAYER_ORDER_STATEMENT}@layer ui-common{.a{}}@layer reset{}`; + const folded = + "@layer reset{}@layer theme,base;@layer astryx-base{}@layer astryx-theme{}" + + "@layer ui-common{}@layer components,utilities;"; + expect(effectiveLayerOrder(plain)).toEqual([...LAYER_ORDER]); + expect(effectiveLayerOrder(folded)).toEqual([...LAYER_ORDER]); + }); + + it("ignores nested layers, comments and strings", () => { + const css = + '/* @layer ui-common{ */ @layer reset{.a{content:"@layer ui-common{"}@layer inner{}}' + + "@layer astryx-base{}"; + expect(effectiveLayerOrder(css)).toEqual(["reset", "astryx-base"]); + }); +}); diff --git a/src/migrationMap.test.ts b/src/migrationMap.test.ts index 46bc186..67e5a56 100644 --- a/src/migrationMap.test.ts +++ b/src/migrationMap.test.ts @@ -1,5 +1,6 @@ /** - * `migration/0.1-to-0.2.json` is read by `ui-common upgrade`. It has to agree + * `packages/cli/migration/0.1-to-0.2.json` is read by `ui-common upgrade` + * (@lablup/ui-common-cli). It has to agree * with the package it describes: every removed component is gone, every * replacement import resolves to a runtime export, and every kept component's * class rename matches the classes its stylesheet declares. @@ -21,7 +22,7 @@ interface KeptEntry { } const map = JSON.parse( - readFileSync(join(ROOT, "migration", "0.1-to-0.2.json"), "utf8"), + readFileSync(join(ROOT, "packages", "cli", "migration", "0.1-to-0.2.json"), "utf8"), ) as { removedComponents: RemovedEntry[]; keptComponents: KeptEntry[] }; const pkg = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8")) as { diff --git a/src/types/build-constants.d.ts b/src/types/build-constants.d.ts new file mode 100644 index 0000000..8685e09 --- /dev/null +++ b/src/types/build-constants.d.ts @@ -0,0 +1,9 @@ +/** + * Constants `vite.config.ts` defines at build and test time, from package.json. + */ + +/** ui-common's own version. */ +declare const __UI_COMMON_VERSION__: string; + +/** The `@astryxdesign/core` version ui-common pins. */ +declare const __ASTRYX_CORE_VERSION__: string; diff --git a/src/ui-common.css b/src/ui-common.css index 1be27bb..986b19b 100644 --- a/src/ui-common.css +++ b/src/ui-common.css @@ -5,7 +5,9 @@ * not live here: each component imports its own stylesheet. This file holds * only rules that have no component to hang on. * - * Declare the layer order once in your app, before any of these imports: + * The build puts the layer order statement (scripts/layer-order.mjs) at the + * top of this and every other shipped stylesheet, since a component's sheet + * usually reaches the page before the app's own statement does: * * @layer reset, theme, base, astryx-base, astryx-theme, ui-common, * components, utilities; diff --git a/test/upgrade/fixtures/adapter/expected/src/main.tsx b/test/upgrade/fixtures/adapter/expected/src/main.tsx deleted file mode 100644 index 8427de5..0000000 --- a/test/upgrade/fixtures/adapter/expected/src/main.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import { createRoot } from "react-dom/client"; -import "./ui-common-entry.css"; -import "./design-system/common-components.css"; -import { App } from "./App"; - -createRoot(document.getElementById("root")!).render(); diff --git a/vite.config.ts b/vite.config.ts index 33c7cc7..5bf50a1 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,5 +1,5 @@ -import { cp, mkdir, writeFile } from "node:fs/promises"; -import { existsSync, statSync } from "node:fs"; +import { cp, mkdir, readFile, readdir, writeFile } from "node:fs/promises"; +import { existsSync, readFileSync, statSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { dirname, isAbsolute, posix, relative, resolve, sep } from "node:path"; @@ -10,9 +10,15 @@ import dts from "vite-plugin-dts"; import { globSync } from "tinyglobby"; import { uiCommonCatalog } from "./src/i18n/catalog.ts"; +import { withLayerOrder } from "./scripts/layer-order.mjs"; const root = dirname(fileURLToPath(import.meta.url)); +const pkg = JSON.parse(readFileSync(resolve(root, "package.json"), "utf8")) as { + version: string; + dependencies: Record; +}; + /** * Every component and hook barrel is its own Rollup entry. Combined with * `preserveModules`, this is what makes the subpath exports in package.json @@ -110,10 +116,39 @@ function copyAssets(): Plugin { const english = resolve(root, "dist/ui-common-locales/en.json"); await mkdir(dirname(english), { recursive: true }); await writeFile(english, `${JSON.stringify(uiCommonCatalog, null, 2)}\n`); + + // Last, so it sees the copies above as well as Rollup's emitted assets. + await prependLayerOrder(resolve(root, "dist")); }, }; } +/** + * Put the cascade layer order statement at the top of every stylesheet in + * `dist`: the component sheets Rollup emitted, the copied package sheets, the + * pre-built theme and the one-line Astryx `@import` mirrors (a layer + * statement may precede `@import`). + * + * A layer's position is fixed by the first sheet that names it. Each component + * module imports its own sheet, and a consumer imports ui-common's modules + * before its entry stylesheet runs, so without this the first thing a bundle + * says about layers is `@layer ui-common{…}` and ui-common becomes the lowest + * layer, below Astryx's base and theme. The statement comes from + * `scripts/layer-order.mjs`; repeating it is a no-op. + */ +async function prependLayerOrder(dir: string): Promise { + for (const entry of await readdir(dir, { withFileTypes: true })) { + const path = resolve(dir, entry.name); + if (entry.isDirectory()) { + await prependLayerOrder(path); + } else if (entry.name.endsWith(".css")) { + const css = await readFile(path, "utf8"); + const next = withLayerOrder(css); + if (next !== css) await writeFile(path, next); + } + } +} + /** Strip a query suffix and express an id relative to the repository root. */ function sourceKey(id: string): string { const path = id.split("?")[0] ?? id; @@ -212,6 +247,11 @@ function linkComponentStyles(): Plugin { } export default defineConfig({ + // Read by the duplicate-copy warning (src/instance.ts). + define: { + __UI_COMMON_VERSION__: JSON.stringify(pkg.version), + __ASTRYX_CORE_VERSION__: JSON.stringify(pkg.dependencies["@astryxdesign/core"]), + }, plugins: [ react(), dts({ @@ -256,8 +296,13 @@ export default defineConfig({ globals: true, setupFiles: ["./src/test/setup.ts"], css: true, - // Consumer source the upgrade codemods run on, not tests of this package; - // and agent worktrees, which are other checkouts of this repository. - exclude: [...configDefaults.exclude, "test/upgrade/fixtures/**", ".claude/**"], + // The CLI package's tests (packages/cli/test) run here too, under the same + // setup. Its upgrade fixtures are consumer source the codemods run on, not + // tests; agent worktrees are other checkouts of this repository. + exclude: [ + ...configDefaults.exclude, + "packages/cli/test/upgrade/fixtures/**", + ".claude/**", + ], }, }); From 1268e0d2a840928c52866548e9b4157c3a16693a Mon Sep 17 00:00:00 2001 From: Jongeun Date: Fri, 2 Oct 2026 04:55:23 +0000 Subject: [PATCH 87/87] chore: prepare 0.2.0-alpha.15 --- CHANGELOG.md | 10 +++++++++- package.json | 2 +- packages/cli/package.json | 2 +- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e0d24ab..fcff8f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ Versioning follows the policy in [CONTRIBUTING.md](CONTRIBUTING.md#versioning). ## [Unreleased] +## [0.2.0-alpha.15] + +Hardening for the apps moving off 0.1: ui-common's styles now sit in their +cascade layer in every bundle, the CLI ships as its own package, +`@lablup/ui-common-cli`, React 19.2 is the floor, and the upgrade tool and +several components get the fixes a pre-merge review found. + ### Changed - **The `ui-common` CLI is its own package, `@lablup/ui-common-cli`**, in @@ -1429,7 +1436,8 @@ mid-migration. validation, and a clean external React install fixture. - Apache-2.0 license and the initial public boundary rules. -[Unreleased]: https://github.com/lablup/ui-common/compare/v0.2.0-alpha.14...HEAD +[Unreleased]: https://github.com/lablup/ui-common/compare/v0.2.0-alpha.15...HEAD +[0.2.0-alpha.15]: https://github.com/lablup/ui-common/compare/v0.2.0-alpha.14...v0.2.0-alpha.15 [0.2.0-alpha.14]: https://github.com/lablup/ui-common/compare/v0.2.0-alpha.13...v0.2.0-alpha.14 [0.2.0-alpha.13]: https://github.com/lablup/ui-common/compare/v0.2.0-alpha.12...v0.2.0-alpha.13 [0.2.0-alpha.12]: https://github.com/lablup/ui-common/compare/v0.2.0-alpha.11...v0.2.0-alpha.12 diff --git a/package.json b/package.json index 6ec63c1..a588c52 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@lablup/ui-common", - "version": "0.2.0-alpha.14", + "version": "0.2.0-alpha.15", "description": "Lablup's UI layer on Astryx: the Astryx surface re-exported 1:1, the Lablup theme, and shared components", "license": "Apache-2.0", "author": "Lablup Inc.", diff --git a/packages/cli/package.json b/packages/cli/package.json index b14d471..57da650 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@lablup/ui-common-cli", - "version": "0.2.0-alpha.14", + "version": "0.2.0-alpha.15", "description": "The ui-common command line: the pinned Astryx CLI in @lablup/ui-common terms, the agent block, and the upgrade codemods", "license": "Apache-2.0", "author": "Lablup Inc.",