diff --git a/.changeset/calm-trees-travel.md b/.changeset/calm-trees-travel.md new file mode 100644 index 000000000..998e8a25e --- /dev/null +++ b/.changeset/calm-trees-travel.md @@ -0,0 +1,5 @@ +--- +"@cube-dev/ui-kit": patch +--- + +Enlarge the size of the fullscreen dialog. diff --git a/.changeset/grumpy-windows-unite.md b/.changeset/grumpy-windows-unite.md new file mode 100644 index 000000000..d2f66d252 --- /dev/null +++ b/.changeset/grumpy-windows-unite.md @@ -0,0 +1,5 @@ +--- +"@cube-dev/ui-kit": patch +--- + +Add IconSwitch component for icon transitions. diff --git a/.changeset/hip-coats-sit.md b/.changeset/hip-coats-sit.md new file mode 100644 index 000000000..19fb15cb8 --- /dev/null +++ b/.changeset/hip-coats-sit.md @@ -0,0 +1,5 @@ +--- +"@cube-dev/ui-kit": patch +--- + +Remove redundant `isButton` prop from Item component. diff --git a/.changeset/hungry-geckos-kneel.md b/.changeset/hungry-geckos-kneel.md new file mode 100644 index 000000000..4c2180176 --- /dev/null +++ b/.changeset/hungry-geckos-kneel.md @@ -0,0 +1,5 @@ +--- +"@cube-dev/ui-kit": patch +--- + +Add `tight` modifier to `preset` style for setting line-height to the same value as font-size. diff --git a/.changeset/loud-worms-hunt.md b/.changeset/loud-worms-hunt.md new file mode 100644 index 000000000..aea14ef06 --- /dev/null +++ b/.changeset/loud-worms-hunt.md @@ -0,0 +1,5 @@ +--- +"@cube-dev/ui-kit": patch +--- + +Add `card` type to Item component. diff --git a/.changeset/strange-coats-tickle.md b/.changeset/strange-coats-tickle.md new file mode 100644 index 000000000..c0710336e --- /dev/null +++ b/.changeset/strange-coats-tickle.md @@ -0,0 +1,5 @@ +--- +"@cube-dev/ui-kit": patch +--- + +Add `preserveContent` prop to DisplayTransition component. When enabled (default: true), the component preserves children content during exit transitions, ensuring smooth animations even when parent components remove content immediately after hiding. diff --git a/.changeset/tasty-tokens-support.md b/.changeset/tasty-tokens-support.md new file mode 100644 index 000000000..714b7aafc --- /dev/null +++ b/.changeset/tasty-tokens-support.md @@ -0,0 +1,5 @@ +--- +"@cube-dev/ui-kit": minor +--- + +Add `tokens` prop to tasty components for defining CSS custom properties as inline styles. Tokens support design system values (`$name` for regular properties, `#name` for colors with RGB variants) and are merged from component defaults to instance usage. Use `tokens` instead of `style` prop for dynamic CSS custom properties. diff --git a/.changeset/tiny-buses-wave.md b/.changeset/tiny-buses-wave.md new file mode 100644 index 000000000..aeda055c3 --- /dev/null +++ b/.changeset/tiny-buses-wave.md @@ -0,0 +1,5 @@ +--- +"@cube-dev/ui-kit": patch +--- + +Add `title` type support to Item component. diff --git a/.changeset/violet-bees-promise.md b/.changeset/violet-bees-promise.md new file mode 100644 index 000000000..cf8906f6e --- /dev/null +++ b/.changeset/violet-bees-promise.md @@ -0,0 +1,9 @@ +--- +"@cube-dev/ui-kit": minor +--- + +Add dynamic icon support to Button and Item components. The `icon` and `rightIcon` props now support: +- `true` - renders an empty slot (reserves space but shows nothing) +- Function `({ loading, selected, ...mods }) => ReactNode | true` - dynamically renders icon based on component modifiers + +Also made `Mods` type generic for better type definitions: `Mods<{ loading?: boolean }>` instead of extending interface. diff --git a/.changeset/violet-gifts-buy.md b/.changeset/violet-gifts-buy.md new file mode 100644 index 000000000..07937a75c --- /dev/null +++ b/.changeset/violet-gifts-buy.md @@ -0,0 +1,5 @@ +--- +"@cube-dev/ui-kit": patch +--- + +Remove the selected mod in DisclosureTrigger.' diff --git a/.changeset/witty-geckos-dance.md b/.changeset/witty-geckos-dance.md new file mode 100644 index 000000000..3c3a862a1 --- /dev/null +++ b/.changeset/witty-geckos-dance.md @@ -0,0 +1,5 @@ +--- +"@cube-dev/ui-kit": patch +--- + +Fix Layout.PanelHeader props type. diff --git a/.size-limit.cjs b/.size-limit.cjs index 2820e41c9..dcee2f310 100644 --- a/.size-limit.cjs +++ b/.size-limit.cjs @@ -20,7 +20,7 @@ module.exports = [ }), ); }, - limit: '315kB', + limit: '320kB', }, { name: 'Tree shaking (just a Button)', diff --git a/package.json b/package.json index 84115b078..586aa4704 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ "prepare": "husky install", "clear": "pnpm clear:dist && rimraf ./storybook-docs ./storybook-static ./node_modules/.cache", "clear:dist": "rimraf ./dist", - "release": "pnpm build && changeset publish", + "release": "pnpm build && changeset publish --provenance", "postinstall": "git config blame.ignoreRevsFile .git-blame-ignore-revs", "add-icons": "cd src/icons && node add-new-icon.js && pnpm fix" }, @@ -117,9 +117,9 @@ "@size-limit/webpack": "^8.2.4", "@size-limit/webpack-why": "^8.2.4", "@statoscope/cli": "^5.20.1", - "@storybook/addon-docs": "^10.0.8", - "@storybook/addon-links": "^10.0.8", - "@storybook/react-vite": "^10.0.8", + "@storybook/addon-docs": "^10.1.10", + "@storybook/addon-links": "^10.1.10", + "@storybook/react-vite": "^10.1.10", "@swc/core": "^1.3.36", "@swc/jest": "^0.2.36", "@testing-library/dom": "^10.4.1", @@ -148,7 +148,7 @@ "eslint-plugin-jsx-a11y": "^6.10.2", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^5.2.0", - "eslint-plugin-storybook": "^10.0.8", + "eslint-plugin-storybook": "^10.1.10", "husky": "^6.0.0", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", @@ -165,7 +165,7 @@ "react-test-renderer": "^19.1.1", "rimraf": "^6.0.1", "size-limit": "^8.2.6", - "storybook": "^10.0.8", + "storybook": "^10.1.10", "storybook-addon-turbo-build": "^2.0.1", "styled-components": "^6.1.19", "swc-loader": "^0.2.6", @@ -182,4 +182,4 @@ "node": ">=22.14.0", "pnpm": "^10.0.0" } -} \ No newline at end of file +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 45a53e52b..ef7fa42b0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -163,14 +163,14 @@ importers: specifier: ^5.20.1 version: 5.20.1 '@storybook/addon-docs': - specifier: ^10.0.8 - version: 10.0.8(@types/react@19.1.10)(esbuild@0.25.9)(rollup@4.46.4)(storybook@10.0.8(@testing-library/dom@10.4.1)(prettier@3.2.5)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(vite@7.1.3(@types/node@22.17.2)(terser@5.31.1)))(vite@7.1.3(@types/node@22.17.2)(terser@5.31.1))(webpack@5.76.1(@swc/core@1.3.36)(esbuild@0.25.9)) + specifier: ^10.1.10 + version: 10.1.10(@types/react@19.1.10)(esbuild@0.25.9)(rollup@4.46.4)(storybook@10.1.10(@testing-library/dom@10.4.1)(prettier@3.2.5)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(vite@7.1.3(@types/node@22.17.2)(terser@5.31.1))(webpack@5.76.1(@swc/core@1.3.36)(esbuild@0.25.9)) '@storybook/addon-links': - specifier: ^10.0.8 - version: 10.0.8(react@19.1.1)(storybook@10.0.8(@testing-library/dom@10.4.1)(prettier@3.2.5)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(vite@7.1.3(@types/node@22.17.2)(terser@5.31.1))) + specifier: ^10.1.10 + version: 10.1.10(react@19.1.1)(storybook@10.1.10(@testing-library/dom@10.4.1)(prettier@3.2.5)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)) '@storybook/react-vite': - specifier: ^10.0.8 - version: 10.0.8(esbuild@0.25.9)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(rollup@4.46.4)(storybook@10.0.8(@testing-library/dom@10.4.1)(prettier@3.2.5)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(vite@7.1.3(@types/node@22.17.2)(terser@5.31.1)))(typescript@5.6.3)(vite@7.1.3(@types/node@22.17.2)(terser@5.31.1))(webpack@5.76.1(@swc/core@1.3.36)(esbuild@0.25.9)) + specifier: ^10.1.10 + version: 10.1.10(esbuild@0.25.9)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(rollup@4.46.4)(storybook@10.1.10(@testing-library/dom@10.4.1)(prettier@3.2.5)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(typescript@5.6.3)(vite@7.1.3(@types/node@22.17.2)(terser@5.31.1))(webpack@5.76.1(@swc/core@1.3.36)(esbuild@0.25.9)) '@swc/core': specifier: ^1.3.36 version: 1.3.36 @@ -242,7 +242,7 @@ importers: version: 10.1.2(eslint@9.25.1) eslint-config-react-app: specifier: ^7.0.1 - version: 7.0.1(@babel/plugin-syntax-flow@7.24.7(@babel/core@7.25.8))(@babel/plugin-transform-react-jsx@7.24.7(@babel/core@7.25.8))(eslint@9.25.1)(jest@29.7.0(@types/node@22.17.2)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.3.36)(@types/node@22.17.2)(typescript@5.6.3)))(typescript@5.6.3) + version: 7.0.1(@babel/plugin-syntax-flow@7.24.7(@babel/core@7.28.5))(@babel/plugin-transform-react-jsx@7.24.7(@babel/core@7.28.5))(eslint@9.25.1)(jest@29.7.0(@types/node@22.17.2)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.3.36)(@types/node@22.17.2)(typescript@5.6.3)))(typescript@5.6.3) eslint-plugin-import: specifier: ^2.31.0 version: 2.31.0(@typescript-eslint/parser@8.31.0(eslint@9.25.1)(typescript@5.6.3))(eslint@9.25.1) @@ -256,8 +256,8 @@ importers: specifier: ^5.2.0 version: 5.2.0(eslint@9.25.1) eslint-plugin-storybook: - specifier: ^10.0.8 - version: 10.0.8(eslint@9.25.1)(storybook@10.0.8(@testing-library/dom@10.4.1)(prettier@3.2.5)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(vite@7.1.3(@types/node@22.17.2)(terser@5.31.1)))(typescript@5.6.3) + specifier: ^10.1.10 + version: 10.1.10(eslint@9.25.1)(storybook@10.1.10(@testing-library/dom@10.4.1)(prettier@3.2.5)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(typescript@5.6.3) husky: specifier: ^6.0.0 version: 6.0.0 @@ -307,8 +307,8 @@ importers: specifier: ^8.2.6 version: 8.2.6 storybook: - specifier: ^10.0.8 - version: 10.0.8(@testing-library/dom@10.4.1)(prettier@3.2.5)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(vite@7.1.3(@types/node@22.17.2)(terser@5.31.1)) + specifier: ^10.1.10 + version: 10.1.10(@testing-library/dom@10.4.1)(prettier@3.2.5)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) storybook-addon-turbo-build: specifier: ^2.0.1 version: 2.0.1(webpack@5.76.1(@swc/core@1.3.36)(esbuild@0.25.9)) @@ -366,10 +366,18 @@ packages: resolution: {integrity: sha512-ZsysZyXY4Tlx+Q53XdnOFmqwfB9QDTHYxaZYajWRoBLuLEAwI2UIbtxOjWh/cFaa9IKUlcB+DDuoskLuKu56JA==} engines: {node: '>=6.9.0'} + '@babel/compat-data@7.28.5': + resolution: {integrity: sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==} + engines: {node: '>=6.9.0'} + '@babel/core@7.25.8': resolution: {integrity: sha512-Oixnb+DzmRT30qu9d3tJSQkxuygWm32DFykT4bRoORPa9hZ/L4KhVB/XiRm6KG+roIEM7DBQlmg27kw2HZkdZg==} engines: {node: '>=6.9.0'} + '@babel/core@7.28.5': + resolution: {integrity: sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==} + engines: {node: '>=6.9.0'} + '@babel/eslint-parser@7.22.15': resolution: {integrity: sha512-yc8OOBIQk1EcRrpizuARSQS0TWAcOMpEJ1aafhNznaeYkeL+OhqnDObGFylB8ka8VFF/sZc+S4RzHyO+3LjQxg==} engines: {node: ^10.13.0 || ^12.13.0 || >=14.0.0} @@ -381,6 +389,10 @@ packages: resolution: {integrity: sha512-VybsKvpiN1gU1sdMZIp7FcqphVVKEwcuj02x73uvcHE0PTihx1nlBcowYWhDwjpoAXRv43+gDzyggGnn1XZhVw==} engines: {node: '>=6.9.0'} + '@babel/generator@7.28.5': + resolution: {integrity: sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==} + engines: {node: '>=6.9.0'} + '@babel/helper-annotate-as-pure@7.24.7': resolution: {integrity: sha512-BaDeOonYvhdKw+JoMVkAixAAJzG2jVPIwWoKBPdYuY9b452e2rPuI9QPYh3KpofZ3pW2akOmwZLOiOsHMiqRAg==} engines: {node: '>=6.9.0'} @@ -393,6 +405,10 @@ packages: resolution: {integrity: sha512-DniTEax0sv6isaw6qSQSfV4gVRNtw2rte8HHM45t9ZR0xILaufBRNkpMifCRiAPyvL4ACD6v0gfCwCmtOQaV4A==} engines: {node: '>=6.9.0'} + '@babel/helper-compilation-targets@7.27.2': + resolution: {integrity: sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==} + engines: {node: '>=6.9.0'} + '@babel/helper-create-class-features-plugin@7.24.1': resolution: {integrity: sha512-1yJa9dX9g//V6fDebXoEfEsxkZHk3Hcbm+zLhyu6qVgYFLvmTALTeV+jNU9e5RnYtioBrGEOdoI2joMSNQ/+aA==} engines: {node: '>=6.9.0'} @@ -423,6 +439,10 @@ packages: resolution: {integrity: sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==} engines: {node: '>=6.9.0'} + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + '@babel/helper-hoist-variables@7.22.5': resolution: {integrity: sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw==} engines: {node: '>=6.9.0'} @@ -435,12 +455,22 @@ packages: resolution: {integrity: sha512-o0xCgpNmRohmnoWKQ0Ij8IdddjyBFE4T2kagL/x6M3+4zUgc+4qTOUBoNe4XxDskt1HPKO007ZPiMgLDq2s7Kw==} engines: {node: '>=6.9.0'} + '@babel/helper-module-imports@7.27.1': + resolution: {integrity: sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==} + engines: {node: '>=6.9.0'} + '@babel/helper-module-transforms@7.25.7': resolution: {integrity: sha512-k/6f8dKG3yDz/qCwSM+RKovjMix563SLxQFo0UhRNo239SP6n9u5/eLtKD6EAjwta2JHJ49CsD8pms2HdNiMMQ==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0 + '@babel/helper-module-transforms@7.28.3': + resolution: {integrity: sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + '@babel/helper-optimise-call-expression@7.22.5': resolution: {integrity: sha512-HBwaojN0xFRx4yIvpwGqxiV2tUfl7401jlok564NgB9EHS1y6QT17FmKWm4ztqjeVdXLuC4fSvHc5ePpQjoTbw==} engines: {node: '>=6.9.0'} @@ -477,6 +507,10 @@ packages: resolution: {integrity: sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==} engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.25.9': resolution: {integrity: sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==} engines: {node: '>=6.9.0'} @@ -485,10 +519,18 @@ packages: resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==} engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-option@7.25.7': resolution: {integrity: sha512-ytbPLsm+GjArDYXJ8Ydr1c/KJuutjF2besPNbIZnZ6MKUxi/uTA22t2ymmA4WFjZFpjiAMO0xuuJPqK2nvDVfQ==} engines: {node: '>=6.9.0'} + '@babel/helper-validator-option@7.27.1': + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + engines: {node: '>=6.9.0'} + '@babel/helper-wrap-function@7.22.20': resolution: {integrity: sha512-pms/UwkOpnQe/PDAEdV/d7dVCoBbB+R4FvYoHGZz+4VPcg7RtYy2KP7S2lbuWM6FCSgob5wshfGESbC/hzNXZw==} engines: {node: '>=6.9.0'} @@ -497,11 +539,20 @@ packages: resolution: {integrity: sha512-Sv6pASx7Esm38KQpF/U/OXLwPPrdGHNKoeblRxgZRLXnAtnkEe4ptJPDtAZM7fBLadbc1Q07kQpSiGQ0Jg6tRA==} engines: {node: '>=6.9.0'} + '@babel/helpers@7.28.4': + resolution: {integrity: sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==} + engines: {node: '>=6.9.0'} + '@babel/parser@7.27.0': resolution: {integrity: sha512-iaepho73/2Pz7w2eMS0Q5f83+0RKI7i4xmiYeBmDzfRVbQtTOG7Ts0S4HzJVsTMGI9keU8rNfuZr8DKfSt7Yyg==} engines: {node: '>=6.0.0'} hasBin: true + '@babel/parser@7.28.5': + resolution: {integrity: sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.24.1': resolution: {integrity: sha512-y4HqEnkelJIOQGd+3g1bTeKsA5c6qM7eOn7VggGVbBc0y8MLSKHacwcIE2PplNlQSj0PqS9rrXL/nkPVK+kUNg==} engines: {node: '>=6.9.0'} @@ -1079,14 +1130,26 @@ packages: resolution: {integrity: sha512-2ncevenBqXI6qRMukPlXwHKHchC7RyMuu4xv5JBXRfOGVcTy1mXCD12qrp7Jsoxll1EV3+9sE4GugBVRjT2jFA==} engines: {node: '>=6.9.0'} + '@babel/template@7.27.2': + resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} + engines: {node: '>=6.9.0'} + '@babel/traverse@7.27.0': resolution: {integrity: sha512-19lYZFzYVQkkHkl4Cy4WrAVcqBkgvV2YM2TU3xG6DIwO7O3ecbDPfW3yM3bjAGcqcQHi+CCtjMR3dIEHxsd6bA==} engines: {node: '>=6.9.0'} + '@babel/traverse@7.28.5': + resolution: {integrity: sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==} + engines: {node: '>=6.9.0'} + '@babel/types@7.27.0': resolution: {integrity: sha512-H45s8fVLYjbhFH62dIJ3WtmJ6RSPt/3DRO0ZcT2SUiYiQyz3BLVb9ADEnLl91m74aQPS3AzzeajZHYOalWe3bg==} engines: {node: '>=6.9.0'} + '@babel/types@7.28.5': + resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} + engines: {node: '>=6.9.0'} + '@bcoe/v8-coverage@0.2.3': resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} @@ -1634,6 +1697,14 @@ packages: '@internationalized/string@3.2.7': resolution: {integrity: sha512-D4OHBjrinH+PFZPvfCXvG28n2LSykWcJ7GIioQL+ok0LON15SdfoUssoHzzOUmVZLbRoREsQXVzA6r8JKsbP6A==} + '@isaacs/balanced-match@4.0.1': + resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==} + engines: {node: 20 || >=22} + + '@isaacs/brace-expansion@5.0.0': + resolution: {integrity: sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==} + engines: {node: 20 || >=22} + '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -1716,8 +1787,8 @@ packages: resolution: {integrity: sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - '@joshwooding/vite-plugin-react-docgen-typescript@0.6.1': - resolution: {integrity: sha512-J4BaTocTOYFkMHIra1JDWrMWpNmBl4EkplIwHEsV8aeUOtdWjwSnln9U7twjMFTAEB7mptNtSKyVi1Y2W9sDJw==} + '@joshwooding/vite-plugin-react-docgen-typescript@0.6.3': + resolution: {integrity: sha512-9TGZuAX+liGkNKkwuo3FYJu7gHWT0vkBcf7GkOe7s7fmC19XwH/4u5u7sDIFrMooe558ORcmuBvBz7Ur5PlbHw==} peerDependencies: typescript: '>= 4.3.x' vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 @@ -1725,6 +1796,9 @@ packages: typescript: optional: true + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + '@jridgewell/gen-mapping@0.3.5': resolution: {integrity: sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==} engines: {node: '>=6.0.0'} @@ -1752,6 +1826,9 @@ packages: '@jridgewell/trace-mapping@0.3.25': resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} @@ -1830,10 +1907,6 @@ packages: '@octokit/types@13.8.0': resolution: {integrity: sha512-x7DjTIbEpEWXK99DMd01QfWy0hd5h4EN+Q7shkdKds3otGQP+oWE/y0A76i1OvH9fygo4ddvNf7ZvF0t78P98A==} - '@pkgjs/parseargs@0.11.0': - resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} - engines: {node: '>=14'} - '@react-aria/breadcrumbs@3.5.27': resolution: {integrity: sha512-fuXD9nvBaBVZO0Z6EntBlxQD621/2Ldcxz76jFjc4V/jNOq/6BIVQRtpnAYYrSTiW3ZV2IoAyxRWNxQU22hOow==} peerDependencies: @@ -2637,32 +2710,32 @@ packages: '@statoscope/webpack-ui@5.26.2': resolution: {integrity: sha512-01RYHyG2nrif9Y5i717EI6jUMqdypbrOMdqpNUBFlw2rmaEB5t21V35b5Vd0pZEgesKNijE3ULvP7EQ37jEbIg==} - '@storybook/addon-docs@10.0.8': - resolution: {integrity: sha512-PYuaGXGycsamK/7OrFoE4syHGy22mdqqArl67cfosRwmRxZEI9ManQK0jTjNQM9ZX14NpThMOSWNGoWLckkxog==} + '@storybook/addon-docs@10.1.10': + resolution: {integrity: sha512-PSJVtawnGNrEkeLJQn9TTdeqrtDij8onvmnFtfkDaFG5IaUdQaLX9ibJ4gfxYakq+BEtlCcYiWErNJcqDrDluQ==} peerDependencies: - storybook: ^10.0.8 + storybook: ^10.1.10 - '@storybook/addon-links@10.0.8': - resolution: {integrity: sha512-LnakruogdN5ND0cF0SOKyhzbEeIGDe1njkufX2aR9LOXQ0mMj5S2P86TdP87dR5R9bJjYYPPg/F7sjsAiI1Lqg==} + '@storybook/addon-links@10.1.10': + resolution: {integrity: sha512-SVKFDb14mne16QMGkmOEk+T4NLvCuFJJ1ecebQ01cPiG5gM72LhzYkAro717Aizd6owyMqcWs0Rsfwl09qi5zA==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - storybook: ^10.0.8 + storybook: ^10.1.10 peerDependenciesMeta: react: optional: true - '@storybook/builder-vite@10.0.8': - resolution: {integrity: sha512-kaf/pUENzXxYgQMHGGPNiIk1ieb+SOMuSeLKx8wAUOlQOrzhtSH+ItACW/l43t+O6YZ8jYHoNBMF1kdQ1+Y5+w==} + '@storybook/builder-vite@10.1.10': + resolution: {integrity: sha512-6m6zOyDhHLynv3lvkH70s1YoIkIFPhbpGsBKvHchRLrZLe8hCPeafIFLfZRPoD4yIPwBS6rWbjMsSvBMFlR+ag==} peerDependencies: - storybook: ^10.0.8 + storybook: ^10.1.10 vite: ^5.0.0 || ^6.0.0 || ^7.0.0 - '@storybook/csf-plugin@10.0.8': - resolution: {integrity: sha512-OtLUWHIm3SDGtclQn6Mdd/YsWizLBgdEBRAdekGtwI/TvICfT7gpWYIycP53v2t9ufu2MIXjsxtV2maZKs8sZg==} + '@storybook/csf-plugin@10.1.10': + resolution: {integrity: sha512-2dri4TRU8uuj/skmx/ZBw+GnnXf8EZHiMDMeijVRdBQtYFWPeoYzNIrGRpNfbuGpnDP0dcxrqti/TsedoxwFkA==} peerDependencies: esbuild: '*' rollup: '*' - storybook: ^10.0.8 + storybook: ^10.1.10 vite: '*' webpack: '*' peerDependenciesMeta: @@ -2678,34 +2751,33 @@ packages: '@storybook/global@5.0.0': resolution: {integrity: sha512-FcOqPAXACP0I3oJ/ws6/rrPT9WGhu915Cg8D02a9YxLo0DE9zI+a9A5gRGvmQ09fiWPukqI8ZAEoQEdWUKMQdQ==} - '@storybook/icons@1.6.0': - resolution: {integrity: sha512-hcFZIjW8yQz8O8//2WTIXylm5Xsgc+lW9ISLgUk1xGmptIJQRdlhVIXCpSyLrQaaRiyhQRaVg7l3BD9S216BHw==} - engines: {node: '>=14.0.0'} + '@storybook/icons@2.0.1': + resolution: {integrity: sha512-/smVjw88yK3CKsiuR71vNgWQ9+NuY2L+e8X7IMrFjexjm6ZR8ULrV2DRkTA61aV6ryefslzHEGDInGpnNeIocg==} peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta - react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - '@storybook/react-dom-shim@10.0.8': - resolution: {integrity: sha512-ojuH22MB9Sz6rWbhTmC5IErZr0ZADbZijtPteUdydezY7scORT00UtbNoBcG0V6iVjdChgDtSKw2KHUUfchKqg==} + '@storybook/react-dom-shim@10.1.10': + resolution: {integrity: sha512-9pmUbEr1MeMHg9TG0c2jVUfHWr2AA86vqZGphY/nT6mbe/rGyWtBl5EnFLrz6WpI8mo3h+Kxs6p2oiuIYieRtw==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - storybook: ^10.0.8 + storybook: ^10.1.10 - '@storybook/react-vite@10.0.8': - resolution: {integrity: sha512-HS2X4qlitrZr3/sN2+ollxAaNE813IasZRE8lOez1Ey1ISGBtYIb9rmJs82MK35+yDM0pHdiDjkFMD4SkNYh2g==} + '@storybook/react-vite@10.1.10': + resolution: {integrity: sha512-6kE4/88YuwO07P0DR6caKNDNvCB/VnpimPmj4Jv6qmqrBgnoOOiXHIKyHJD+EjNyrbbwv4ygG01RVEajpjQaDA==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - storybook: ^10.0.8 + storybook: ^10.1.10 vite: ^5.0.0 || ^6.0.0 || ^7.0.0 - '@storybook/react@10.0.8': - resolution: {integrity: sha512-PkuPb8sAqmjjkowSzm3rutiSuETvZI2F8SnjbHE6FRqZWWK4iFoaUrQbrg5kpPAtX//xIrqkdFwlbmQ3skhiPA==} + '@storybook/react@10.1.10': + resolution: {integrity: sha512-9Rpr8/wX0p5/EaulrxpqrjKjhGaA/Ab9HgxzTqs2Shz0gvMAQHoiRnTEp7RCCkP49ruFYnIp0yGRSovu03LakQ==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - storybook: ^10.0.8 + storybook: ^10.1.10 typescript: '>= 4.9.x' peerDependenciesMeta: typescript: @@ -2902,6 +2974,9 @@ packages: '@types/babel__traverse@7.18.5': resolution: {integrity: sha512-enCvTL8m/EHS/zIvJno9nE+ndYPh1/oNFzRYRmtUqJICG2VnCSBzMLW5VN2KCQU91f23tsNKR8v7VJJQMatl7Q==} + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/chai@5.2.2': resolution: {integrity: sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==} @@ -3582,6 +3657,10 @@ packages: buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + bundle-name@4.1.0: + resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} + engines: {node: '>=18'} + bytes-iec@3.1.1: resolution: {integrity: sha512-fey6+4jDK7TFtFg/klGSvNKJctyU7n2aQdnM+CO0ruLPbqqMOM8Tio0Pc+deqUeVKX1tL5DQep1zQ7+37aTAsA==} engines: {node: '>= 0.8'} @@ -3967,6 +4046,14 @@ packages: resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} engines: {node: '>=0.10.0'} + default-browser-id@5.0.1: + resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} + engines: {node: '>=18'} + + default-browser@5.4.0: + resolution: {integrity: sha512-XDuvSq38Hr1MdN47EDvYtx3U0MTqpCEn+F6ft8z2vYDzMrvQhVp0ui9oQdqW3MvK3vqUETglt1tVGgjLuJ5izg==} + engines: {node: '>=18'} + defaults@1.0.4: resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} @@ -3978,6 +4065,10 @@ packages: resolution: {integrity: sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==} engines: {node: '>=8'} + define-lazy-prop@3.0.0: + resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} + engines: {node: '>=12'} + define-properties@1.2.1: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} @@ -4280,11 +4371,11 @@ packages: peerDependencies: eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7 - eslint-plugin-storybook@10.0.8: - resolution: {integrity: sha512-ZKEMFhF/z/HRVvIgnEIYG2uAqmuLbkebUdHH3DpGHE64GPgk+KozcpqnD6zNk5vJ407bFmcWsGinBc2zi74f0g==} + eslint-plugin-storybook@10.1.10: + resolution: {integrity: sha512-ITr6Aq3buR/DuDATkq1BafUVJLybyo676fY+tj9Zjd1Ak+UXBAMQcQ++tiBVVHm1RqADwM3b1o6bnWHK2fPPKw==} peerDependencies: eslint: '>=8' - storybook: ^10.0.8 + storybook: ^10.1.10 eslint-plugin-testing-library@5.11.1: resolution: {integrity: sha512-5eX9e1Kc2PqVRed3taaLnAAqPZGEX75C+M/rXzUAI3wIg/ZxzUm1OVAwfe/O+vE+6YXOLetSe9g5GKD2ecXipw==} @@ -4479,6 +4570,10 @@ packages: resolution: {integrity: sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==} engines: {node: '>=14'} + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + form-data@4.0.0: resolution: {integrity: sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==} engines: {node: '>= 6'} @@ -4572,16 +4667,16 @@ packages: glob-to-regexp@0.4.1: resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} - glob@10.3.10: - resolution: {integrity: sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==} - engines: {node: '>=16 || 14 >=14.17'} - hasBin: true - glob@11.0.1: resolution: {integrity: sha512-zrQDm8XPnYEKawJScsnM0QzobJxlT/kHOOlRTio8IH/GrmxRE5fjllkzdaHclIuNjUQTJYH2xHNIGfdpJkDJUw==} engines: {node: 20 || >=22} hasBin: true + glob@11.1.0: + resolution: {integrity: sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==} + engines: {node: 20 || >=22} + hasBin: true + glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} deprecated: Glob versions prior to v9 are no longer supported @@ -4806,6 +4901,11 @@ packages: engines: {node: '>=8'} hasBin: true + is-docker@3.0.0: + resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + hasBin: true + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -4838,6 +4938,11 @@ packages: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} + is-inside-container@1.0.0: + resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} + engines: {node: '>=14.16'} + hasBin: true + is-map@2.0.3: resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} engines: {node: '>= 0.4'} @@ -4940,6 +5045,10 @@ packages: resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} engines: {node: '>=8'} + is-wsl@3.1.0: + resolution: {integrity: sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==} + engines: {node: '>=16'} + isarray@2.0.5: resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} @@ -4974,14 +5083,14 @@ packages: resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} engines: {node: '>= 0.4'} - jackspeak@2.3.6: - resolution: {integrity: sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==} - engines: {node: '>=14'} - jackspeak@4.1.0: resolution: {integrity: sha512-9DDdhb5j6cpeitCbvLO7n7J4IxnbM6hoF6O1g4HQ5TfhvvKN8ywDM7668ZhMHRqVmxqhps/F6syWK2KcPxYlkw==} engines: {node: 20 || >=22} + jackspeak@4.1.1: + resolution: {integrity: sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==} + engines: {node: 20 || >=22} + javascript-natural-sort@0.7.1: resolution: {integrity: sha512-nO6jcEfZWQXDhOiBtG2KvKyEptz7RVbpGP4vTD2hLBdmNQSsCiicO2Ioinv6UI4y9ukqnBpy+XZ9H6uLNgJTlw==} @@ -5347,10 +5456,6 @@ packages: loupe@3.2.0: resolution: {integrity: sha512-2NCfZcT5VGVNX9mSZIxLRkEAegDGBpuQZBy13desuHeVORmBDyAET4TkJr4SjqQy3A8JDofMN6LpkK8Xcm/dlw==} - lru-cache@10.2.0: - resolution: {integrity: sha512-2bIM8x+VAf6JT4bKAljS1qUWgMsqZRPGJS6FSahIMPVvctcNhyVp7AJu7quxOW9jwkryBReKZY5tY5JYv2n/7Q==} - engines: {node: 14 || >=16.14} - lru-cache@11.1.0: resolution: {integrity: sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A==} engines: {node: 20 || >=22} @@ -5572,6 +5677,10 @@ packages: resolution: {integrity: sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ==} engines: {node: 20 || >=22} + minimatch@10.1.1: + resolution: {integrity: sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==} + engines: {node: 20 || >=22} + minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} @@ -5708,6 +5817,10 @@ packages: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} engines: {node: '>=6'} + open@10.2.0: + resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==} + engines: {node: '>=18'} + open@8.4.2: resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==} engines: {node: '>=12'} @@ -5802,10 +5915,6 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} - path-scurry@1.10.1: - resolution: {integrity: sha512-MkhCqzzBEpPvxxQ71Md0b1Kk51W01lrYvlMzSUaIzNsODdd7mqhiimSZlr+VegAz5Z6Vzt9Xg2ttE//XBhH3EQ==} - engines: {node: '>=16 || 14 >=14.17'} - path-scurry@2.0.0: resolution: {integrity: sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==} engines: {node: 20 || >=22} @@ -5989,8 +6098,8 @@ packages: peerDependencies: typescript: '>= 4.3.x' - react-docgen@8.0.0: - resolution: {integrity: sha512-kmob/FOTwep7DUWf9KjuenKX0vyvChr3oTdvvPt09V60Iz75FJp+T/0ZeHMbAfJj2WaVWqAPP5Hmm3PYzSPPKg==} + react-docgen@8.0.2: + resolution: {integrity: sha512-+NRMYs2DyTP4/tqWz371Oo50JqmWltR1h2gcdgUMAWZJIAvrd0/SqlCfx7tpzpl/s36rzw6qH2MjoNrxtRNYhA==} engines: {node: ^20.9.0 || >=22} react-dom@19.1.1: @@ -6199,6 +6308,10 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + run-applescript@7.1.0: + resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} + engines: {node: '>=18'} + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -6393,8 +6506,8 @@ packages: storybook-addon-turbo-build@2.0.1: resolution: {integrity: sha512-NP9e42fOmhkRe93okDlmIJ+2m+j4c9HZSa8EQJPJiJBQiAZ6MrjL6v0jzMukcwhIlu91RtHSkjlACm3xbi9jWQ==} - storybook@10.0.8: - resolution: {integrity: sha512-vQMufKKA9TxgoEDHJv3esrqUkjszuuRiDkThiHxENFPdQawHhm2Dei+iwNRwH5W671zTDy9iRT9P1KDjcU5Iyw==} + storybook@10.1.10: + resolution: {integrity: sha512-oK0t0jEogiKKfv5Z1ao4Of99+xWw1TMUGuGRYDQS4kp2yyBsJQEgu7NI7OLYsCDI6gzt5p3RPtl1lqdeVLUi8A==} hasBin: true peerDependencies: prettier: ^2 || ^3 @@ -7056,6 +7169,10 @@ packages: utf-8-validate: optional: true + wsl-utils@0.1.0: + resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} + engines: {node: '>=18'} + xml-name-validator@4.0.0: resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==} engines: {node: '>=12'} @@ -7164,6 +7281,8 @@ snapshots: '@babel/compat-data@7.25.8': {} + '@babel/compat-data@7.28.5': {} + '@babel/core@7.25.8': dependencies: '@ampproject/remapping': 2.2.1 @@ -7184,6 +7303,26 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/core@7.28.5': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/generator': 7.28.5 + '@babel/helper-compilation-targets': 7.27.2 + '@babel/helper-module-transforms': 7.28.3(@babel/core@7.28.5) + '@babel/helpers': 7.28.4 + '@babel/parser': 7.28.5 + '@babel/template': 7.27.2 + '@babel/traverse': 7.28.5 + '@babel/types': 7.28.5 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.3.5 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + '@babel/eslint-parser@7.22.15(@babel/core@7.25.8)(eslint@9.25.1)': dependencies: '@babel/core': 7.25.8 @@ -7200,6 +7339,14 @@ snapshots: '@jridgewell/trace-mapping': 0.3.25 jsesc: 3.0.2 + '@babel/generator@7.28.5': + dependencies: + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.0.2 + '@babel/helper-annotate-as-pure@7.24.7': dependencies: '@babel/types': 7.27.0 @@ -7216,6 +7363,14 @@ snapshots: lru-cache: 5.1.1 semver: 6.3.1 + '@babel/helper-compilation-targets@7.27.2': + dependencies: + '@babel/compat-data': 7.28.5 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.24.0 + lru-cache: 5.1.1 + semver: 6.3.1 + '@babel/helper-create-class-features-plugin@7.24.1(@babel/core@7.25.8)': dependencies: '@babel/core': 7.25.8 @@ -7265,6 +7420,8 @@ snapshots: '@babel/template': 7.27.0 '@babel/types': 7.27.0 + '@babel/helper-globals@7.28.0': {} + '@babel/helper-hoist-variables@7.22.5': dependencies: '@babel/types': 7.27.0 @@ -7280,6 +7437,13 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/helper-module-imports@7.27.1': + dependencies: + '@babel/traverse': 7.28.5 + '@babel/types': 7.28.5 + transitivePeerDependencies: + - supports-color + '@babel/helper-module-transforms@7.25.7(@babel/core@7.25.8)': dependencies: '@babel/core': 7.25.8 @@ -7290,6 +7454,15 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/helper-module-transforms@7.28.3(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-module-imports': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.28.5 + transitivePeerDependencies: + - supports-color + '@babel/helper-optimise-call-expression@7.22.5': dependencies: '@babel/types': 7.27.0 @@ -7327,12 +7500,18 @@ snapshots: '@babel/helper-string-parser@7.25.9': {} + '@babel/helper-string-parser@7.27.1': {} + '@babel/helper-validator-identifier@7.25.9': {} '@babel/helper-validator-identifier@7.27.1': {} + '@babel/helper-validator-identifier@7.28.5': {} + '@babel/helper-validator-option@7.25.7': {} + '@babel/helper-validator-option@7.27.1': {} + '@babel/helper-wrap-function@7.22.20': dependencies: '@babel/helper-function-name': 7.23.0 @@ -7344,10 +7523,19 @@ snapshots: '@babel/template': 7.27.0 '@babel/types': 7.27.0 + '@babel/helpers@7.28.4': + dependencies: + '@babel/template': 7.27.2 + '@babel/types': 7.28.5 + '@babel/parser@7.27.0': dependencies: '@babel/types': 7.27.0 + '@babel/parser@7.28.5': + dependencies: + '@babel/types': 7.28.5 + '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.24.1(@babel/core@7.25.8)': dependencies: '@babel/core': 7.25.8 @@ -7458,6 +7646,11 @@ snapshots: '@babel/core': 7.25.8 '@babel/helper-plugin-utils': 7.25.7 + '@babel/plugin-syntax-flow@7.24.7(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.25.7 + '@babel/plugin-syntax-import-assertions@7.24.1(@babel/core@7.25.8)': dependencies: '@babel/core': 7.25.8 @@ -7483,6 +7676,11 @@ snapshots: '@babel/core': 7.25.8 '@babel/helper-plugin-utils': 7.25.7 + '@babel/plugin-syntax-jsx@7.24.7(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.25.7 + '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.25.8)': dependencies: '@babel/core': 7.25.8 @@ -7814,6 +8012,17 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/plugin-transform-react-jsx@7.24.7(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-annotate-as-pure': 7.24.7 + '@babel/helper-module-imports': 7.25.7 + '@babel/helper-plugin-utils': 7.25.7 + '@babel/plugin-syntax-jsx': 7.24.7(@babel/core@7.28.5) + '@babel/types': 7.27.0 + transitivePeerDependencies: + - supports-color + '@babel/plugin-transform-react-pure-annotations@7.22.5(@babel/core@7.25.8)': dependencies: '@babel/core': 7.25.8 @@ -8030,6 +8239,12 @@ snapshots: '@babel/parser': 7.27.0 '@babel/types': 7.27.0 + '@babel/template@7.27.2': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 + '@babel/traverse@7.27.0': dependencies: '@babel/code-frame': 7.26.2 @@ -8042,11 +8257,28 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/traverse@7.28.5': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/generator': 7.28.5 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.28.5 + '@babel/template': 7.27.2 + '@babel/types': 7.28.5 + debug: 4.3.5 + transitivePeerDependencies: + - supports-color + '@babel/types@7.27.0': dependencies: '@babel/helper-string-parser': 7.25.9 '@babel/helper-validator-identifier': 7.25.9 + '@babel/types@7.28.5': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + '@bcoe/v8-coverage@0.2.3': {} '@changesets/apply-release-plan@6.1.3': @@ -8616,6 +8848,12 @@ snapshots: dependencies: '@swc/helpers': 0.5.1 + '@isaacs/balanced-match@4.0.1': {} + + '@isaacs/brace-expansion@5.0.0': + dependencies: + '@isaacs/balanced-match': 4.0.1 + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -8801,15 +9039,19 @@ snapshots: '@types/yargs': 17.0.24 chalk: 4.1.2 - '@joshwooding/vite-plugin-react-docgen-typescript@0.6.1(typescript@5.6.3)(vite@7.1.3(@types/node@22.17.2)(terser@5.31.1))': + '@joshwooding/vite-plugin-react-docgen-typescript@0.6.3(typescript@5.6.3)(vite@7.1.3(@types/node@22.17.2)(terser@5.31.1))': dependencies: - glob: 10.3.10 - magic-string: 0.30.17 + glob: 11.1.0 react-docgen-typescript: 2.2.2(typescript@5.6.3) vite: 7.1.3(@types/node@22.17.2)(terser@5.31.1) optionalDependencies: typescript: 5.6.3 + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + '@jridgewell/gen-mapping@0.3.5': dependencies: '@jridgewell/set-array': 1.2.1 @@ -8818,8 +9060,8 @@ snapshots: '@jridgewell/remapping@2.3.5': dependencies: - '@jridgewell/gen-mapping': 0.3.5 - '@jridgewell/trace-mapping': 0.3.25 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 '@jridgewell/resolve-uri@3.1.1': {} @@ -8839,6 +9081,11 @@ snapshots: '@jridgewell/resolve-uri': 3.1.1 '@jridgewell/sourcemap-codec': 1.4.15 + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.1 + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping@0.3.9': dependencies: '@jridgewell/resolve-uri': 3.1.1 @@ -8940,9 +9187,6 @@ snapshots: dependencies: '@octokit/openapi-types': 23.0.1 - '@pkgjs/parseargs@0.11.0': - optional: true - '@react-aria/breadcrumbs@3.5.27(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': dependencies: '@react-aria/i18n': 3.12.11(react-dom@19.1.1(react@19.1.1))(react@19.1.1) @@ -10231,15 +10475,15 @@ snapshots: dependencies: '@statoscope/types': 5.22.0 - '@storybook/addon-docs@10.0.8(@types/react@19.1.10)(esbuild@0.25.9)(rollup@4.46.4)(storybook@10.0.8(@testing-library/dom@10.4.1)(prettier@3.2.5)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(vite@7.1.3(@types/node@22.17.2)(terser@5.31.1)))(vite@7.1.3(@types/node@22.17.2)(terser@5.31.1))(webpack@5.76.1(@swc/core@1.3.36)(esbuild@0.25.9))': + '@storybook/addon-docs@10.1.10(@types/react@19.1.10)(esbuild@0.25.9)(rollup@4.46.4)(storybook@10.1.10(@testing-library/dom@10.4.1)(prettier@3.2.5)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(vite@7.1.3(@types/node@22.17.2)(terser@5.31.1))(webpack@5.76.1(@swc/core@1.3.36)(esbuild@0.25.9))': dependencies: '@mdx-js/react': 3.0.1(@types/react@19.1.10)(react@19.1.1) - '@storybook/csf-plugin': 10.0.8(esbuild@0.25.9)(rollup@4.46.4)(storybook@10.0.8(@testing-library/dom@10.4.1)(prettier@3.2.5)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(vite@7.1.3(@types/node@22.17.2)(terser@5.31.1)))(vite@7.1.3(@types/node@22.17.2)(terser@5.31.1))(webpack@5.76.1(@swc/core@1.3.36)(esbuild@0.25.9)) - '@storybook/icons': 1.6.0(react-dom@19.1.1(react@19.1.1))(react@19.1.1) - '@storybook/react-dom-shim': 10.0.8(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(storybook@10.0.8(@testing-library/dom@10.4.1)(prettier@3.2.5)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(vite@7.1.3(@types/node@22.17.2)(terser@5.31.1))) + '@storybook/csf-plugin': 10.1.10(esbuild@0.25.9)(rollup@4.46.4)(storybook@10.1.10(@testing-library/dom@10.4.1)(prettier@3.2.5)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(vite@7.1.3(@types/node@22.17.2)(terser@5.31.1))(webpack@5.76.1(@swc/core@1.3.36)(esbuild@0.25.9)) + '@storybook/icons': 2.0.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@storybook/react-dom-shim': 10.1.10(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(storybook@10.1.10(@testing-library/dom@10.4.1)(prettier@3.2.5)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)) react: 19.1.1 react-dom: 19.1.1(react@19.1.1) - storybook: 10.0.8(@testing-library/dom@10.4.1)(prettier@3.2.5)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(vite@7.1.3(@types/node@22.17.2)(terser@5.31.1)) + storybook: 10.1.10(@testing-library/dom@10.4.1)(prettier@3.2.5)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) ts-dedent: 2.2.0 transitivePeerDependencies: - '@types/react' @@ -10248,27 +10492,29 @@ snapshots: - vite - webpack - '@storybook/addon-links@10.0.8(react@19.1.1)(storybook@10.0.8(@testing-library/dom@10.4.1)(prettier@3.2.5)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(vite@7.1.3(@types/node@22.17.2)(terser@5.31.1)))': + '@storybook/addon-links@10.1.10(react@19.1.1)(storybook@10.1.10(@testing-library/dom@10.4.1)(prettier@3.2.5)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))': dependencies: '@storybook/global': 5.0.0 - storybook: 10.0.8(@testing-library/dom@10.4.1)(prettier@3.2.5)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(vite@7.1.3(@types/node@22.17.2)(terser@5.31.1)) + storybook: 10.1.10(@testing-library/dom@10.4.1)(prettier@3.2.5)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) optionalDependencies: react: 19.1.1 - '@storybook/builder-vite@10.0.8(esbuild@0.25.9)(rollup@4.46.4)(storybook@10.0.8(@testing-library/dom@10.4.1)(prettier@3.2.5)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(vite@7.1.3(@types/node@22.17.2)(terser@5.31.1)))(vite@7.1.3(@types/node@22.17.2)(terser@5.31.1))(webpack@5.76.1(@swc/core@1.3.36)(esbuild@0.25.9))': + '@storybook/builder-vite@10.1.10(esbuild@0.25.9)(rollup@4.46.4)(storybook@10.1.10(@testing-library/dom@10.4.1)(prettier@3.2.5)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(vite@7.1.3(@types/node@22.17.2)(terser@5.31.1))(webpack@5.76.1(@swc/core@1.3.36)(esbuild@0.25.9))': dependencies: - '@storybook/csf-plugin': 10.0.8(esbuild@0.25.9)(rollup@4.46.4)(storybook@10.0.8(@testing-library/dom@10.4.1)(prettier@3.2.5)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(vite@7.1.3(@types/node@22.17.2)(terser@5.31.1)))(vite@7.1.3(@types/node@22.17.2)(terser@5.31.1))(webpack@5.76.1(@swc/core@1.3.36)(esbuild@0.25.9)) - storybook: 10.0.8(@testing-library/dom@10.4.1)(prettier@3.2.5)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(vite@7.1.3(@types/node@22.17.2)(terser@5.31.1)) + '@storybook/csf-plugin': 10.1.10(esbuild@0.25.9)(rollup@4.46.4)(storybook@10.1.10(@testing-library/dom@10.4.1)(prettier@3.2.5)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(vite@7.1.3(@types/node@22.17.2)(terser@5.31.1))(webpack@5.76.1(@swc/core@1.3.36)(esbuild@0.25.9)) + '@vitest/mocker': 3.2.4(vite@7.1.3(@types/node@22.17.2)(terser@5.31.1)) + storybook: 10.1.10(@testing-library/dom@10.4.1)(prettier@3.2.5)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) ts-dedent: 2.2.0 vite: 7.1.3(@types/node@22.17.2)(terser@5.31.1) transitivePeerDependencies: - esbuild + - msw - rollup - webpack - '@storybook/csf-plugin@10.0.8(esbuild@0.25.9)(rollup@4.46.4)(storybook@10.0.8(@testing-library/dom@10.4.1)(prettier@3.2.5)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(vite@7.1.3(@types/node@22.17.2)(terser@5.31.1)))(vite@7.1.3(@types/node@22.17.2)(terser@5.31.1))(webpack@5.76.1(@swc/core@1.3.36)(esbuild@0.25.9))': + '@storybook/csf-plugin@10.1.10(esbuild@0.25.9)(rollup@4.46.4)(storybook@10.1.10(@testing-library/dom@10.4.1)(prettier@3.2.5)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(vite@7.1.3(@types/node@22.17.2)(terser@5.31.1))(webpack@5.76.1(@swc/core@1.3.36)(esbuild@0.25.9))': dependencies: - storybook: 10.0.8(@testing-library/dom@10.4.1)(prettier@3.2.5)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(vite@7.1.3(@types/node@22.17.2)(terser@5.31.1)) + storybook: 10.1.10(@testing-library/dom@10.4.1)(prettier@3.2.5)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) unplugin: 2.3.10 optionalDependencies: esbuild: 0.25.9 @@ -10278,48 +10524,52 @@ snapshots: '@storybook/global@5.0.0': {} - '@storybook/icons@1.6.0(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': + '@storybook/icons@2.0.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': dependencies: react: 19.1.1 react-dom: 19.1.1(react@19.1.1) - '@storybook/react-dom-shim@10.0.8(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(storybook@10.0.8(@testing-library/dom@10.4.1)(prettier@3.2.5)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(vite@7.1.3(@types/node@22.17.2)(terser@5.31.1)))': + '@storybook/react-dom-shim@10.1.10(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(storybook@10.1.10(@testing-library/dom@10.4.1)(prettier@3.2.5)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))': dependencies: react: 19.1.1 react-dom: 19.1.1(react@19.1.1) - storybook: 10.0.8(@testing-library/dom@10.4.1)(prettier@3.2.5)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(vite@7.1.3(@types/node@22.17.2)(terser@5.31.1)) + storybook: 10.1.10(@testing-library/dom@10.4.1)(prettier@3.2.5)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) - '@storybook/react-vite@10.0.8(esbuild@0.25.9)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(rollup@4.46.4)(storybook@10.0.8(@testing-library/dom@10.4.1)(prettier@3.2.5)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(vite@7.1.3(@types/node@22.17.2)(terser@5.31.1)))(typescript@5.6.3)(vite@7.1.3(@types/node@22.17.2)(terser@5.31.1))(webpack@5.76.1(@swc/core@1.3.36)(esbuild@0.25.9))': + '@storybook/react-vite@10.1.10(esbuild@0.25.9)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(rollup@4.46.4)(storybook@10.1.10(@testing-library/dom@10.4.1)(prettier@3.2.5)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(typescript@5.6.3)(vite@7.1.3(@types/node@22.17.2)(terser@5.31.1))(webpack@5.76.1(@swc/core@1.3.36)(esbuild@0.25.9))': dependencies: - '@joshwooding/vite-plugin-react-docgen-typescript': 0.6.1(typescript@5.6.3)(vite@7.1.3(@types/node@22.17.2)(terser@5.31.1)) + '@joshwooding/vite-plugin-react-docgen-typescript': 0.6.3(typescript@5.6.3)(vite@7.1.3(@types/node@22.17.2)(terser@5.31.1)) '@rollup/pluginutils': 5.1.0(rollup@4.46.4) - '@storybook/builder-vite': 10.0.8(esbuild@0.25.9)(rollup@4.46.4)(storybook@10.0.8(@testing-library/dom@10.4.1)(prettier@3.2.5)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(vite@7.1.3(@types/node@22.17.2)(terser@5.31.1)))(vite@7.1.3(@types/node@22.17.2)(terser@5.31.1))(webpack@5.76.1(@swc/core@1.3.36)(esbuild@0.25.9)) - '@storybook/react': 10.0.8(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(storybook@10.0.8(@testing-library/dom@10.4.1)(prettier@3.2.5)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(vite@7.1.3(@types/node@22.17.2)(terser@5.31.1)))(typescript@5.6.3) + '@storybook/builder-vite': 10.1.10(esbuild@0.25.9)(rollup@4.46.4)(storybook@10.1.10(@testing-library/dom@10.4.1)(prettier@3.2.5)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(vite@7.1.3(@types/node@22.17.2)(terser@5.31.1))(webpack@5.76.1(@swc/core@1.3.36)(esbuild@0.25.9)) + '@storybook/react': 10.1.10(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(storybook@10.1.10(@testing-library/dom@10.4.1)(prettier@3.2.5)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(typescript@5.6.3) empathic: 2.0.0 magic-string: 0.30.17 react: 19.1.1 - react-docgen: 8.0.0 + react-docgen: 8.0.2 react-dom: 19.1.1(react@19.1.1) resolve: 1.22.8 - storybook: 10.0.8(@testing-library/dom@10.4.1)(prettier@3.2.5)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(vite@7.1.3(@types/node@22.17.2)(terser@5.31.1)) + storybook: 10.1.10(@testing-library/dom@10.4.1)(prettier@3.2.5)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) tsconfig-paths: 4.2.0 vite: 7.1.3(@types/node@22.17.2)(terser@5.31.1) transitivePeerDependencies: - esbuild + - msw - rollup - supports-color - typescript - webpack - '@storybook/react@10.0.8(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(storybook@10.0.8(@testing-library/dom@10.4.1)(prettier@3.2.5)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(vite@7.1.3(@types/node@22.17.2)(terser@5.31.1)))(typescript@5.6.3)': + '@storybook/react@10.1.10(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(storybook@10.1.10(@testing-library/dom@10.4.1)(prettier@3.2.5)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(typescript@5.6.3)': dependencies: '@storybook/global': 5.0.0 - '@storybook/react-dom-shim': 10.0.8(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(storybook@10.0.8(@testing-library/dom@10.4.1)(prettier@3.2.5)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(vite@7.1.3(@types/node@22.17.2)(terser@5.31.1))) + '@storybook/react-dom-shim': 10.1.10(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(storybook@10.1.10(@testing-library/dom@10.4.1)(prettier@3.2.5)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)) react: 19.1.1 + react-docgen: 8.0.2 react-dom: 19.1.1(react@19.1.1) - storybook: 10.0.8(@testing-library/dom@10.4.1)(prettier@3.2.5)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(vite@7.1.3(@types/node@22.17.2)(terser@5.31.1)) + storybook: 10.1.10(@testing-library/dom@10.4.1)(prettier@3.2.5)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) optionalDependencies: typescript: 5.6.3 + transitivePeerDependencies: + - supports-color '@swc/core-darwin-arm64@1.3.36': optional: true @@ -10487,6 +10737,10 @@ snapshots: dependencies: '@babel/types': 7.27.0 + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.28.5 + '@types/chai@5.2.2': dependencies: '@types/deep-eql': 4.0.2 @@ -11319,6 +11573,10 @@ snapshots: buffer-from@1.1.2: {} + bundle-name@4.1.0: + dependencies: + run-applescript: 7.1.0 + bytes-iec@3.1.1: {} bytes@3.0.0: {} @@ -11691,6 +11949,13 @@ snapshots: deepmerge@4.3.1: {} + default-browser-id@5.0.1: {} + + default-browser@5.4.0: + dependencies: + bundle-name: 4.1.0 + default-browser-id: 5.0.1 + defaults@1.0.4: dependencies: clone: 1.0.4 @@ -11703,6 +11968,8 @@ snapshots: define-lazy-prop@2.0.0: {} + define-lazy-prop@3.0.0: {} + define-properties@1.2.1: dependencies: define-data-property: 1.1.4 @@ -11991,7 +12258,7 @@ snapshots: dependencies: eslint: 9.25.1 - eslint-config-react-app@7.0.1(@babel/plugin-syntax-flow@7.24.7(@babel/core@7.25.8))(@babel/plugin-transform-react-jsx@7.24.7(@babel/core@7.25.8))(eslint@9.25.1)(jest@29.7.0(@types/node@22.17.2)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.3.36)(@types/node@22.17.2)(typescript@5.6.3)))(typescript@5.6.3): + eslint-config-react-app@7.0.1(@babel/plugin-syntax-flow@7.24.7(@babel/core@7.28.5))(@babel/plugin-transform-react-jsx@7.24.7(@babel/core@7.28.5))(eslint@9.25.1)(jest@29.7.0(@types/node@22.17.2)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.3.36)(@types/node@22.17.2)(typescript@5.6.3)))(typescript@5.6.3): dependencies: '@babel/core': 7.25.8 '@babel/eslint-parser': 7.22.15(@babel/core@7.25.8)(eslint@9.25.1) @@ -12001,7 +12268,7 @@ snapshots: babel-preset-react-app: 10.0.1 confusing-browser-globals: 1.0.11 eslint: 9.25.1 - eslint-plugin-flowtype: 8.0.3(@babel/plugin-syntax-flow@7.24.7(@babel/core@7.25.8))(@babel/plugin-transform-react-jsx@7.24.7(@babel/core@7.25.8))(eslint@9.25.1) + eslint-plugin-flowtype: 8.0.3(@babel/plugin-syntax-flow@7.24.7(@babel/core@7.28.5))(@babel/plugin-transform-react-jsx@7.24.7(@babel/core@7.28.5))(eslint@9.25.1) eslint-plugin-import: 2.31.0(@typescript-eslint/parser@5.62.0(eslint@9.25.1)(typescript@5.6.3))(eslint@9.25.1) eslint-plugin-jest: 25.7.0(@typescript-eslint/eslint-plugin@5.62.0(@typescript-eslint/parser@5.62.0(eslint@9.25.1)(typescript@5.6.3))(eslint@9.25.1)(typescript@5.6.3))(eslint@9.25.1)(jest@29.7.0(@types/node@22.17.2)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.3.36)(@types/node@22.17.2)(typescript@5.6.3)))(typescript@5.6.3) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.25.1) @@ -12046,10 +12313,10 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-plugin-flowtype@8.0.3(@babel/plugin-syntax-flow@7.24.7(@babel/core@7.25.8))(@babel/plugin-transform-react-jsx@7.24.7(@babel/core@7.25.8))(eslint@9.25.1): + eslint-plugin-flowtype@8.0.3(@babel/plugin-syntax-flow@7.24.7(@babel/core@7.28.5))(@babel/plugin-transform-react-jsx@7.24.7(@babel/core@7.28.5))(eslint@9.25.1): dependencies: - '@babel/plugin-syntax-flow': 7.24.7(@babel/core@7.25.8) - '@babel/plugin-transform-react-jsx': 7.24.7(@babel/core@7.25.8) + '@babel/plugin-syntax-flow': 7.24.7(@babel/core@7.28.5) + '@babel/plugin-transform-react-jsx': 7.24.7(@babel/core@7.28.5) eslint: 9.25.1 lodash: 4.17.21 string-natural-compare: 3.0.1 @@ -12172,11 +12439,11 @@ snapshots: string.prototype.matchall: 4.0.12 string.prototype.repeat: 1.0.0 - eslint-plugin-storybook@10.0.8(eslint@9.25.1)(storybook@10.0.8(@testing-library/dom@10.4.1)(prettier@3.2.5)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(vite@7.1.3(@types/node@22.17.2)(terser@5.31.1)))(typescript@5.6.3): + eslint-plugin-storybook@10.1.10(eslint@9.25.1)(storybook@10.1.10(@testing-library/dom@10.4.1)(prettier@3.2.5)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(typescript@5.6.3): dependencies: '@typescript-eslint/utils': 8.31.0(eslint@9.25.1)(typescript@5.6.3) eslint: 9.25.1 - storybook: 10.0.8(@testing-library/dom@10.4.1)(prettier@3.2.5)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(vite@7.1.3(@types/node@22.17.2)(terser@5.31.1)) + storybook: 10.1.10(@testing-library/dom@10.4.1)(prettier@3.2.5)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) transitivePeerDependencies: - supports-color - typescript @@ -12407,6 +12674,11 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + form-data@4.0.0: dependencies: asynckit: 0.4.0 @@ -12510,14 +12782,6 @@ snapshots: glob-to-regexp@0.4.1: {} - glob@10.3.10: - dependencies: - foreground-child: 3.1.1 - jackspeak: 2.3.6 - minimatch: 9.0.5 - minipass: 7.1.2 - path-scurry: 1.10.1 - glob@11.0.1: dependencies: foreground-child: 3.1.1 @@ -12527,6 +12791,15 @@ snapshots: package-json-from-dist: 1.0.1 path-scurry: 2.0.0 + glob@11.1.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 4.1.1 + minimatch: 10.1.1 + minipass: 7.1.2 + package-json-from-dist: 1.0.1 + path-scurry: 2.0.0 + glob@7.2.3: dependencies: fs.realpath: 1.0.0 @@ -12737,6 +13010,8 @@ snapshots: is-docker@2.2.1: {} + is-docker@3.0.0: {} + is-extglob@2.1.1: {} is-finalizationregistry@1.1.1: @@ -12761,6 +13036,10 @@ snapshots: dependencies: is-extglob: 2.1.1 + is-inside-container@1.0.0: + dependencies: + is-docker: 3.0.0 + is-map@2.0.3: {} is-number-object@1.1.1: @@ -12845,6 +13124,10 @@ snapshots: dependencies: is-docker: 2.2.1 + is-wsl@3.1.0: + dependencies: + is-inside-container: 1.0.0 + isarray@2.0.5: {} isexe@2.0.0: {} @@ -12899,13 +13182,11 @@ snapshots: has-symbols: 1.1.0 set-function-name: 2.0.2 - jackspeak@2.3.6: + jackspeak@4.1.0: dependencies: '@isaacs/cliui': 8.0.2 - optionalDependencies: - '@pkgjs/parseargs': 0.11.0 - jackspeak@4.1.0: + jackspeak@4.1.1: dependencies: '@isaacs/cliui': 8.0.2 @@ -13493,8 +13774,6 @@ snapshots: loupe@3.2.0: {} - lru-cache@10.2.0: {} - lru-cache@11.1.0: {} lru-cache@4.1.5: @@ -13893,6 +14172,10 @@ snapshots: dependencies: brace-expansion: 2.0.1 + minimatch@10.1.1: + dependencies: + '@isaacs/brace-expansion': 5.0.0 + minimatch@3.1.2: dependencies: brace-expansion: 1.1.11 @@ -14031,6 +14314,13 @@ snapshots: dependencies: mimic-fn: 2.1.0 + open@10.2.0: + dependencies: + default-browser: 5.4.0 + define-lazy-prop: 3.0.0 + is-inside-container: 1.0.0 + wsl-utils: 0.1.0 + open@8.4.2: dependencies: define-lazy-prop: 2.0.0 @@ -14118,11 +14408,6 @@ snapshots: path-parse@1.0.7: {} - path-scurry@1.10.1: - dependencies: - lru-cache: 10.2.0 - minipass: 7.1.2 - path-scurry@2.0.0: dependencies: lru-cache: 11.1.0 @@ -14313,13 +14598,13 @@ snapshots: dependencies: typescript: 5.6.3 - react-docgen@8.0.0: + react-docgen@8.0.2: dependencies: - '@babel/core': 7.25.8 - '@babel/traverse': 7.27.0 - '@babel/types': 7.27.0 + '@babel/core': 7.28.5 + '@babel/traverse': 7.28.5 + '@babel/types': 7.28.5 '@types/babel__core': 7.20.5 - '@types/babel__traverse': 7.18.5 + '@types/babel__traverse': 7.28.0 '@types/doctrine': 0.0.9 '@types/resolve': 1.20.6 doctrine: 3.0.0 @@ -14616,6 +14901,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.46.4 fsevents: 2.3.3 + run-applescript@7.1.0: {} + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 @@ -14835,29 +15122,28 @@ snapshots: transitivePeerDependencies: - webpack - storybook@10.0.8(@testing-library/dom@10.4.1)(prettier@3.2.5)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(vite@7.1.3(@types/node@22.17.2)(terser@5.31.1)): + storybook@10.1.10(@testing-library/dom@10.4.1)(prettier@3.2.5)(react-dom@19.1.1(react@19.1.1))(react@19.1.1): dependencies: '@storybook/global': 5.0.0 - '@storybook/icons': 1.6.0(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@storybook/icons': 2.0.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1) '@testing-library/jest-dom': 6.7.0 '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1) '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.1.3(@types/node@22.17.2)(terser@5.31.1)) '@vitest/spy': 3.2.4 esbuild: 0.25.9 + open: 10.2.0 recast: 0.23.6 semver: 7.7.1 + use-sync-external-store: 1.5.0(react@19.1.1) ws: 8.18.3 optionalDependencies: prettier: 3.2.5 transitivePeerDependencies: - '@testing-library/dom' - bufferutil - - msw - react - react-dom - utf-8-validate - - vite stream-transform@2.1.3: dependencies: @@ -15572,6 +15858,10 @@ snapshots: ws@8.18.3: {} + wsl-utils@0.1.0: + dependencies: + is-wsl: 3.1.0 + xml-name-validator@4.0.0: {} xmlchars@2.2.0: {} diff --git a/src/components/actions/Button/Button.docs.mdx b/src/components/actions/Button/Button.docs.mdx index c2363a0bd..d4988c378 100644 --- a/src/components/actions/Button/Button.docs.mdx +++ b/src/components/actions/Button/Button.docs.mdx @@ -96,7 +96,27 @@ The `mods` prop accepts the following modifiers you can override: ```jsx import { IconPlus } from '@tabler/icons-react'; +{/* Standard icon */} + +{/* Return true from function for empty slot */} + ``` ### Link Button diff --git a/src/components/actions/Button/Button.stories.tsx b/src/components/actions/Button/Button.stories.tsx index 4a90557cc..9711393e3 100644 --- a/src/components/actions/Button/Button.stories.tsx +++ b/src/components/actions/Button/Button.stories.tsx @@ -1,5 +1,11 @@ import { StoryFn } from '@storybook/react-vite'; -import { IconCaretDown, IconCoin } from '@tabler/icons-react'; +import { + IconCaretDown, + IconCoin, + IconHeart, + IconHeartFilled, +} from '@tabler/icons-react'; +import { useState } from 'react'; import { baseProps } from '../../../stories/lists/baseProps'; import { Space } from '../../layout/Space'; @@ -40,11 +46,13 @@ export default { /* Content */ icon: { control: { type: null }, - description: 'Icon element rendered before the content', + description: + 'Icon rendered before the content. Can be: ReactNode, `true` (empty slot), or function `({ loading, selected, ...mods }) => ReactNode | true`', }, rightIcon: { control: { type: null }, - description: 'Icon element rendered after the content', + description: + 'Icon rendered after the content. Can be: ReactNode, `true` (empty slot), or function `({ loading, selected, ...mods }) => ReactNode | true`', }, children: { control: { type: 'text' }, @@ -509,3 +517,51 @@ Loading.args = { isLoading: true, children: 'Button', }; + +export const DynamicIcon = () => { + const [isSelected, setIsSelected] = useState(false); + + return ( + + ); +}; + +export const ToggleLoading = () => { + const [isLoading, setIsLoading] = useState(false); + + return ( + + + + + ); +}; + +export const CustomSize: StoryFn = () => ( + + + + +); + +CustomSize.parameters = { + docs: { + description: { + story: + 'Demonstrates custom size values using the `size` prop. Supports both string values (like `8x`) and number values (converted to pixels, like `64`). Custom sizes override the default size token via the `tokens` prop.', + }, + }, +}; diff --git a/src/components/actions/Button/Button.tsx b/src/components/actions/Button/Button.tsx index 975461e23..506754c71 100644 --- a/src/components/actions/Button/Button.tsx +++ b/src/components/actions/Button/Button.tsx @@ -1,14 +1,17 @@ import { FocusableRef } from '@react-types/shared'; import { + Children, forwardRef, HTMLAttributes, - ReactElement, + isValidElement, ReactNode, RefObject, useMemo, + useState, } from 'react'; import { OverlayProps } from 'react-aria'; +import { useIsFirstRender } from '../../../_internal/hooks/use-is-first-render'; import { useWarn } from '../../../_internal/hooks/use-warn'; import { DANGER_CLEAR_STYLES, @@ -40,19 +43,43 @@ import { LoadingIcon } from '../../../icons'; import { CONTAINER_STYLES, extractStyles, + Mods, Styles, tasty, TEXT_STYLES, } from '../../../tasty'; -import { mergeProps } from '../../../utils/react'; +import { DynamicIcon, mergeProps, resolveIcon } from '../../../utils/react'; import { useAutoTooltip } from '../../content/use-auto-tooltip'; +import { DisplayTransition } from '../../helpers/DisplayTransition'; +import { IconSwitch } from '../../helpers/IconSwitch/IconSwitch'; import { CubeTooltipProviderProps } from '../../overlays/Tooltip/TooltipProvider'; import { CubeActionProps } from '../Action/Action'; import { useAction } from '../use-action'; +const BUTTON_SIZE_VALUES = [ + 'xsmall', + 'small', + 'medium', + 'large', + 'xlarge', + 'inline', +] as const; + +/** Known modifiers for Button component */ +export type ButtonMods = Mods<{ + loading?: boolean; + selected?: boolean; + 'has-icons'?: boolean; + 'has-icon'?: boolean; + 'has-right-icon'?: boolean; + 'single-icon'?: boolean; + 'text-only'?: boolean; + 'raw-children'?: boolean; +}>; + export interface CubeButtonProps extends CubeActionProps { - icon?: ReactElement; - rightIcon?: ReactElement; + icon?: DynamicIcon; + rightIcon?: DynamicIcon; isLoading?: boolean; isSelected?: boolean; type?: @@ -64,7 +91,15 @@ export interface CubeButtonProps extends CubeActionProps { | 'outline' | 'neutral' | (string & {}); - size?: 'xsmall' | 'small' | 'medium' | 'large' | 'xlarge' | (string & {}); + size?: + | 'xsmall' + | 'small' + | 'medium' + | 'large' + | 'xlarge' + | 'inline' + | number + | (string & {}); /** * Tooltip content and configuration: * - string: simple tooltip text @@ -113,21 +148,22 @@ const STYLE_PROPS = [...CONTAINER_STYLES, ...TEXT_STYLES]; const DEFAULT_ICON_STYLES: Styles = { $: '>', + position: 'relative', display: 'grid', placeItems: 'center', - placeContent: 'stretch', - aspectRatio: '1 / 1', - width: '($size - 2bw)', + placeContent: 'center', + placeSelf: 'stretch', + // overflow: 'hidden', + width: 'fixed ($size - 2bw)', + height: 'fixed ($size - 2bw)', + pointerEvents: 'none', + transition: 'theme, width, height, translate', }; export const DEFAULT_BUTTON_STYLES = { display: 'inline-grid', flow: 'column dense', gap: 0, - gridTemplate: { - '': '"icon label rightIcon" auto / max-content 1sf max-content', - 'raw-children': 'initial', - }, placeItems: { '': 'stretch', 'raw-children': 'center stretch', @@ -148,15 +184,14 @@ export const DEFAULT_BUTTON_STYLES = { 'size=xlarge': 't2m', }, textDecoration: 'none', - transition: 'theme', reset: 'button', outline: 0, outlineOffset: 1, padding: { '': 0, - 'raw-children': - '$block-padding $label-padding-right $block-padding $label-padding-left', - 'raw-children & type=link': 0, + 'raw-children & !has-icons': + '$block-padding $inline-padding $block-padding $inline-padding', + 'type=link': '0', }, width: { '': 'min $size', @@ -173,6 +208,8 @@ export const DEFAULT_BUTTON_STYLES = { '': true, 'type=link & !focused': 0, }, + transition: 'theme, grid-template, padding', + verticalAlign: 'bottom', $size: { '': '$size-md', @@ -181,6 +218,7 @@ export const DEFAULT_BUTTON_STYLES = { 'size=medium': '$size-md', 'size=large': '$size-lg', 'size=xlarge': '$size-xl', + 'size=inline': '(1lh + 2bw)', }, '$inline-padding': { '': 'max($min-inline-padding, (($size - 1lh - 2bw) / 2 + $inline-compensation))', @@ -191,31 +229,52 @@ export const DEFAULT_BUTTON_STYLES = { }, '$inline-compensation': '.5x', '$min-inline-padding': '(1x - 1bw)', - '$label-padding-left': { + '$left-padding': { '': '$inline-padding', - 'has-icon': 0, + 'is-icon-shown': '0px', }, - '$label-padding-right': { + '$right-padding': { '': '$inline-padding', - 'has-right-icon': 0, + 'is-right-icon-shown': '0px', }, // Icon sub-element (recommended format) Icon: { ...DEFAULT_ICON_STYLES, - gridArea: 'icon', + width: { + '': 'fixed 0px', + 'is-icon-shown': 'fixed ($size - 2bw)', + }, + opacity: { + '': 0, + 'is-icon-shown': 1, + }, + translate: { + '': '($size * 1 / 4) 0', + 'is-icon-shown': '0 0', + }, }, // RightIcon sub-element (recommended format) RightIcon: { ...DEFAULT_ICON_STYLES, - gridArea: 'rightIcon', + width: { + '': 'fixed 0px', + 'is-right-icon-shown': 'fixed ($size - 2bw)', + }, + opacity: { + '': 0, + 'is-right-icon-shown': 1, + }, + translate: { + '': '($size * -1 / 4) 0', + 'is-right-icon-shown': '0 0', + }, }, // Label sub-element (recommended format) Label: { $: '>', - gridArea: 'label', display: 'block', placeSelf: 'center stretch', boxSizing: 'border-box', @@ -224,16 +283,12 @@ export const DEFAULT_BUTTON_STYLES = { textOverflow: 'ellipsis', maxWidth: '100%', textAlign: 'center', + transition: 'theme, padding', padding: { - '': '$block-padding $label-padding-right $block-padding $label-padding-left', - 'type=link': 0, + '': '$block-padding $right-padding $block-padding $left-padding', + 'type=link': '0', }, }, - - // ButtonIcon sub-element (backward compatibility) - ButtonIcon: { - width: 'min 1fs', - }, } as const; const ButtonElement = tasty({ @@ -280,12 +335,12 @@ export const Button = forwardRef(function Button( ) { let { type, - size, + size: sizeProp, label, children, theme = 'default', - icon, - rightIcon, + icon: iconProp, + rightIcon: rightIconProp, mods, download, tooltip = true, @@ -293,26 +348,71 @@ export const Button = forwardRef(function Button( ...props } = allProps; - const isDisabled = props.isDisabled || props.isLoading; + const size = sizeProp ?? (type === 'link' ? 'inline' : 'medium'); + + const isDisabled = props.isDisabled ?? props.isLoading; const isLoading = props.isLoading; const isSelected = props.isSelected; - children = children || icon || rightIcon ? children : label; + // Base mods for icon resolution (without icon-dependent mods) + const baseMods = useMemo( + () => ({ + loading: isLoading, + selected: isSelected, + ...mods, + }), + [isLoading, isSelected, mods], + ); + + // Resolve dynamic icon props + const resolvedIcon = useMemo( + () => resolveIcon(iconProp, baseMods), + [iconProp, baseMods], + ); + const resolvedRightIcon = useMemo( + () => resolveIcon(rightIconProp, baseMods), + [rightIconProp, baseMods], + ); + + const hasLeftSlot = resolvedIcon.hasSlot; + const hasRightSlot = resolvedRightIcon.hasSlot; + + const icon: ReactNode = resolvedIcon.content; + const rightIcon: ReactNode = resolvedRightIcon.content; + + // Generate stable keys for icon transitions based on icon type + const iconKey = isLoading + ? 'loading' + : isValidElement(icon) + ? (icon.type as any)?.displayName || (icon.type as any)?.name || 'icon' + : icon + ? 'icon' + : 'empty'; + + const rightIconKey = isValidElement(rightIcon) + ? (rightIcon.type as any)?.displayName || + (rightIcon.type as any)?.name || + 'icon' + : rightIcon + ? 'icon' + : 'empty'; + + children = children || hasLeftSlot || hasRightSlot ? children : label; const specifiedLabel = label ?? props['aria-label'] ?? props['aria-labelledby']; // Warn about accessibility issues when button has no accessible label - useWarn(!children && icon && !specifiedLabel, { - key: ['button-icon-no-label', !!icon], + useWarn(!children && hasLeftSlot && !specifiedLabel, { + key: ['button-icon-no-label', hasLeftSlot], args: [ 'accessibility issue:', 'If you provide `icon` property for a Button and do not provide any children then you should specify the `aria-label` property to make sure the Button element stays accessible.', ], }); - useWarn(!children && !icon && !specifiedLabel, { - key: ['button-no-content-no-label', !!icon], + useWarn(!children && !hasLeftSlot && !specifiedLabel, { + key: ['button-no-content-no-label', hasLeftSlot], args: [ 'accessibility issue:', 'If you provide no children for a Button then you should specify the `aria-label` property to make sure the Button element stays accessible.', @@ -323,42 +423,48 @@ export const Button = forwardRef(function Button( label = 'Unnamed'; // fix to avoid warning in production } - const hasLeftIcon = !!icon || isLoading; + const hasLeftIcon = !!(hasLeftSlot || isLoading); const hasChildren = children != null; const singleIcon = !!( - ((hasLeftIcon && !rightIcon) || (rightIcon && !hasLeftIcon)) && + ((hasLeftIcon && !hasRightSlot) || (hasRightSlot && !hasLeftIcon)) && !hasChildren ); - const hasIcons = hasLeftIcon || !!rightIcon; + const hasIcons = hasLeftIcon || hasRightSlot; const rawChildren = !!( hasChildren && typeof children !== 'string' && - !hasIcons + !Children.toArray(children).some((child) => typeof child === 'string') ); - const modifiers = useMemo( + const [isIconShown, setIsIconShown] = useState(hasLeftIcon); + const [isRightIconShown, setIsRightIconShown] = useState(hasRightSlot); + const isFirstRender = useIsFirstRender(); + + const modifiers = useMemo( () => ({ - loading: isLoading, - selected: isSelected, + ...baseMods, 'has-icons': hasIcons, 'has-icon': hasLeftIcon, - 'has-right-icon': !!rightIcon, + 'is-icon-shown': isIconShown, + 'has-right-icon': hasRightSlot, + 'is-right-icon-shown': isRightIconShown, 'single-icon': singleIcon, 'text-only': !!(hasChildren && typeof children === 'string' && !hasIcons), 'raw-children': rawChildren, - ...mods, + 'has-content': children != null, }), [ - mods, + baseMods, children, - icon, - rightIcon, - isLoading, - isSelected, + hasLeftIcon, + hasRightSlot, singleIcon, hasIcons, + hasChildren, rawChildren, + isIconShown, + isRightIconShown, ], ); @@ -402,6 +508,14 @@ export const Button = forwardRef(function Button( } }; + // Determine if size is custom (number or unrecognized string) + const isCustomSize = + typeof size === 'number' || + (size != null && + !(BUTTON_SIZE_VALUES as readonly string[]).includes(size)); + const sizeTokenValue = + typeof size === 'number' ? `${size}px` : isCustomSize ? size : undefined; + return ( - {(icon || isLoading) && ( -
{isLoading ? : icon}
- )} + + {({ ref }) => ( + + )} + {hasChildren && (rawChildren ? ( children @@ -425,7 +550,19 @@ export const Button = forwardRef(function Button( {children} ))} - {rightIcon &&
{rightIcon}
} + + {({ ref }) => ( + + )} +
); }; diff --git a/src/components/actions/ItemActionContext.tsx b/src/components/actions/ItemActionContext.tsx index 9ebafded3..b53085394 100644 --- a/src/components/actions/ItemActionContext.tsx +++ b/src/components/actions/ItemActionContext.tsx @@ -29,7 +29,10 @@ export function ItemActionProvider({ ( +
+
+

Basic Highlight

+
+ } highlight="file"> + File management options + + } + highlight="edit" + > + Edit document settings + +
+
+ +
+

Case Sensitivity

+
+ } + highlight="FILE" + > + Case-insensitive: FILE and file match + + } + highlight="FILE" + > + Case-sensitive: FILE matches, file does not + +
+
+ +
+

Multiple Matches

+
+ } + highlight="the" + > + The quick brown fox jumps over the lazy dog + +
+
+ +
+

With Custom Highlight Styles

+
+ } + highlight="custom" + highlightStyles={{ fill: '#success', color: '#success-text' }} + > + Item with custom highlight style + +
+
+ +
+

Combined with Other Features

+
+ } + rightIcon={} + description="Product details" + descriptionPlacement="inline" + highlight="product" + > + Product name with highlight + + } + highlight="actions" + actions={ + <> + } aria-label="Edit" /> + } aria-label="Delete" /> + + } + > + Item with actions and highlight + +
+
+
+ ), + parameters: { + docs: { + description: { + story: + 'Demonstrates the `highlight`, `highlightCaseSensitive`, and `highlightStyles` props for highlighting text within the ItemButton label. Only works when children is a plain string. By default, matching is case-insensitive.', + }, + }, + }, +}; + export const WithActionsHoverBehavior: Story = { render: (args) => (
diff --git a/src/components/actions/ItemButton/ItemButton.tsx b/src/components/actions/ItemButton/ItemButton.tsx index a3bed01ae..ccd3e17b8 100644 --- a/src/components/actions/ItemButton/ItemButton.tsx +++ b/src/components/actions/ItemButton/ItemButton.tsx @@ -7,7 +7,7 @@ import { useRef, useState, } from 'react'; -import { useHover } from 'react-aria'; +import { useFocusWithin, useHover } from 'react-aria'; import { Styles, tasty } from '../../../tasty'; import { mergeProps } from '../../../utils/react'; @@ -30,7 +30,6 @@ const StyledItem = tasty(Item, { as: 'button', type: 'neutral', theme: 'default', - isButton: true, styles: { reset: 'button', placeContent: 'center stretch', @@ -43,11 +42,6 @@ const ActionsWrapper = tasty({ position: 'relative', placeContent: 'stretch', placeItems: 'stretch', - preset: { - '': 't3m', - 'size=xsmall': 't4', - 'size=xlarge': 't2m', - }, $size: { '': '$size-md', @@ -97,7 +91,7 @@ const ItemButton = forwardRef(function ItemButton( htmlType, as, type = 'neutral', - theme, + theme = 'default', onPress, // Extract react-aria press callbacks to prevent them from leaking to DOM via rest. // These are handled by useButton inside useAction. @@ -129,7 +123,37 @@ const ItemButton = forwardRef(function ItemButton( } }, [actions, areActionsVisible]); + const [isFocusWithin, setIsFocusWithin] = useState(false); + const [hasPressed, setHasPressed] = useState(false); const { hoverProps, isHovered } = useHover({}); + const { focusWithinProps } = useFocusWithin({ + onFocusWithinChange: setIsFocusWithin, + }); + + // Watch for data-pressed attribute on any descendant element + useLayoutEffect(() => { + const actionsEl = actionsRef.current; + + if (!actionsEl || !showActionsOnHover) return; + + const checkPressed = () => { + setHasPressed(actionsEl.querySelector('[data-pressed]') !== null); + }; + + const observer = new MutationObserver(checkPressed); + + observer.observe(actionsEl, { + attributes: true, + attributeFilter: ['data-pressed'], + subtree: true, + }); + + checkPressed(); + + return () => observer.disconnect(); + }, [areActionsVisible, showActionsOnHover]); + + const shouldShowActions = isHovered || isFocusWithin || hasPressed; const { actionProps } = useAction( { ...(allProps as any), htmlType, to, as, mods }, @@ -173,7 +197,7 @@ const ItemButton = forwardRef(function ItemButton( {showActionsOnHover ? ( { setAreActionsVisible(phase !== 'unmounted'); }} @@ -184,6 +208,7 @@ const ItemButton = forwardRef(function ItemButton( {({ ref: transitionRef }) => { return (
{ actionsRef.current = node; transitionRef(node); diff --git a/src/components/actions/Link/Link.tsx b/src/components/actions/Link/Link.tsx index ace1692c2..15de6680f 100644 --- a/src/components/actions/Link/Link.tsx +++ b/src/components/actions/Link/Link.tsx @@ -7,5 +7,5 @@ export const Link = forwardRef(function Link( props: CubeButtonProps, ref: FocusableRef, ) { - return + + {/* Content rendering bound to isExpanded - will be null when false */} + {isExpandedCustom ? ( + + This disclosure uses a custom trigger via function syntax + (render prop). The content is still conditionally rendered + based on `isExpanded`, and it's preserved during the exit + transition just like the standard trigger case. + + ) : null} + + + )} + + + + ); + }, + args: { + transitionDuration: 500, + }, +}; diff --git a/src/components/content/Disclosure/Disclosure.test.tsx b/src/components/content/Disclosure/Disclosure.test.tsx index 4daf58c59..e86d623fd 100644 --- a/src/components/content/Disclosure/Disclosure.test.tsx +++ b/src/components/content/Disclosure/Disclosure.test.tsx @@ -487,3 +487,67 @@ describe('Nested Disclosures', () => { expect(innerTrigger).toHaveAttribute('aria-expanded', 'true'); }); }); + +describe('Content Preservation', () => { + beforeEach(() => { + jest.useFakeTimers({ legacyFakeTimers: false }); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('should preserve content during exit phase', () => { + // This test verifies that content is stored during the exiting phase + // Similar to DisplayTransition's preserveContent behavior + + interface TestWrapperProps { + isExpanded: boolean; + content: string; + } + + function TestWrapper({ isExpanded, content }: TestWrapperProps) { + return ( + + Toggle + + {/* Simulate parent conditionally rendering content based on its own state */} + {isExpanded ? content : null} + + + ); + } + + const { container, rerender } = renderWithRoot( + , + ); + + // Initial: expanded with content + expect(container.textContent).toContain('original content'); + + // Trigger collapse - parent passes isExpanded=false and content becomes null + rerender(); + + // Immediately after rerender, content should still be preserved + // (stored children from when isExpanded was true) + expect(container.textContent).toContain('original content'); + + // Advance timers to move into exit phase + act(() => { + jest.advanceTimersByTime(50); + }); + + // During exit phase, content should still be preserved + expect(container.textContent).toContain('original content'); + + // Advance through the rest of the exit flow + act(() => { + jest.advanceTimersByTime(150); + }); + + // After completing the exit transition, content should have been preserved + // throughout the exit animation + // The content may no longer be visible after transition completes, but it + // should have been preserved during the exit phase + }); +}); diff --git a/src/components/content/Disclosure/Disclosure.tsx b/src/components/content/Disclosure/Disclosure.tsx index ac4a7a09c..3fdc6627c 100644 --- a/src/components/content/Disclosure/Disclosure.tsx +++ b/src/components/content/Disclosure/Disclosure.tsx @@ -176,12 +176,13 @@ const DisclosureRoot = tasty({ }); const ContentWrapperElement = tasty({ + qa: 'DisclosureContentWrapper', styles: { display: 'block', overflow: 'hidden', interpolateSize: 'allow-keywords', height: { - '': '0', + '': 0, shown: 'max-content', }, transition: 'height $disclosure-transition', @@ -191,8 +192,9 @@ const ContentWrapperElement = tasty({ const ContentElement = tasty({ qa: 'DisclosureContent', styles: { + display: 'block', + flow: 'column', contentVisibility: 'auto', - padding: '1x', }, }); @@ -216,6 +218,8 @@ const TriggerIcon = tasty(RightIcon, { }); const StyledTrigger = tasty(ItemButton, { + qa: 'DisclosureTrigger', + type: 'header', styles: { radius: { '': '1r', @@ -347,6 +351,7 @@ const DisclosureTrigger = forwardRef< expanded: isExpanded, disabled: isDisabled, shape, + selected: false, ...mods, }), [isExpanded, isDisabled, shape, mods], diff --git a/src/components/content/Item/Item.docs.mdx b/src/components/content/Item/Item.docs.mdx index ce99d44ad..eeb99ce13 100644 --- a/src/components/content/Item/Item.docs.mdx +++ b/src/components/content/Item/Item.docs.mdx @@ -68,31 +68,32 @@ The `mods` property accepts the following modifiers: | selected | `boolean` | Applied when isSelected is true | | disabled | `boolean` | Applied when isDisabled is true or when loading | | loading | `boolean` | Applied when isLoading is true | -| card | `boolean` | Applied when isCard is true | -| button | `boolean` | Applied when isButton is true | | size | `string` | Applied based on size prop value (xsmall, small, medium, large, xlarge, inline) | -| type | `string` | Applied based on type prop value (item, primary, secondary, outline, neutral, clear, link) | -| theme | `string` | Applied based on theme prop value (default, danger, success, special) | -| shape | `string` | Applied based on shape prop value (card, button, sharp) | +| type | `string` | Applied based on type prop value (item, header, primary, secondary, outline, neutral, clear, link, card) | +| theme | `string` | Applied based on theme prop value (default, danger, success, special, note) | +| shape | `string` | Applied based on shape prop value (card, button, sharp, pill) | ## Variants ### Types - `item` - Default item appearance (no specific styling) +- `header` - Header appearance for section headers (only supports `default` theme, defaults to `descriptionPlacement="block"`) - `primary` - Primary styled item with prominent appearance - `secondary` - Secondary styled item with moderate emphasis - `outline` - Item with border outline styling - `neutral` - Neutral colored item styling - `clear` - Transparent item with minimal styling -- `link` - Link-styled item appearance +- `link` - Link-styled item appearance (does not support icons or loading state) +- `card` - Card appearance for notifications (supports `default`, `success`, `danger`, `note` themes; defaults to `shape="card"`, `descriptionPlacement="block"`, and has `.5x` padding) ### Themes - `default` - Standard appearance with default colors - `danger` - Red theme for destructive or warning actions - `success` - Green theme for positive actions -- `special` - Special theme with unique styling +- `special` - Special theme with unique styling (not available for `card` or `header` types) +- `note` - Note theme for informational cards (only available for `card` type) ### Sizes @@ -105,9 +106,10 @@ The `mods` property accepts the following modifiers: ### Shapes -- `card` - Card shape with larger border radius (`1cr`) -- `button` - Button shape with default border radius (default) +- `card` - Card shape with larger border radius (`1cr`) — default for `type="card"` +- `button` - Button shape with default border radius (default for other types) - `sharp` - Sharp corners with no border radius (`0`) +- `pill` - Pill shape with fully rounded ends (`round`) ## Examples @@ -120,14 +122,38 @@ The `mods` property accepts the following modifiers: ### With Icons ```jsx +{/* Standard icons */} } rightIcon={}> Item with icons + +{/* Empty slot (reserves space but shows nothing) */} + + Item with empty slots + + +{/* Dynamic icon based on mods */} + loading ? : } + rightIcon={({ selected }) => selected ? : } +> + Dynamic icons + + +{/* Return true from function for empty slot */} + selected ? : true} +> + Conditional icon + ``` ### With Description +The `descriptionPlacement` prop controls where the description appears. It defaults to `"inline"` for most types, but `"block"` for `type="card"` and `type="header"`: + ```jsx +{/* Inline description (default for most types) */} } description="Secondary description text" @@ -135,6 +161,15 @@ The `mods` property accepts the following modifiers: > Main item content + +{/* Block description */} +} + description="Description appears below the label" + descriptionPlacement="block" +> + Main item content + ``` ### With Hotkeys @@ -161,6 +196,42 @@ The `mods` property accepts the following modifiers: +### Header Type + +Use `type="header"` for section headers. Header items have larger text preset and default to block description placement: + +```jsx +}> + Section Header + + +} description="Section description"> + Section Header with Description + +``` + +### Card Type + +Use `type="card"` for notification-style items. Card items automatically use card shape, block description placement, and have `.5x` padding. They support `default`, `success`, `danger`, and `note` themes: + +```jsx +} description="This is an informational message"> + Info Card + + +} description="Operation completed successfully"> + Success Card + + +} description="Something went wrong"> + Error Card + + +} description="Additional context or tips"> + Note Card + +``` + ### With Loading State The `isLoading` prop displays a loading indicator and disables the item. The `loadingSlot` prop controls which slot the loading icon replaces: diff --git a/src/components/content/Item/Item.stories.tsx b/src/components/content/Item/Item.stories.tsx index 5ecb6266b..55908810c 100644 --- a/src/components/content/Item/Item.stories.tsx +++ b/src/components/content/Item/Item.stories.tsx @@ -5,12 +5,13 @@ import { IconTrash, IconUser, } from '@tabler/icons-react'; -import { useState } from 'react'; +import { Fragment, useState } from 'react'; import { expect, userEvent, waitFor, within } from 'storybook/test'; import { DirectionIcon } from '../../../icons'; import { baseProps } from '../../../stories/lists/baseProps'; import { Button, ItemAction } from '../../actions'; +import { Block } from '../../Block'; import { Flow } from '../../layout/Flow'; import { Space } from '../../layout/Space'; import { Title } from '../Title'; @@ -36,11 +37,13 @@ export default { }, icon: { control: { type: null }, - description: 'Icon element rendered before the content', + description: + 'Icon rendered before the content. Can be: ReactNode, `"checkbox"`, `true` (empty slot), or function `({ selected, loading, ...mods }) => ReactNode | true`', }, rightIcon: { control: { type: null }, - description: 'Icon element rendered after the content', + description: + 'Icon rendered after the content. Can be: ReactNode, `true` (empty slot), or function `({ selected, loading, ...mods }) => ReactNode | true`', }, prefix: { control: { type: null }, @@ -74,7 +77,7 @@ export default { }, }, shape: { - options: ['card', 'button', 'sharp'], + options: ['card', 'button', 'sharp', 'pill'], control: { type: 'radio' }, description: 'Shape of the item border radius', table: { @@ -92,16 +95,9 @@ export default { }, }; -const DEFAULT_STYLES = { - // border: true, - // radius: true, -}; - const DefaultTemplate: StoryFn = (props) => ; -const Template: StoryFn = (props) => ( - -); +const Template: StoryFn = (props) => ; export const Default = DefaultTemplate.bind({}); Default.args = { @@ -150,34 +146,39 @@ FullConfiguration.args = { export const DifferentSizes: StoryFn = (args) => ( - XSmall Item - + }> + Inline size + + }> XSmall size - - Small Item - + }> Small size - - Medium Item - + }> Medium size - - Large Item - + }> Large size - - XLarge Item - + }> XLarge size - Inline Item - - Inline size + }> + XSmall header + + }> + Small header + + }> + Medium header + + }> + Large header + + }> + XLarge header ); @@ -190,7 +191,7 @@ DifferentSizes.parameters = { docs: { description: { story: - 'Item supports six sizes: `xsmall`, `small`, `medium` (default), `large`, `xlarge`, and `inline` to accommodate different interface requirements.', + 'Item supports five sizes: `xsmall`, `small`, `medium` (default), `large`, and `xlarge` to accommodate different interface requirements.', }, }, }; @@ -201,7 +202,6 @@ export const SizesWithIcons: StoryFn = (args) => ( } rightIcon={} > @@ -212,7 +212,6 @@ export const SizesWithIcons: StoryFn = (args) => ( } rightIcon={} > @@ -223,7 +222,6 @@ export const SizesWithIcons: StoryFn = (args) => ( } rightIcon={} > @@ -234,7 +232,6 @@ export const SizesWithIcons: StoryFn = (args) => ( } rightIcon={} > @@ -245,23 +242,11 @@ export const SizesWithIcons: StoryFn = (args) => ( } rightIcon={} > XLarge with icons - - Inline with Icons - } - rightIcon={} - > - Inline with icons - ); @@ -283,7 +268,7 @@ export const TextOverflow: StoryFn = (args) => ( Text Overflow with Limited Width } rightIcon={} > @@ -294,7 +279,7 @@ export const TextOverflow: StoryFn = (args) => ( Text Overflow with Prefix and Suffix } prefix="$" suffix=".00" @@ -307,7 +292,7 @@ export const TextOverflow: StoryFn = (args) => ( } > Long text in small size component @@ -315,7 +300,7 @@ export const TextOverflow: StoryFn = (args) => ( } > Long text in medium size component @@ -323,7 +308,7 @@ export const TextOverflow: StoryFn = (args) => ( } > Long text in large size component @@ -346,7 +331,7 @@ export const ExtraWidth: StoryFn = (args) => ( Short Text with Extra Width } rightIcon={} > @@ -356,7 +341,7 @@ export const ExtraWidth: StoryFn = (args) => ( Medium Text with Extra Width } prefix="$" suffix=".00" @@ -369,7 +354,7 @@ export const ExtraWidth: StoryFn = (args) => ( } > Small size @@ -377,7 +362,7 @@ export const ExtraWidth: StoryFn = (args) => ( } > Medium size @@ -385,7 +370,7 @@ export const ExtraWidth: StoryFn = (args) => ( } > Large size @@ -395,7 +380,7 @@ export const ExtraWidth: StoryFn = (args) => ( Icon Only with Extra Width } rightIcon={} > @@ -417,67 +402,52 @@ export const WithCheckbox: StoryFn = (args) => ( Selected Items (Checkbox Visible) - + Selected item with checkbox - + Small selected item - + Large selected item Non-Selected Items (Checkbox Hidden) - + Non-selected item with hidden checkbox - + Small non-selected item - + Large non-selected item Mixed Selection States - + Item 1 - + Item 2 - + Item 3 Comparison: Checkbox vs Regular Icon - + With checkbox (selected) - + With checkbox (not selected) - }> + }> With regular icon @@ -503,7 +473,6 @@ export const WithHotkeys: StoryFn = (args) => ( alert('Save action triggered!')} @@ -512,7 +481,6 @@ export const WithHotkeys: StoryFn = (args) => ( = (args) => ( = (args) => ( = (args) => ( = (args) => ( Disabled Item with Hotkeys = (args) => ( } @@ -600,7 +563,6 @@ export const WithTooltip: StoryFn = (args) => ( = (args) => ( = (args) => ( = (args) => ( Tooltip with Hotkeys = (args) => ( = (args) => ( = (args) => ( = (args) => ( = (args) => ( = (args) => ( = (args) => ( Complete Configuration = (args) => ( } @@ -822,7 +773,6 @@ export const WithLoading: StoryFn = (args) => ( } @@ -831,7 +781,6 @@ export const WithLoading: StoryFn = (args) => ( = (args) => ( } @@ -855,7 +803,6 @@ export const WithLoading: StoryFn = (args) => ( } @@ -865,7 +812,6 @@ export const WithLoading: StoryFn = (args) => ( } @@ -875,7 +821,6 @@ export const WithLoading: StoryFn = (args) => ( } @@ -887,62 +832,26 @@ export const WithLoading: StoryFn = (args) => ( Different Sizes with Auto Loading - } - > + }> Small size - } - > + }> Medium size - } - > + }> Large size Loading with Different Visual Types - } - > + }> Item type - } - > + }> Primary type - } - > + }> Secondary type @@ -968,7 +877,6 @@ export const WithDescription: StoryFn = (args) => ( } description="Inline description appears next to the label" @@ -978,7 +886,6 @@ export const WithDescription: StoryFn = (args) => ( } description="Settings" @@ -988,7 +895,6 @@ export const WithDescription: StoryFn = (args) => ( } rightIcon={} @@ -1003,7 +909,6 @@ export const WithDescription: StoryFn = (args) => ( } description="Block description appears below the entire item" @@ -1013,7 +918,6 @@ export const WithDescription: StoryFn = (args) => ( } description="Configure your application preferences" @@ -1023,7 +927,6 @@ export const WithDescription: StoryFn = (args) => ( } rightIcon={} @@ -1038,7 +941,6 @@ export const WithDescription: StoryFn = (args) => ( } description="Inline: Description next to label" @@ -1048,7 +950,6 @@ export const WithDescription: StoryFn = (args) => ( } description="Block: Description below item" @@ -1079,7 +980,6 @@ export const DescriptionWithSizes: StoryFn = (args) => ( } @@ -1090,7 +990,6 @@ export const DescriptionWithSizes: StoryFn = (args) => ( } @@ -1101,7 +1000,6 @@ export const DescriptionWithSizes: StoryFn = (args) => ( } @@ -1112,7 +1010,6 @@ export const DescriptionWithSizes: StoryFn = (args) => ( } @@ -1123,7 +1020,6 @@ export const DescriptionWithSizes: StoryFn = (args) => ( } @@ -1138,7 +1034,6 @@ export const DescriptionWithSizes: StoryFn = (args) => ( } @@ -1149,7 +1044,6 @@ export const DescriptionWithSizes: StoryFn = (args) => ( } @@ -1160,7 +1054,6 @@ export const DescriptionWithSizes: StoryFn = (args) => ( } @@ -1171,7 +1064,6 @@ export const DescriptionWithSizes: StoryFn = (args) => ( } @@ -1182,7 +1074,6 @@ export const DescriptionWithSizes: StoryFn = (args) => ( } @@ -1214,7 +1105,6 @@ export const DescriptionWithTypes: StoryFn = (args) => ( } description="Item type" @@ -1224,7 +1114,6 @@ export const DescriptionWithTypes: StoryFn = (args) => ( } description="Primary type" @@ -1234,7 +1123,6 @@ export const DescriptionWithTypes: StoryFn = (args) => ( } description="Secondary type" @@ -1244,7 +1132,6 @@ export const DescriptionWithTypes: StoryFn = (args) => ( } description="Outline type" @@ -1254,7 +1141,6 @@ export const DescriptionWithTypes: StoryFn = (args) => ( } description="Neutral type" @@ -1264,7 +1150,6 @@ export const DescriptionWithTypes: StoryFn = (args) => ( } description="Clear type" @@ -1274,7 +1159,6 @@ export const DescriptionWithTypes: StoryFn = (args) => ( } description="Link type" @@ -1288,7 +1172,6 @@ export const DescriptionWithTypes: StoryFn = (args) => ( } description="Item type with block description" @@ -1298,7 +1181,6 @@ export const DescriptionWithTypes: StoryFn = (args) => ( } description="Primary type with block description" @@ -1308,7 +1190,6 @@ export const DescriptionWithTypes: StoryFn = (args) => ( } description="Secondary type with block description" @@ -1318,7 +1199,6 @@ export const DescriptionWithTypes: StoryFn = (args) => ( } description="Outline type with block description" @@ -1349,7 +1229,6 @@ export const DescriptionWithComplexContent: StoryFn = (args) => ( } rightIcon={} @@ -1360,7 +1239,6 @@ export const DescriptionWithComplexContent: StoryFn = (args) => ( } prefix="$" @@ -1372,7 +1250,6 @@ export const DescriptionWithComplexContent: StoryFn = (args) => ( } hotkeys="cmd+u" @@ -1383,7 +1260,6 @@ export const DescriptionWithComplexContent: StoryFn = (args) => ( } rightIcon={} @@ -1400,7 +1276,6 @@ export const DescriptionWithComplexContent: StoryFn = (args) => ( } rightIcon={} @@ -1411,7 +1286,6 @@ export const DescriptionWithComplexContent: StoryFn = (args) => ( } prefix="$" @@ -1423,7 +1297,6 @@ export const DescriptionWithComplexContent: StoryFn = (args) => ( } hotkeys="cmd+u" @@ -1434,7 +1307,6 @@ export const DescriptionWithComplexContent: StoryFn = (args) => ( } rightIcon={} @@ -1451,7 +1323,6 @@ export const DescriptionWithComplexContent: StoryFn = (args) => ( } description="Inline with actions" @@ -1467,7 +1338,6 @@ export const DescriptionWithComplexContent: StoryFn = (args) => ( } description="Block description below the item with inline actions" @@ -1504,7 +1374,7 @@ export const DescriptionOverflow: StoryFn = (args) => ( } description="This is a very long inline description that will be truncated with ellipsis when it exceeds the available width" @@ -1514,7 +1384,7 @@ export const DescriptionOverflow: StoryFn = (args) => ( } description="Another example of text overflow with even narrower container width" @@ -1528,7 +1398,7 @@ export const DescriptionOverflow: StoryFn = (args) => ( } description="This is a very long block description that will wrap naturally to multiple lines when it exceeds the available container width, providing a better reading experience for detailed information" @@ -1538,7 +1408,7 @@ export const DescriptionOverflow: StoryFn = (args) => ( } description="Another example with narrower container demonstrating how block descriptions wrap text naturally without truncation, making them ideal for longer explanatory content that needs to be fully visible" @@ -1553,7 +1423,7 @@ export const DescriptionOverflow: StoryFn = (args) => (
} description="This is a comprehensive description that demonstrates how inline placement handles longer text content by truncating it with an ellipsis to maintain the single-line layout" @@ -1565,7 +1435,7 @@ export const DescriptionOverflow: StoryFn = (args) => (
} description="This is a comprehensive description that demonstrates how block placement handles longer text content by allowing it to wrap naturally across multiple lines for better readability" @@ -1593,7 +1463,6 @@ export const WithActions: StoryFn = (args) => ( } actions={ @@ -1607,7 +1476,6 @@ export const WithActions: StoryFn = (args) => ( } actions={ @@ -1624,7 +1492,6 @@ export const WithActions: StoryFn = (args) => ( } @@ -1639,7 +1506,6 @@ export const WithActions: StoryFn = (args) => ( } @@ -1654,7 +1520,6 @@ export const WithActions: StoryFn = (args) => ( } @@ -1669,7 +1534,6 @@ export const WithActions: StoryFn = (args) => ( } @@ -1684,7 +1548,6 @@ export const WithActions: StoryFn = (args) => ( } @@ -1697,28 +1560,12 @@ export const WithActions: StoryFn = (args) => ( > XLarge item - } - actions={ - <> - } aria-label="Edit" /> - } aria-label="Delete" /> - - } - > - Inline item - Different Types with Actions } actions={ @@ -1732,7 +1579,6 @@ export const WithActions: StoryFn = (args) => ( } actions={ @@ -1746,7 +1592,6 @@ export const WithActions: StoryFn = (args) => ( } actions={ @@ -1760,7 +1605,6 @@ export const WithActions: StoryFn = (args) => ( } actions={ @@ -1778,7 +1622,6 @@ export const WithActions: StoryFn = (args) => ( } rightIcon={} @@ -1793,7 +1636,6 @@ export const WithActions: StoryFn = (args) => ( } prefix="$" @@ -1809,7 +1651,6 @@ export const WithActions: StoryFn = (args) => ( } description="Additional information" @@ -1825,7 +1666,6 @@ export const WithActions: StoryFn = (args) => ( } description="Additional information" @@ -1844,7 +1684,7 @@ export const WithActions: StoryFn = (args) => ( Long Text with Actions } actions={ @@ -1880,7 +1720,6 @@ export const WithActionsOnHover: StoryFn = (args) => ( } showActionsOnHover={true} @@ -1895,7 +1734,6 @@ export const WithActionsOnHover: StoryFn = (args) => ( } showActionsOnHover={true} @@ -1914,7 +1752,6 @@ export const WithActionsOnHover: StoryFn = (args) => ( } showActionsOnHover={false} @@ -1929,7 +1766,6 @@ export const WithActionsOnHover: StoryFn = (args) => ( } showActionsOnHover={true} @@ -1948,7 +1784,6 @@ export const WithActionsOnHover: StoryFn = (args) => ( } @@ -1964,7 +1799,6 @@ export const WithActionsOnHover: StoryFn = (args) => ( } @@ -1980,7 +1814,6 @@ export const WithActionsOnHover: StoryFn = (args) => ( } @@ -2000,7 +1833,6 @@ export const WithActionsOnHover: StoryFn = (args) => ( } description="Inline description" @@ -2017,7 +1849,6 @@ export const WithActionsOnHover: StoryFn = (args) => ( } description="Block description below the item" @@ -2152,67 +1983,39 @@ DynamicAutoTooltip.parameters = { export const DifferentShapes: StoryFn = (args) => ( Card Shape - } - > + }> Card shape with larger border radius Button Shape (Default) - } - > + }> Button shape with default border radius Sharp Shape - } - > + }> Sharp shape with no border radius + Pill Shape + }> + Pill shape with fully rounded ends + + All Shapes Comparison - } - > + }> Card shape - } - > + }> Button shape - } - > + }> Sharp shape + }> + Pill shape + ); @@ -2225,7 +2028,7 @@ DifferentShapes.parameters = { docs: { description: { story: - 'Demonstrates the three shape variants: `card` (larger border radius), `button` (default border radius), and `sharp` (no border radius). Use card for card-like interfaces, button for interactive elements, and sharp for minimal or technical interfaces.', + 'Demonstrates the four shape variants: `card` (larger border radius), `button` (default border radius), `sharp` (no border radius), and `pill` (fully rounded ends). Use card for card-like interfaces, button for interactive elements, sharp for minimal or technical interfaces, and pill for tag or chip-like appearances.', }, }, }; @@ -2234,18 +2037,11 @@ export const WithHighlight: StoryFn = (args) => ( Basic Highlight - } - highlight="user" - > + } highlight="user"> User account settings } highlight="settings" @@ -2256,19 +2052,12 @@ export const WithHighlight: StoryFn = (args) => ( Case Sensitivity - } - highlight="USER" - > + } highlight="USER"> Case-insensitive: USER and user match } highlight="USER" @@ -2278,20 +2067,13 @@ export const WithHighlight: StoryFn = (args) => ( Multiple Matches - } - highlight="the" - > + } highlight="the"> The quick brown fox jumps over the lazy dog With Custom Highlight Styles } highlight="custom" @@ -2304,7 +2086,6 @@ export const WithHighlight: StoryFn = (args) => ( } rightIcon={} @@ -2316,7 +2097,6 @@ export const WithHighlight: StoryFn = (args) => ( } highlight="actions" @@ -2345,3 +2125,124 @@ WithHighlight.parameters = { }, }, }; + +export const CustomSize: StoryFn = (args) => ( + + Custom Size: String Value (8x) + }> + Custom size with 8x + + + Custom Size: Number Value (64px) + }> + Custom size with 64px + + +); + +CustomSize.args = { + width: '300px', +}; + +CustomSize.parameters = { + docs: { + description: { + story: + 'Demonstrates custom size values using the `size` prop. Supports both string values (like `8x`) and number values (converted to pixels, like `64`). Custom sizes override the default size token via the `tokens` prop.', + }, + }, +}; + +export const TypesAndThemes: StoryFn = (args) => { + // Valid type+theme combinations: + // - title: only 'default' + // - card: 'default', 'success', 'danger', 'note' + // - all other types: 'default', 'success', 'danger', 'special' + const standardTypes = [ + 'item', + 'primary', + 'secondary', + 'outline', + 'neutral', + 'clear', + ] as const; + const standardThemes = ['default', 'danger', 'success', 'special'] as const; + const cardThemes = ['default', 'danger', 'success', 'note'] as const; + + return ( + + All Type + Theme Combinations + + + Type: header (default theme only) + + }> + default + + + + + {standardTypes.map((type) => ( + + Type: {type} + + {standardThemes.map((theme) => { + const item = ( + })} + > + {theme} + + ); + + if (theme === 'special') { + return ( + + {item} + + ); + } + + return {item}; + })} + + + ))} + + + Type: card + + {cardThemes.map((theme) => ( + } + > + {theme} + + ))} + + + + ); +}; + +TypesAndThemes.parameters = { + docs: { + description: { + story: + 'Showcases all valid type and theme combinations. Valid combinations: `title` type only supports `default` theme; `card` type supports `default`, `success`, `danger`, and `note` themes; all other types (`item`, `primary`, `secondary`, `outline`, `neutral`, `clear`, `link`) support `default`, `success`, `danger`, and `special` themes. The `link` type does not support icons or loading state (`isLoading`). Using an invalid type+theme combination, icons with `link` type, or `isLoading` with `link` type will trigger a console warning.', + }, + }, +}; diff --git a/src/components/content/Item/Item.tsx b/src/components/content/Item/Item.tsx index 0c8042ebb..c6b70b80a 100644 --- a/src/components/content/Item/Item.tsx +++ b/src/components/content/Item/Item.tsx @@ -2,6 +2,7 @@ import { ForwardedRef, forwardRef, HTMLAttributes, + isValidElement, KeyboardEvent, MouseEvent, PointerEvent, @@ -12,7 +13,9 @@ import { import { OverlayProps } from 'react-aria'; import { useHotkeys } from 'react-hotkeys-hook'; +import { useWarn } from '../../../_internal/hooks/use-warn'; import { + DANGER_CARD_STYLES, DANGER_CLEAR_STYLES, DANGER_ITEM_STYLES, DANGER_LINK_STYLES, @@ -20,6 +23,7 @@ import { DANGER_OUTLINE_STYLES, DANGER_PRIMARY_STYLES, DANGER_SECONDARY_STYLES, + DEFAULT_CARD_STYLES, DEFAULT_CLEAR_STYLES, DEFAULT_ITEM_STYLES, DEFAULT_LINK_STYLES, @@ -28,6 +32,7 @@ import { DEFAULT_PRIMARY_STYLES, DEFAULT_SECONDARY_STYLES, ItemVariant, + NOTE_CARD_STYLES, SPECIAL_CLEAR_STYLES, SPECIAL_ITEM_STYLES, SPECIAL_LINK_STYLES, @@ -35,6 +40,7 @@ import { SPECIAL_OUTLINE_STYLES, SPECIAL_PRIMARY_STYLES, SPECIAL_SECONDARY_STYLES, + SUCCESS_CARD_STYLES, SUCCESS_CLEAR_STYLES, SUCCESS_ITEM_STYLES, SUCCESS_LINK_STYLES, @@ -49,22 +55,57 @@ import { BaseProps, CONTAINER_STYLES, ContainerStyleProps, + Mods, Props, Styles, tasty, } from '../../../tasty'; -import { mergeProps } from '../../../utils/react'; +import { DynamicIcon, mergeProps, resolveIcon } from '../../../utils/react'; import { ItemAction } from '../../actions/ItemAction'; import { ItemActionProvider } from '../../actions/ItemActionContext'; +import { IconSwitch } from '../../helpers/IconSwitch/IconSwitch'; import { CubeTooltipProviderProps } from '../../overlays/Tooltip/TooltipProvider'; import { highlightText } from '../highlightText'; import { HotKeys } from '../HotKeys'; import { ItemBadge } from '../ItemBadge'; import { useAutoTooltip } from '../use-auto-tooltip'; +const ITEM_SIZE_VALUES = [ + 'xsmall', + 'small', + 'medium', + 'large', + 'xlarge', + 'inline', +] as const; + +/** Known modifiers for Item component */ +export type ItemMods = Mods<{ + 'has-icon'?: boolean; + 'has-start-content'?: boolean; + 'has-end-content'?: boolean; + 'has-right-icon'?: boolean; + 'has-label'?: boolean; + 'has-prefix'?: boolean; + 'has-suffix'?: boolean; + 'has-description'?: boolean; + 'has-actions'?: boolean; + 'has-actions-content'?: boolean; + 'show-actions-on-hover'?: boolean; + checkbox?: boolean; + disabled?: boolean; + selected?: boolean; + loading?: boolean; + size?: string; + description?: string; + type?: string; + theme?: string; + shape?: string; +}>; + export interface CubeItemProps extends BaseProps, ContainerStyleProps { - icon?: ReactNode | 'checkbox'; - rightIcon?: ReactNode; + icon?: DynamicIcon | 'checkbox'; + rightIcon?: DynamicIcon; prefix?: ReactNode; suffix?: ReactNode; description?: ReactNode; @@ -101,15 +142,16 @@ export interface CubeItemProps extends BaseProps, ContainerStyleProps { | (string & {}); type?: | 'item' + | 'header' | 'primary' | 'secondary' | 'outline' | 'neutral' | 'clear' | 'link' + | 'card' | (string & {}); - theme?: 'default' | 'danger' | 'success' | 'special' | (string & {}); - variant?: ItemVariant; + theme?: 'default' | 'danger' | 'success' | 'special' | 'note' | (string & {}); /** Keyboard shortcut that triggers the element when pressed */ hotkeys?: string; /** @@ -142,22 +184,15 @@ export interface CubeItemProps extends BaseProps, ContainerStyleProps { * and makes the component disabled. */ isLoading?: boolean; - /** - * When true, applies card styling with increased border radius. - */ - isCard?: boolean; - /** - * When true, adds button modifier to the component styling. - */ - isButton?: boolean; /** * Shape of the item's border radius. * - `card` - Card shape with larger border radius (`1cr`) * - `button` - Button shape with default border radius (default) * - `sharp` - Sharp corners with no border radius (`0`) + * - `pill` - Pill shape with fully rounded ends (`round`) * @default "button" */ - shape?: 'card' | 'button' | 'sharp'; + shape?: 'card' | 'button' | 'sharp' | 'pill'; /** * @private * Default tooltip placement for the item. @@ -168,6 +203,12 @@ export interface CubeItemProps extends BaseProps, ContainerStyleProps { * Ref to access the label element directly */ labelRef?: RefObject; + /** + * Heading level for the Label element when type="header". + * Changes the Label's HTML tag to the corresponding heading (h1-h6). + * @default 3 + */ + level?: 1 | 2 | 3 | 4 | 5 | 6; /** * String to highlight within children. * Only works when children is a plain string. @@ -182,6 +223,10 @@ export interface CubeItemProps extends BaseProps, ContainerStyleProps { * Custom styles for highlighted text. */ highlightStyles?: Styles; + /** + * Variant of the item. + */ + variant?: ItemVariant; } const DEFAULT_ICON_STYLES: Styles = { @@ -232,6 +277,8 @@ const ItemElement = tasty({ gridTemplate: { '': '"icon prefix label suffix rightIcon actions" auto "icon prefix label suffix rightIcon actions" auto / max-content max-content 1sf max-content max-content max-content', 'description=inline': + '"icon prefix description suffix rightIcon actions" auto / max-content max-content 1sf max-content max-content max-content', + 'description=inline & has-label': '"icon prefix label suffix rightIcon actions" auto "icon prefix description suffix rightIcon actions" auto / max-content max-content 1sf max-content max-content max-content', 'description=block': '"icon prefix label suffix rightIcon actions" auto "description description description description description description" auto / max-content max-content 1sf max-content max-content max-content', @@ -242,13 +289,17 @@ const ItemElement = tasty({ 'menuitem | listboxitem': 0, }, position: 'relative', - padding: 0, + padding: { + '': 0, + 'type=card': '.5x', + }, margin: 0, radius: { '': true, 'shape=card': '1cr', 'shape=button': true, 'shape=sharp': '0', + 'shape=pill': 'round', }, height: { '': 'min $size', @@ -276,11 +327,15 @@ const ItemElement = tasty({ }, preset: { '': 't3', - button: 't3m', + '!type=item': 't3m', 'size=xsmall': 't4', 'size=xlarge': 't2', - 'size=xlarge & button': 't2m', - 'size=inline': 'tag', + 'size=xlarge & !type=item': 't2m', + 'size=inline': 'inline', + '(type=header | type=card) & (size=xsmall | size=small | size=medium)': + 'h6', + '(type=header | type=card) & size=large': 'h5', + '(type=header | type=card) & size=xlarge': 'h4', }, boxSizing: 'border-box', textDecoration: 'none', @@ -288,7 +343,7 @@ const ItemElement = tasty({ reset: 'button', outlineOffset: 1, cursor: { - '': 'default', + '': 'inherit', ':is(a)': 'pointer', ':is(button) | listboxitem | menuitem': '$pointer', disabled: 'not-allowed', @@ -301,12 +356,10 @@ const ItemElement = tasty({ 'size=medium': '$size-md', 'size=large': '$size-lg', 'size=xlarge': '$size-xl', - 'size=inline': '1lh', - }, - '$inline-padding': { - '': 'max($min-inline-padding, (($size - 1lh - 2bw) / 2 + $inline-compensation))', - 'size=inline': '.25x', + 'size=inline': '(1lh + 2bw)', }, + '$inline-padding': + 'max($min-inline-padding, (($size - 1lh - 2bw) / 2 + $inline-compensation))', '$block-padding': { '': '.5x', 'size=xsmall | size=small': '.25x', @@ -340,11 +393,11 @@ const ItemElement = tasty({ 'description=block & !has-end-content': '$inline-padding', }, '$description-padding-bottom': { - '': '$block-padding', - 'description=block': '$bottom-padding', + '': 0, + 'has-label & description=inline': '$block-padding', + 'has-label & description=block': + 'max($block-padding, (($size - 4x) / 2) + $block-padding)', }, - '$bottom-padding': - 'max($block-padding, (($size - 4x) / 2) + $block-padding)', Icon: { ...DEFAULT_ICON_STYLES, gridArea: 'icon' }, @@ -352,6 +405,7 @@ const ItemElement = tasty({ Label: { $: '>', + margin: 0, gridArea: 'label', display: 'block', placeSelf: 'center start', @@ -361,6 +415,7 @@ const ItemElement = tasty({ overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: '100%', + preset: 'inherit', padding: '$block-padding $label-padding-right $label-padding-bottom $label-padding-left', }, @@ -368,9 +423,17 @@ const ItemElement = tasty({ Description: { $: '>', gridArea: 'description', - preset: 't4', + preset: { + '': 't4', + 'type=card | type=header': 't3', + }, + placeSelf: 'center start', + boxSizing: 'border-box', color: 'inherit', - opacity: 0.75, + opacity: { + '': 0.75, + 'type=card | type=header': 1, + }, overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis', @@ -436,6 +499,7 @@ const ItemElement = tasty({ 'default.clear': DEFAULT_CLEAR_STYLES, 'default.link': DEFAULT_LINK_STYLES, 'default.item': DEFAULT_ITEM_STYLES, + 'default.card': DEFAULT_CARD_STYLES, // Danger theme 'danger.primary': DANGER_PRIMARY_STYLES, 'danger.secondary': DANGER_SECONDARY_STYLES, @@ -444,6 +508,7 @@ const ItemElement = tasty({ 'danger.clear': DANGER_CLEAR_STYLES, 'danger.link': DANGER_LINK_STYLES, 'danger.item': DANGER_ITEM_STYLES, + 'danger.card': DANGER_CARD_STYLES, // Success theme 'success.primary': SUCCESS_PRIMARY_STYLES, 'success.secondary': SUCCESS_SECONDARY_STYLES, @@ -452,6 +517,7 @@ const ItemElement = tasty({ 'success.clear': SUCCESS_CLEAR_STYLES, 'success.link': SUCCESS_LINK_STYLES, 'success.item': SUCCESS_ITEM_STYLES, + 'success.card': SUCCESS_CARD_STYLES, // Special theme 'special.primary': SPECIAL_PRIMARY_STYLES, 'special.secondary': SPECIAL_SECONDARY_STYLES, @@ -460,6 +526,8 @@ const ItemElement = tasty({ 'special.clear': SPECIAL_CLEAR_STYLES, 'special.link': SPECIAL_LINK_STYLES, 'special.item': SPECIAL_ITEM_STYLES, + // Note theme (card type only) + 'note.card': NOTE_CARD_STYLES, }, styleProps: CONTAINER_STYLES, }); @@ -474,12 +542,12 @@ const Item = ( type = 'item', theme = 'default', mods, - icon, - rightIcon, + icon: iconProp, + rightIcon: rightIconProp, prefix, suffix, description, - descriptionPlacement = 'inline', + descriptionPlacement, labelProps, descriptionProps, keyboardShortcutProps, @@ -492,34 +560,140 @@ const Item = ( style, loadingSlot = 'auto', isLoading = false, - isCard = false, actions, showActionsOnHover = false, disableActionsFocus = false, - isButton = false, - shape = 'button', + shape, defaultTooltipPlacement = 'top', + level = 3, highlight, highlightCaseSensitive = false, highlightStyles, ...rest } = props; + // Determine if Label will be rendered + const hasLabel = !!(children || labelProps); + + // Set default descriptionPlacement based on type + // For card/header types, use 'block' only when Label is rendered + const finalDescriptionPlacement = + descriptionPlacement ?? + ((type === 'card' || type === 'header') && hasLabel ? 'block' : 'inline'); + + // Set default shape based on type + const finalShape = shape ?? (type === 'card' ? 'card' : 'button'); + // Loading state makes the component disabled const finalIsDisabled = isDisabled === true || (isLoading && isDisabled !== false); + // Validate type+theme combinations + const STANDARD_THEMES = ['default', 'success', 'danger', 'special']; + const CARD_THEMES = ['default', 'success', 'danger', 'note']; + const HEADER_THEMES = ['default']; + + const isInvalidCombination = + (type === 'header' && !HEADER_THEMES.includes(theme)) || + (type === 'card' && !CARD_THEMES.includes(theme)) || + (!['header', 'card'].includes(type) && !STANDARD_THEMES.includes(theme)); + + useWarn(isInvalidCombination, { + key: ['Item', 'invalid-type-theme', type, theme], + args: [ + `Item: Invalid type+theme combination. type="${type}" does not support theme="${theme}".` + + (type === 'header' + ? ' The "header" type only supports theme: default.' + : type === 'card' + ? ' The "card" type only supports themes: default, success, danger, note.' + : ' Standard types support themes: default, success, danger, special.'), + ], + }); + + // Warn if link type is used with icons or loading state + const hasLinkWithIcons = type === 'link' && (iconProp || rightIconProp); + const hasLinkWithLoading = type === 'link' && isLoading; + const hasLinkRestrictions = hasLinkWithIcons || hasLinkWithLoading; + + const linkRestrictionMessages: string[] = []; + if (hasLinkWithIcons) { + linkRestrictionMessages.push('icons (`icon` or `rightIcon` props)'); + } + if (hasLinkWithLoading) { + linkRestrictionMessages.push('loading state (`isLoading` prop)'); + } + + useWarn(hasLinkRestrictions, { + key: ['Item', 'link-restrictions'], + args: [ + `Item: The "link" type does not support ${linkRestrictionMessages.join(' or ')}. Remove these props when using type="link".`, + ], + }); + // Determine if we should show checkbox instead of icon - const hasCheckbox = icon === 'checkbox'; + const hasCheckbox = iconProp === 'checkbox'; + + // Determine if size is custom (number or unrecognized string) + const isCustomSize = + typeof size === 'number' || + !(ITEM_SIZE_VALUES as readonly string[]).includes(size); + const sizeTokenValue = + typeof size === 'number' ? `${size}px` : isCustomSize ? size : undefined; + + // Base mods for icon resolution (without icon-dependent mods) + const baseMods = useMemo( + () => ({ + disabled: finalIsDisabled, + selected: isSelected === true, + loading: isLoading, + ...(!isCustomSize && { size: size as string }), + type, + theme, + shape: finalShape, + ...mods, + }), + [ + finalIsDisabled, + isSelected, + isLoading, + size, + isCustomSize, + type, + theme, + finalShape, + mods, + ], + ); + + // Resolve dynamic icon props (skip resolution for 'checkbox' special value) + const resolvedIcon = useMemo(() => { + if (hasCheckbox) { + return { content: null, hasSlot: true }; + } + return resolveIcon(iconProp as DynamicIcon, baseMods); + }, [iconProp, baseMods, hasCheckbox]); + + const resolvedRightIcon = useMemo( + () => resolveIcon(rightIconProp, baseMods), + [rightIconProp, baseMods], + ); // Determine which slot to use for loading when "auto" is selected + // Must be computed before hasIconSlot/hasRightIconSlot since they depend on it const resolvedLoadingSlot = useMemo(() => { if (loadingSlot !== 'auto') return loadingSlot; // Auto logic: prefer icon if present, then rightIcon, fallback to icon - if (rightIcon && !icon) return 'rightIcon'; + if (resolvedRightIcon.hasSlot && !resolvedIcon.hasSlot) return 'rightIcon'; return 'icon'; // fallback - }, [loadingSlot, icon, rightIcon]); + }, [loadingSlot, resolvedIcon.hasSlot, resolvedRightIcon.hasSlot]); + + // Determine if icon slots should render (original slot OR loading state targets this slot) + const hasIconSlot = + resolvedIcon.hasSlot || (isLoading && resolvedLoadingSlot === 'icon'); + const hasRightIconSlot = + resolvedRightIcon.hasSlot || + (isLoading && resolvedLoadingSlot === 'rightIcon'); const showDescription = useMemo(() => { const copyProps = { ...descriptionProps }; @@ -529,13 +703,41 @@ const Item = ( // Apply loading state to appropriate slots const finalIcon = - isLoading && resolvedLoadingSlot === 'icon' ? : icon; + isLoading && resolvedLoadingSlot === 'icon' ? ( + + ) : ( + resolvedIcon.content + ); const finalRightIcon = isLoading && resolvedLoadingSlot === 'rightIcon' ? ( ) : ( - rightIcon + resolvedRightIcon.content ); + + // Generate stable keys for icon transitions based on icon type + const iconKey = hasCheckbox + ? 'checkbox' + : isLoading && resolvedLoadingSlot === 'icon' + ? 'loading' + : isValidElement(finalIcon) + ? (finalIcon.type as any)?.displayName || + (finalIcon.type as any)?.name || + 'icon' + : finalIcon + ? 'icon' + : 'empty'; + + const rightIconKey = + isLoading && resolvedLoadingSlot === 'rightIcon' + ? 'loading' + : isValidElement(finalRightIcon) + ? (finalRightIcon.type as any)?.displayName || + (finalRightIcon.type as any)?.name || + 'icon' + : finalRightIcon + ? 'icon' + : 'empty'; const finalPrefix = isLoading && resolvedLoadingSlot === 'prefix' ? : prefix; @@ -576,13 +778,14 @@ const Item = ( [hotkeys, finalIsDisabled], ); - mods = useMemo(() => { + const finalMods = useMemo(() => { return { - 'has-icon': !!finalIcon, - 'has-start-content': !!(finalIcon || finalPrefix), - 'has-end-content': !!(finalRightIcon || finalSuffix || actions), - 'has-right-icon': !!finalRightIcon, - 'has-label': !!(children || labelProps), + ...baseMods, + 'has-icon': hasIconSlot, + 'has-start-content': !!(hasIconSlot || finalPrefix), + 'has-end-content': !!(hasRightIconSlot || finalSuffix || actions), + 'has-right-icon': hasRightIconSlot, + 'has-label': hasLabel, 'has-prefix': !!finalPrefix, 'has-suffix': !!finalSuffix, 'has-description': showDescription, @@ -590,37 +793,20 @@ const Item = ( 'has-actions-content': !!(actions && actions !== true), 'show-actions-on-hover': showActionsOnHover === true, checkbox: hasCheckbox, - disabled: finalIsDisabled, - selected: isSelected === true, - loading: isLoading, - card: isCard === true, - button: isButton === true, - ...(typeof size === 'number' ? {} : { size }), - description: showDescription ? descriptionPlacement : 'none', - type, - theme, - shape, - ...mods, + description: showDescription ? finalDescriptionPlacement : 'none', }; }, [ - finalIcon, - finalRightIcon, + baseMods, + hasIconSlot, + hasRightIconSlot, finalPrefix, finalSuffix, showDescription, - descriptionPlacement, + finalDescriptionPlacement, hasCheckbox, - isSelected, - isLoading, - isCard, - isButton, - shape, actions, showActionsOnHover, - size, - type, - theme, - mods, + hasLabel, ]); const { @@ -666,45 +852,60 @@ const Item = ( } }; - // Merge custom size style with provided style - const finalStyle = - typeof size === 'number' - ? ({ ...style, '--size': `${size}px` } as any) - : style; - return ( - {finalIcon && ( + {hasIconSlot && (
- {hasCheckbox ? : finalIcon} + + {hasCheckbox ? : finalIcon} +
)} {finalPrefix &&
{finalPrefix}
} - {children || labelProps ? ( -
- {processedChildren} -
- ) : null} + {children || labelProps + ? (() => { + const LabelTag = + type === 'header' ? (`h${level}` as const) : 'div'; + return ( + + {processedChildren} + + ); + })() + : null} {showDescription ? (
{description}
) : null} {finalSuffix &&
{finalSuffix}
} - {finalRightIcon &&
{finalRightIcon}
} + {hasRightIconSlot && ( +
+ + {finalRightIcon} + +
+ )} {actions && (
{actions !== true ? ( diff --git a/src/components/content/Layout/Layout.docs.mdx b/src/components/content/Layout/Layout.docs.mdx index 00231a825..219c6aa93 100644 --- a/src/components/content/Layout/Layout.docs.mdx +++ b/src/components/content/Layout/Layout.docs.mdx @@ -42,6 +42,13 @@ Customizes the root Layout element. These properties allow direct style application: `width`, `height`, `padding`, `gap`, `fill`, `border`, `flow`. +### Inner Element Properties + +| Property | Type | Description | +|----------|------|-------------| +| innerRef | `ForwardedRef` | Ref for the inner content element | +| innerProps | `HTMLAttributes` | Props to spread on the Inner sub-element | + ### Grid Mode When `isGrid` is enabled, the inner content becomes a CSS grid: @@ -129,6 +136,8 @@ Scrollable content area with automatic overflow handling and custom scrollbar st |----------|------|---------|-------------| | scrollbar | `'default' \| 'thin' \| 'tiny' \| 'none'` | `'thin'` | Scrollbar style | | children | `ReactNode` | - | Content to display | +| innerRef | `ForwardedRef` | - | Ref for the inner content element | +| innerProps | `HTMLAttributes` | - | Props to spread on the Inner sub-element | **Scrollbar types:** - `default` - Browser default scrollbar @@ -157,6 +166,8 @@ Horizontally centered content area with constrained width. Ideal for forms, arti | Property | Type | Default | Description | |----------|------|---------|-------------| | children | `ReactNode` | - | Content to display | +| innerRef | `ForwardedRef` | - | Ref for the inner content element | +| innerProps | `HTMLAttributes` | - | Props to spread on the Inner sub-element | **Width constraints:** - Minimum: `40x` (320px at default gap) @@ -258,6 +269,8 @@ Collapsible side panel with resizing, transitions, and multiple rendering modes. | defaultIsDialogOpen | `boolean` | `false` | Initial dialog state | | onDialogOpenChange | `(isOpen: boolean) => void` | - | Dialog state callback | | dialogProps | `CubeDialogContainerProps` | - | Props for dialog mode | +| innerRef | `ForwardedRef` | - | Ref for the inner content element | +| innerProps | `HTMLAttributes` | - | Props to spread on the Inner sub-element | **Panel Modes:** @@ -279,6 +292,48 @@ Collapsible side panel with resizing, transitions, and multiple rendering modes. --- +### Layout.Pane + +Resizable inline section within a layout. Unlike Layout.Panel (which is absolutely positioned), Pane participates in the normal flex/grid flow and can be resized via drag handles. + +```jsx + + Pane Content + This pane can be resized by dragging the edge. + +``` + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| resizeEdge | `'right' \| 'bottom'` | `'right'` | Edge where resize handler appears | +| isResizable | `boolean` | `false` | Enable resize functionality | +| size | `number \| string` | - | Controlled size (width for right edge, height for bottom edge) | +| defaultSize | `number \| string` | `200` | Default size for uncontrolled state | +| minSize | `number \| string` | - | Minimum size constraint | +| maxSize | `number \| string` | - | Maximum size constraint | +| onSizeChange | `(size: number) => void` | - | Size change callback | +| scrollbar | `ScrollbarType` | `'thin'` | Scrollbar style | +| children | `ReactNode` | - | Content to display | +| innerRef | `ForwardedRef` | - | Ref for the inner content element | +| innerProps | `HTMLAttributes` | - | Props to spread on the Inner sub-element | + +**Sub-elements:** +- `Inner` - Scrollable inner container +- `ScrollbarV` - Vertical scroll indicator (tiny mode) +- `ScrollbarH` - Horizontal scroll indicator (tiny mode) +- `ResizeHandler` - Drag handle for resizing +- `Drag` - Visual drag indicator +- `DragPart` - Individual dots in the drag indicator + +--- + ### Layout.PanelHeader Header for panels with optional close button. diff --git a/src/components/content/Layout/Layout.stories.tsx b/src/components/content/Layout/Layout.stories.tsx index fc56bc558..189067a78 100644 --- a/src/components/content/Layout/Layout.stories.tsx +++ b/src/components/content/Layout/Layout.stories.tsx @@ -1,6 +1,7 @@ import { IconFilter, IconFilterFilled } from '@tabler/icons-react'; import { useState } from 'react'; +import { baseProps } from '../../../stories/lists/baseProps'; import { Button, ItemButton } from '../../actions'; import { Block } from '../../Block'; import { Space } from '../../layout/Space'; @@ -17,6 +18,94 @@ const meta: Meta = { component: Layout, parameters: { layout: 'fullscreen', + controls: { exclude: baseProps }, + }, + argTypes: { + /* Content */ + children: { + control: { type: null }, + description: + 'Layout sub-components (Layout.Header, Layout.Content, Layout.Panel, etc.)', + table: { + type: { summary: 'ReactNode' }, + }, + }, + + /* Grid Mode */ + isGrid: { + control: 'boolean', + description: 'Switch to grid display mode', + table: { + type: { summary: 'boolean' }, + defaultValue: { summary: false }, + }, + }, + columns: { + control: 'text', + description: 'Grid template columns (when isGrid=true)', + table: { + type: { summary: 'string' }, + }, + }, + rows: { + control: 'text', + description: 'Grid template rows (when isGrid=true)', + table: { + type: { summary: 'string' }, + }, + }, + template: { + control: 'text', + description: 'Grid template shorthand', + table: { + type: { summary: 'string' }, + }, + }, + + /* Behavior */ + contentPadding: { + control: 'text', + description: + 'Padding for content areas (Layout.Content components). Default: "1x"', + table: { + type: { summary: 'string' }, + defaultValue: { summary: '1x' }, + }, + }, + hasTransition: { + control: 'boolean', + description: + 'Enable transition animation for Inner content when panels open/close', + table: { + type: { summary: 'boolean' }, + defaultValue: { summary: false }, + }, + }, + + /* Inner Element */ + innerRef: { + control: { type: null }, + description: 'Ref for the inner content element', + table: { + type: { summary: 'ForwardedRef' }, + }, + }, + innerProps: { + control: { type: null }, + description: 'Props to spread on the Inner sub-element', + table: { + type: { summary: 'HTMLAttributes' }, + }, + }, + + /* Styles */ + styles: { + control: { type: null }, + description: 'Styles for wrapper and Inner sub-element', + table: { + type: { summary: 'Styles' }, + }, + }, }, }; @@ -110,13 +199,11 @@ export const WithSidePanel: Story = { - - - - App - + + + App Main content area @@ -403,10 +490,10 @@ export const MultiplePanels: Story = { export const HorizontalScrollableContent: Story = { render: () => ( - - + + Fixed Left - + This is a very long line of text that should not wrap and will cause @@ -415,10 +502,10 @@ export const HorizontalScrollableContent: Story = { eiusmod tempor incididunt ut labore et dolore magna aliqua →→→→→→→→ - + Fixed Right - - + + ), }; diff --git a/src/components/content/Layout/Layout.tsx b/src/components/content/Layout/Layout.tsx index b99fcfa90..6a97a0c79 100644 --- a/src/components/content/Layout/Layout.tsx +++ b/src/components/content/Layout/Layout.tsx @@ -4,6 +4,7 @@ import { FocusEvent, ForwardedRef, forwardRef, + HTMLAttributes, isValidElement, KeyboardEvent, ReactNode, @@ -29,6 +30,7 @@ import { tasty, } from '../../../tasty'; import { isDevEnv } from '../../../tasty/utils/isDevEnv'; +import { useCombinedRefs } from '../../../utils/react'; import { Alert } from '../Alert'; import { LayoutProvider, useLayoutContext } from './LayoutContext'; @@ -56,6 +58,7 @@ const LayoutElement = tasty({ }, Inner: { + // .base-class[data-hover] > [data-element="Inner"] { ...} // Direct child selector required for nested layouts $: '>', position: 'absolute', @@ -63,6 +66,8 @@ const LayoutElement = tasty({ display: 'flex', flow: 'column', overflow: 'hidden', + placeContent: 'stretch', + placeItems: 'stretch', // Disable transition during panel resize for snappy feedback // Also disable transition when not ready to prevent initial animation // Only animate when has-transition is enabled (and not dragging/not-ready) @@ -96,6 +101,10 @@ export interface CubeLayoutProps /** Styles for wrapper and Inner sub-element */ styles?: Styles; children?: ReactNode; + /** Ref for the inner content element */ + innerRef?: ForwardedRef; + /** Props to spread on the Inner sub-element */ + innerProps?: HTMLAttributes; /** * @internal Force show dev warning even in production (for storybook testing) */ @@ -130,10 +139,14 @@ function LayoutInner( children, style, forwardedRef, + innerRef: innerRefProp, + innerProps, _forceShowDevWarning, ...otherProps } = props; + const combinedInnerRef = useCombinedRefs(innerRefProp); + // Separate panels from other children const { panels, content } = useMemo(() => { const panelElements: ReactNode[] = []; @@ -155,14 +168,14 @@ function LayoutInner( const innerStyles = extractStyles(otherProps, INNER_STYLES); // Calculate if the layout flow is vertical (for auto-border feature) - // Default flow is 'column' (vertical), check if user overrides it + // Default flow is 'column' (vertical), only horizontal when explicitly set to 'row' const isVertical = useMemo(() => { - const providedFlow = (styles?.Inner as Record)?.flow; - const flowValue = - typeof providedFlow === 'string' ? providedFlow : 'column'; + const flowFromProp = innerStyles?.flow; + const flowFromStyles = (styles?.Inner as Record)?.flow; + const flowValue = flowFromProp ?? flowFromStyles; - return flowValue.startsWith('column'); - }, [styles?.Inner]); + return typeof flowValue !== 'string' || !flowValue.startsWith('row'); + }, [innerStyles?.flow, styles?.Inner]); // Merge styles using the same pattern as LayoutPane const finalStyles = useMemo(() => { @@ -344,7 +357,12 @@ function LayoutInner( {/* Panels are rendered outside the Inner element */} {panels} {/* Other content goes inside the Inner element */} -
+
{content}
diff --git a/src/components/content/Layout/LayoutContainer.tsx b/src/components/content/Layout/LayoutContainer.tsx index ea1949700..e827cddd3 100644 --- a/src/components/content/Layout/LayoutContainer.tsx +++ b/src/components/content/Layout/LayoutContainer.tsx @@ -1,4 +1,10 @@ -import { ForwardedRef, forwardRef, ReactNode, useMemo } from 'react'; +import { + ForwardedRef, + forwardRef, + HTMLAttributes, + ReactNode, + useMemo, +} from 'react'; import { BaseProps, @@ -10,6 +16,7 @@ import { Styles, tasty, } from '../../../tasty'; +import { useCombinedRefs } from '../../../utils/react'; import { LayoutContextReset } from './LayoutContext'; @@ -30,7 +37,7 @@ const ContainerElement = tasty({ width: '100%', border: { '': 0, - '!:last-child': '$layout-border-size solid #border bottom', + '!:last-child': '($layout-border-size, 1bw) solid #border bottom', }, Inner: { @@ -40,6 +47,8 @@ const ContainerElement = tasty({ flow: 'column', width: '40x 100% 120x', boxSizing: 'border-box', + + '$layout-border-size': '0', }, }, }); @@ -52,6 +61,10 @@ export interface CubeLayoutContainerProps styles?: Styles; /** Custom styles for the inner element */ innerStyles?: Styles; + /** Ref for the inner content element */ + innerRef?: ForwardedRef; + /** Props to spread on the Inner sub-element */ + innerProps?: HTMLAttributes; } function LayoutContainer( @@ -62,6 +75,8 @@ function LayoutContainer( children, styles, innerStyles: innerStylesProp, + innerRef: innerRefProp, + innerProps, ...otherProps } = props; const innerStyles = extractStyles( @@ -76,13 +91,15 @@ function LayoutContainer( return mergeStyles(styles, hasInnerStyles ? { Inner: innerStyles } : null); }, [styles, hasInnerStyles, innerStyles]); + const combinedInnerRef = useCombinedRefs(innerRefProp); + return ( -
+
{children}
diff --git a/src/components/content/Layout/LayoutContent.tsx b/src/components/content/Layout/LayoutContent.tsx index 84c2e6bf0..434f3c772 100644 --- a/src/components/content/Layout/LayoutContent.tsx +++ b/src/components/content/Layout/LayoutContent.tsx @@ -1,4 +1,11 @@ -import { ForwardedRef, forwardRef, ReactNode, useMemo, useRef } from 'react'; +import { + ForwardedRef, + forwardRef, + HTMLAttributes, + ReactNode, + useMemo, + useRef, +} from 'react'; import { useHover } from 'react-aria'; import { @@ -35,7 +42,7 @@ const ContentElement = tasty({ boxSizing: 'content-box', border: { '': 0, - '!:last-child': '$layout-border-size solid #border bottom', + '!:last-child': '($layout-border-size, 1bw) solid #border bottom', }, Inner: { @@ -52,6 +59,8 @@ const ContentElement = tasty({ '': 'thin', 'scrollbar=tiny | scrollbar=none': 'none', }, + + '$layout-border-size': '0', }, // Custom scrollbar handles (when scrollbar="tiny") @@ -65,7 +74,7 @@ const ContentElement = tasty({ fill: '#dark.35', opacity: { '': 0, - '(hovered | focused | scrolling) & scrollbar=tiny': 1, + '(focused | scrolling) & scrollbar=tiny': 1, }, transition: 'opacity 0.15s', pointerEvents: 'none', @@ -81,7 +90,7 @@ const ContentElement = tasty({ fill: '#dark.35', opacity: { '': 0, - '(hovered | focused | scrolling) & scrollbar=tiny': 1, + '(focused | scrolling) & scrollbar=tiny': 1, }, transition: 'opacity 0.15s', pointerEvents: 'none', @@ -97,6 +106,10 @@ export interface CubeLayoutContentProps extends BaseProps, ContainerStyleProps { children?: ReactNode; /** Additional modifiers to apply */ mods?: Mods; + /** Ref for the inner content element */ + innerRef?: ForwardedRef; + /** Props to spread on the Inner sub-element */ + innerProps?: HTMLAttributes; } function LayoutContent( @@ -108,11 +121,14 @@ function LayoutContent( scrollbar = 'thin', styles, mods: externalMods, + innerRef: innerRefProp, + innerProps, ...otherProps } = props; const outerStyles = extractStyles(otherProps, OUTER_STYLES); const innerStyles = extractStyles(otherProps, INNER_STYLES); - const innerRef = useRef(null); + const internalInnerRef = useRef(null); + const combinedInnerRef = useCombinedRefs(innerRefProp, internalInnerRef); const combinedRef = useCombinedRefs(ref); const isTinyScrollbar = scrollbar === 'tiny'; const { hoverProps, isHovered } = useHover({}); @@ -123,7 +139,7 @@ function LayoutContent( hasOverflowY, hasOverflowX, isScrolling, - } = useTinyScrollbar(innerRef, isTinyScrollbar); + } = useTinyScrollbar(internalInnerRef, isTinyScrollbar); const scrollbarStyle = useMemo(() => { if (!isTinyScrollbar) return {}; @@ -162,7 +178,7 @@ function LayoutContent( styles={finalStyles} style={scrollbarStyle} > -
+
{children}
{isTinyScrollbar && hasOverflowY &&
} diff --git a/src/components/content/Layout/LayoutFooter.tsx b/src/components/content/Layout/LayoutFooter.tsx index 913d83f80..2eac298c3 100644 --- a/src/components/content/Layout/LayoutFooter.tsx +++ b/src/components/content/Layout/LayoutFooter.tsx @@ -15,6 +15,10 @@ const FooterElement = tasty(LayoutContent, { flexShrink: 0, flexGrow: 0, whiteSpace: 'nowrap', + border: { + '': 0, + '!:last-child': '($layout-border-size, 1bw) solid #border bottom', + }, Inner: { display: 'flex', diff --git a/src/components/content/Layout/LayoutPane.tsx b/src/components/content/Layout/LayoutPane.tsx index 70dc71efe..61bf70cc8 100644 --- a/src/components/content/Layout/LayoutPane.tsx +++ b/src/components/content/Layout/LayoutPane.tsx @@ -1,6 +1,7 @@ import { ForwardedRef, forwardRef, + HTMLAttributes, ReactNode, useCallback, useEffect, @@ -80,6 +81,8 @@ const PaneElement = tasty({ '': 'thin', 'scrollbar=tiny | scrollbar=none': 'none', }, + + '$layout-border-size': '0', }, // Custom scrollbar handles (when scrollbar="tiny") @@ -96,7 +99,7 @@ const PaneElement = tasty({ fill: '#dark.35', opacity: { '': 0, - '(hovered | focused | scrolling) & scrollbar=tiny': 1, + '(focused | scrolling) & scrollbar=tiny': 1, }, transition: 'opacity 0.15s', pointerEvents: 'none', @@ -115,7 +118,7 @@ const PaneElement = tasty({ fill: '#dark.35', opacity: { '': 0, - '(hovered | focused | scrolling) & scrollbar=tiny': 1, + '(focused | scrolling) & scrollbar=tiny': 1, }, transition: 'opacity 0.15s', pointerEvents: 'none', @@ -232,6 +235,10 @@ export interface CubeLayoutPaneProps extends BaseProps, ContainerStyleProps { /** Custom styles */ styles?: Styles; children?: ReactNode; + /** Ref for the inner content element */ + innerRef?: ForwardedRef; + /** Props to spread on the Inner sub-element */ + innerProps?: HTMLAttributes; } interface PaneResizeHandlerProps { @@ -303,11 +310,14 @@ function LayoutPane( styles, mods, children, + innerRef: innerRefProp, + innerProps, ...otherProps } = props; const combinedRef = useCombinedRefs(ref); - const innerRef = useRef(null); + const internalInnerRef = useRef(null); + const combinedInnerRef = useCombinedRefs(innerRefProp, internalInnerRef); const prevProvidedSizeRef = useRef(providedSize); const isHorizontal = resizeEdge === 'right'; @@ -340,7 +350,7 @@ function LayoutPane( hasOverflowY, hasOverflowX, isScrolling, - } = useTinyScrollbar(innerRef, isTinyScrollbar); + } = useTinyScrollbar(internalInnerRef, isTinyScrollbar); // Clamp size to min/max constraints const clampValue = useCallback( @@ -466,7 +476,7 @@ function LayoutPane( styles={finalStyles} style={paneStyle} > -
+
{children}
{isTinyScrollbar && hasOverflowY &&
} diff --git a/src/components/content/Layout/LayoutPanel.tsx b/src/components/content/Layout/LayoutPanel.tsx index 74b1bc28f..dc253c267 100644 --- a/src/components/content/Layout/LayoutPanel.tsx +++ b/src/components/content/Layout/LayoutPanel.tsx @@ -1,6 +1,7 @@ import { ForwardedRef, forwardRef, + HTMLAttributes, ReactNode, RefCallback, useCallback, @@ -285,7 +286,7 @@ interface ResizeHandlerProps { isDisabled?: boolean; mods?: Record; moveProps: ReturnType['moveProps']; - style?: Record; + style?: Record; onDoubleClick?: () => void; } @@ -552,9 +553,8 @@ function LayoutPanel( // Register panel with layout context // Include handler outside portion (minus border overlap) for proper content inset - // In sticky and dialog modes, panel doesn't push content, so size is 0 - const effectivePanelSize = - isOpen && mode === 'default' ? size : isOpen && isOverlayMode ? size : 0; + // In sticky, overlay, and dialog modes, panel doesn't push content, so size is 0 + const effectivePanelSize = isOpen && mode === 'default' ? size : 0; const effectiveInsetSize = Math.round( effectivePanelSize + (isResizable && effectivePanelSize > 0 ? RESIZABLE_INSET_OFFSET : 0), @@ -659,7 +659,12 @@ function LayoutPanel( () => ({ '--panel-size': `${size}px`, '--min-size': typeof minSize === 'number' ? `${minSize}px` : minSize, - '--max-size': typeof maxSize === 'number' ? `${maxSize}px` : maxSize, + '--max-size': + maxSize != null + ? typeof maxSize === 'number' + ? `${maxSize}px` + : maxSize + : undefined, }), [size, minSize, maxSize], ); diff --git a/src/components/content/Layout/LayoutPanelHeader.tsx b/src/components/content/Layout/LayoutPanelHeader.tsx index 64cbcdcd4..d268cbc60 100644 --- a/src/components/content/Layout/LayoutPanelHeader.tsx +++ b/src/components/content/Layout/LayoutPanelHeader.tsx @@ -10,28 +10,26 @@ import { } from '../../../tasty'; import { ItemAction } from '../../actions/ItemAction'; import { useDialogContext } from '../../overlays/Dialog/context'; -import { Item } from '../Item/Item'; +import { CubeItemProps, Item } from '../Item/Item'; import { useLayoutPanelContext } from './LayoutContext'; const PanelHeaderElement = tasty(Item, { qa: 'PanelHeader', shape: 'sharp', + type: 'header', styles: { border: 'bottom', - preset: { - '': 't3m', - 'size=xsmall': 't4', - 'size=xlarge': 't2m', - }, + boxSizing: 'content-box', '$inline-padding': '($content-padding, 1x)', }, }); export interface CubeLayoutPanelHeaderProps - extends BaseProps, - ContainerStyleProps { + extends Omit, + ContainerStyleProps, + CubeItemProps { /** Panel title */ title?: ReactNode; /** Title heading level (affects semantics, not visual) */ @@ -40,9 +38,6 @@ export interface CubeLayoutPanelHeaderProps isClosable?: boolean; /** Close button click handler */ onClose?: () => void; - /** Custom actions to display (overrides default close button) */ - actions?: ReactNode; - children?: ReactNode; } function LayoutPanelHeader( diff --git a/src/components/content/Layout/LayoutToolbar.tsx b/src/components/content/Layout/LayoutToolbar.tsx index 6e6335add..98c962c12 100644 --- a/src/components/content/Layout/LayoutToolbar.tsx +++ b/src/components/content/Layout/LayoutToolbar.tsx @@ -18,6 +18,7 @@ const ToolbarElement = tasty(LayoutContent, { placeContent: 'center space-between', placeItems: 'center stretch', gap: '1x', + padding: '0 ($content-padding, 1x)', }, }, }); diff --git a/src/components/content/Layout/hooks/useTinyScrollbar.ts b/src/components/content/Layout/hooks/useTinyScrollbar.ts index d38f72fd8..47500b89d 100644 --- a/src/components/content/Layout/hooks/useTinyScrollbar.ts +++ b/src/components/content/Layout/hooks/useTinyScrollbar.ts @@ -82,11 +82,8 @@ export function useTinyScrollbar( // Initial update updateScrollState(); - // Listen for scroll events - const handleScroll = () => { - updateScrollState(); - - // Show scrollbar on scroll + // Show scrollbar briefly and hide after delay + const showScrollbarBriefly = () => { setIsScrolling(true); // Clear existing timeout @@ -100,11 +97,24 @@ export function useTinyScrollbar( }, SCROLL_VISIBILITY_DURATION); }; + // Listen for scroll events + const handleScroll = () => { + updateScrollState(); + showScrollbarBriefly(); + }; + + // Show scrollbar briefly when mouse enters the container + const handleMouseEnter = () => { + showScrollbarBriefly(); + }; + element.addEventListener('scroll', handleScroll, { passive: true }); + element.addEventListener('mouseenter', handleMouseEnter); // ResizeObserver for content size changes const resizeObserver = new ResizeObserver(() => { updateScrollState(); + showScrollbarBriefly(); }); resizeObserver.observe(element); @@ -116,6 +126,7 @@ export function useTinyScrollbar( return () => { element.removeEventListener('scroll', handleScroll); + element.removeEventListener('mouseenter', handleMouseEnter); resizeObserver.disconnect(); if (scrollTimeoutRef.current) { diff --git a/src/components/content/Tag/Tag.tsx b/src/components/content/Tag/Tag.tsx index 23d663933..32cd3dfaf 100644 --- a/src/components/content/Tag/Tag.tsx +++ b/src/components/content/Tag/Tag.tsx @@ -1,6 +1,6 @@ import { forwardRef } from 'react'; +import { ItemVariant } from 'src/data/item-themes'; -import THEMES from '../../../data/themes'; import { CloseIcon } from '../../../icons'; import { Styles, tasty } from '../../../tasty'; import { CubeItemProps, Item } from '../Item'; @@ -9,30 +9,15 @@ const TagElement = tasty(Item, { qa: 'Tag', role: 'status', styles: { - fill: { - '': '#dark.04', - ...Object.keys(THEMES).reduce((map, type) => { - map[`type=${type}`] = THEMES[type].fill; - - return map; - }, {}), + padding: 0, + preset: { + '': 'tag', + 'size=xsmall': 't4', + 'size=small | size=medium': 't3', + 'size=large | size=xlarge': 't2', }, - color: { - '': '#dark.65', - ...Object.keys(THEMES).reduce((map, type) => { - map[`type=${type}`] = THEMES[type].color; - return map; - }, {}), - }, - border: { - '': true, - ...Object.keys(THEMES).reduce((map, type) => { - map[`type=${type}`] = THEMES[type].border; - - return map; - }, {}), - }, + '$min-inline-padding': '.5x', Label: { textAlign: 'center', @@ -42,9 +27,7 @@ const TagElement = tasty(Item, { }); export interface CubeTagProps extends CubeItemProps { - /* @deprecated Use theme instead */ - type?: keyof typeof THEMES | string; - theme?: keyof typeof THEMES | string; + theme?: 'default' | 'danger' | 'success' | 'note' | 'special'; isClosable?: boolean; onClose?: () => void; closeButtonStyles?: Styles; @@ -52,8 +35,7 @@ export interface CubeTagProps extends CubeItemProps { function Tag(allProps: CubeTagProps, ref) { let { - type, - theme, + theme = 'default', isClosable, onClose, closeButtonStyles, @@ -63,14 +45,19 @@ function Tag(allProps: CubeTagProps, ref) { ...props } = allProps; - const tagTheme = theme ?? type; + let type = 'card'; + + if (theme === 'special') { + theme = 'default'; + type = 'primary'; + } return ( > = ( Popular languages shown - diff --git a/src/components/fields/FilterListBox/FilterListBox.tsx b/src/components/fields/FilterListBox/FilterListBox.tsx index dfc2e7569..fe4be52c2 100644 --- a/src/components/fields/FilterListBox/FilterListBox.tsx +++ b/src/components/fields/FilterListBox/FilterListBox.tsx @@ -1,11 +1,14 @@ import { Key } from '@react-types/shared'; -import React, { +import { + cloneElement, ForwardedRef, forwardRef, + isValidElement, ReactElement, ReactNode, RefObject, useCallback, + useEffect, useLayoutEffect, useMemo, useRef, @@ -288,10 +291,10 @@ export const FilterListBox = forwardRef(function FilterListBox< // the index. This mirrors React Aria examples where the render function // is expected to set keys, but we add a fallback for robustness. if ( - React.isValidElement(rendered) && + isValidElement(rendered) && (rendered as ReactElement).key == null ) { - return React.cloneElement(rendered as ReactElement, { + return cloneElement(rendered as ReactElement, { key: (rendered as any)?.key ?? item?.key ?? idx, }); } @@ -329,7 +332,7 @@ export const FilterListBox = forwardRef(function FilterListBox< const [customKeys, setCustomKeys] = useState>(new Set()); // Initialize custom keys from current selection - React.useEffect(() => { + useEffect(() => { if (!allowsCustomValue) return; const currentSelectedKeys = selectedKeys @@ -880,8 +883,7 @@ export const FilterListBox = forwardRef(function FilterListBox< : undefined } onChange={(e) => { - const value = e.target.value; - handleSearchChange(value); + handleSearchChange(e.target.value); }} {...keyboardProps} {...modAttrs(mods)} diff --git a/src/components/fields/FilterPicker/FilterPicker.stories.tsx b/src/components/fields/FilterPicker/FilterPicker.stories.tsx index 35e4bd631..d739513a7 100644 --- a/src/components/fields/FilterPicker/FilterPicker.stories.tsx +++ b/src/components/fields/FilterPicker/FilterPicker.stories.tsx @@ -973,7 +973,7 @@ export const WithHeaderAndFooter: Story = { Popular languages shown - @@ -1744,7 +1744,6 @@ export const CustomInputComponent: Story = { handleTagRemove(option.key)} > {option.label} diff --git a/src/components/fields/FilterPicker/FilterPicker.tsx b/src/components/fields/FilterPicker/FilterPicker.tsx index e495b89ec..e6689bb12 100644 --- a/src/components/fields/FilterPicker/FilterPicker.tsx +++ b/src/components/fields/FilterPicker/FilterPicker.tsx @@ -66,7 +66,14 @@ export interface CubeFilterPickerProps Omit, Pick< CubeItemButtonProps, - 'type' | 'theme' | 'icon' | 'rightIcon' | 'prefix' | 'suffix' | 'hotkeys' + | 'type' + | 'theme' + | 'icon' + | 'rightIcon' + | 'prefix' + | 'suffix' + | 'hotkeys' + | 'shape' > { /** Placeholder text when no selection is made */ placeholder?: string; @@ -207,6 +214,7 @@ export const FilterPicker = forwardRef(function FilterPicker( popoverStyles, type = 'outline', theme = 'default', + shape, labelSuffix, shouldFocusWrap, children, @@ -255,7 +263,6 @@ export const FilterPicker = forwardRef(function FilterPicker( onSearchChange, sortSelectedToTop: sortSelectedToTopProp, onOpenChange, - isButton = false, form, ...otherProps } = props; @@ -727,12 +734,12 @@ export const FilterPicker = forwardRef(function FilterPicker( } mods={{ up: props.direction === 'up', down: props.direction === 'down', }} label={`Step ${props.direction}`} {...props} - /> + > + + ); } diff --git a/src/components/fields/Picker/Picker.tsx b/src/components/fields/Picker/Picker.tsx index a64b9ad3e..b133dea3b 100644 --- a/src/components/fields/Picker/Picker.tsx +++ b/src/components/fields/Picker/Picker.tsx @@ -52,7 +52,14 @@ export interface CubePickerProps Omit, Pick< CubeItemButtonProps, - 'type' | 'theme' | 'icon' | 'rightIcon' | 'prefix' | 'suffix' | 'hotkeys' + | 'type' + | 'theme' + | 'icon' + | 'rightIcon' + | 'prefix' + | 'suffix' + | 'hotkeys' + | 'shape' > { /** Placeholder text when no selection is made */ placeholder?: string; @@ -193,6 +200,7 @@ export const Picker = forwardRef(function Picker( popoverStyles, type = 'outline', theme = 'default', + shape, labelSuffix, shouldFocusWrap, children, @@ -231,7 +239,6 @@ export const Picker = forwardRef(function Picker( onClear, sortSelectedToTop, onOpenChange, - isButton = false, listStateRef: externalListStateRef, ...otherProps } = props; @@ -584,12 +591,12 @@ export const Picker = forwardRef(function Picker( containerPadding?: number; inputProps?: Props; type?: 'outline' | 'clear' | 'primary' | (string & {}); + /** + * Shape of the trigger's border radius. + * - `card` - Card shape with larger border radius (`1cr`) + * - `button` - Button shape with default border radius (default) + * - `sharp` - Sharp corners with no border radius (`0`) + * - `pill` - Pill shape with fully rounded ends (`round`) + * @default "button" + */ + shape?: 'card' | 'button' | 'sharp' | 'pill'; suffixPosition?: 'before' | 'after'; theme?: 'default' | 'special'; /** Whether the select is clearable using a clear button in the rightIcon slot */ isClearable?: boolean; /** Callback called when the clear button is pressed */ onClear?: () => void; - /** Whether the trigger should use button styling - * @default false - */ - isButton?: boolean; /** Callback called when the popover open state changes */ onOpenChange?: (isOpen: boolean) => void; } @@ -281,6 +286,7 @@ function Select( placeholder, tooltip, size = 'medium', + shape, styles, type = 'outline', theme = 'default', @@ -288,7 +294,6 @@ function Select( suffixPosition = 'before', isClearable, onOpenChange, - isButton = false, form, ...otherProps } = props; @@ -443,6 +448,7 @@ function Select( styles={{ ...inputStyles, ...triggerStyles }} theme={theme} size={size} + shape={shape} // Ensure this button never submits a surrounding form in tests or runtime htmlType="button" // Preserve visual variant via data attribute instead of conflicting with HTML attribute @@ -472,7 +478,6 @@ function Select( descriptionPlacement={descriptionPlacement} hotkeys={hotkeys} tooltip={tooltip} - isButton={isButton} labelProps={valueProps} > {state.selectedItem ? ( diff --git a/src/components/form/FieldWrapper/FieldWrapper.stories.tsx b/src/components/form/FieldWrapper/FieldWrapper.stories.tsx index f0498635c..0171497db 100644 --- a/src/components/form/FieldWrapper/FieldWrapper.stories.tsx +++ b/src/components/form/FieldWrapper/FieldWrapper.stories.tsx @@ -116,14 +116,7 @@ WithButtonSuffix.args = { export const WithButtonSuffixAndTooltip = Template.bind({}); WithButtonSuffixAndTooltip.args = { labelSuffix: ( - + + + + {({ phase, ref: transitionRef }) => ( + + Preserve Content Demo +
+ Content: {content} +
+ Phase: {phase} +
+ + {args.preserveContent + ? 'Content preserved during exit' + : 'Content updates during exit'} + +
+ )} +
+ + ); + }, + args: { + duration: 500, + preserveContent: true, + }, +}; diff --git a/src/components/helpers/DisplayTransition/DisplayTransition.test.tsx b/src/components/helpers/DisplayTransition/DisplayTransition.test.tsx index 84d8fa7cb..84f2d8911 100644 --- a/src/components/helpers/DisplayTransition/DisplayTransition.test.tsx +++ b/src/components/helpers/DisplayTransition/DisplayTransition.test.tsx @@ -415,4 +415,105 @@ describe('DisplayTransition', () => { expect(onRest).toHaveBeenCalledWith('exit'); expect(onRest).not.toHaveBeenCalledWith('enter'); }); + + it('should preserve children content during exit when preserveContent=true (default)', () => { + // This test verifies the fix for a bug where children would disappear instantly + // during exit when the parent conditionally rendered children based on isShown + + interface TestWrapperProps { + isShown: boolean; + content: string; + } + + function TestWrapper({ isShown, content }: TestWrapperProps) { + return ( + + {({ phase, isShown: isShownNow, ref }) => ( +
+ {/* Simulate parent conditionally rendering content based on its own state */} + {isShown ? content : null} +
+ )} +
+ ); + } + + const { container, rerender } = render( + , + ); + + // Initial: entered with content + expect( + container.querySelector('[data-phase="entered"]'), + ).toBeInTheDocument(); + expect(container.textContent).toContain('original content'); + + // Trigger exit - parent passes isShown=false and content becomes null + rerender(); + + // Immediately after rerender, content should still be preserved + // (stored children from when isShown was true) + // Phase is still 'entered' (exit-pending internally, but reported as 'entered') + expect( + container.querySelector('[data-phase="entered"]'), + ).toBeInTheDocument(); + expect(container.textContent).toContain('original content'); + + // Advance through the entire exit flow + act(() => { + jest.advanceTimersByTime(200); + }); + + // After completing the exit transition, should reach unmounted + // Content should have been preserved throughout the exit animation + expect( + container.querySelector('[data-phase="unmounted"]'), + ).toBeInTheDocument(); + }); + + it('should not preserve children content during exit when preserveContent=false', () => { + interface TestWrapperProps { + isShown: boolean; + content: string; + } + + function TestWrapper({ isShown, content }: TestWrapperProps) { + return ( + + {({ phase, isShown: isShownNow, ref }) => ( +
+ {isShown ? content : null} +
+ )} +
+ ); + } + + const { container, rerender } = render( + , + ); + + // Initial: entered with content + expect( + container.querySelector('[data-phase="entered"]'), + ).toBeInTheDocument(); + expect(container.textContent).toContain('original content'); + + // Trigger exit - content immediately becomes null because preserveContent=false + rerender(); + + // Content should be gone immediately since preserveContent=false + expect(container.textContent).not.toContain('original content'); + }); }); diff --git a/src/components/helpers/DisplayTransition/DisplayTransition.tsx b/src/components/helpers/DisplayTransition/DisplayTransition.tsx index 7d6c2e739..69923a7dd 100644 --- a/src/components/helpers/DisplayTransition/DisplayTransition.tsx +++ b/src/components/helpers/DisplayTransition/DisplayTransition.tsx @@ -30,6 +30,8 @@ export type DisplayTransitionProps = { animateOnMount?: boolean; /** Respect prefers-reduced-motion by collapsing duration to 0. */ respectReducedMotion?: boolean; + /** Preserve children content during exit transition. When true, uses stored children from when content was visible. @default true */ + preserveContent?: boolean; /** Render-prop gets { phase, isShown, ref }. Bind ref to the transitioned element for native event detection. */ children: (props: { phase: ReportedPhase; @@ -58,6 +60,7 @@ export function DisplayTransition({ exposeUnmounted = false, animateOnMount = true, respectReducedMotion = true, + preserveContent = true, children, }: DisplayTransitionProps) { // Reduced motion → collapse timing @@ -67,6 +70,9 @@ export function DisplayTransition({ window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches; const dur = prefersReduced ? 0 : duration; + // Store children to preserve content during exit transitions + const storedChildrenRef = useRef(children); + // For native transition event detection const elementRef = useRef(null); const transitionStartedRef = useRef(false); @@ -167,18 +173,24 @@ export function DisplayTransition({ return; } - const onTransitionStart = () => { + const onTransitionStart = (e: TransitionEvent) => { + // Ignore bubbled events from children - only react to our own element's transitions + if (e.target !== element) return; if (flowRef.current !== flow) return; transitionStartedRef.current = true; clearTimer(); // Cancel fallback timer once transition starts }; - const onTransitionEnd = () => { + const onTransitionEnd = (e: TransitionEvent) => { + // Ignore bubbled events from children - only react to our own element's transitions + if (e.target !== element) return; if (flowRef.current !== flow) return; complete(); }; - const onTransitionCancel = () => { + const onTransitionCancel = (e: TransitionEvent) => { + // Ignore bubbled events from children - only react to our own element's transitions + if (e.target !== element) return; if (flowRef.current !== flow) return; complete(); }; @@ -324,7 +336,9 @@ export function DisplayTransition({ }, [isShownNow, onToggleEvent]); // Ref callback to attach to transitioned element - const refCallback: RefCallback = (node) => { + // MUST be memoized so React doesn't re-call it on re-renders, + // which would cleanup event listeners mid-transition + const refCallback: RefCallback = useCallback((node) => { if (node) { elementRef.current = node; // Don't call ensureEnterFlow() here - useLayoutEffect handles RAF scheduling @@ -333,10 +347,26 @@ export function DisplayTransition({ cleanupEventListeners(); elementRef.current = null; } - }; + }, []); + + // Update stored children only when showing (enter/entered phase and targetShown is true) + // This prevents overwriting during exit transitions, preserving content for the animation + const isShowingContent = + (phase === 'enter' || phase === 'entered') && targetShown; + + if (isShowingContent) { + storedChildrenRef.current = children; + } + + // When preserveContent is enabled, always use stored children: + // - During show: stored is updated above, so it equals current children + // - During hide: stored keeps the last shown content for the exit animation + const effectiveChildren = preserveContent + ? storedChildrenRef.current + : children; if (phase === 'unmounted' && !exposeUnmounted) return null; - return children({ + return effectiveChildren({ phase: reportedPhase === 'enter' && duration !== undefined && !duration ? 'entered' diff --git a/src/components/helpers/DisplayTransition/index.ts b/src/components/helpers/DisplayTransition/index.ts new file mode 100644 index 000000000..ae46e1286 --- /dev/null +++ b/src/components/helpers/DisplayTransition/index.ts @@ -0,0 +1,2 @@ +export * from './DisplayTransition'; +export type { DisplayTransitionProps } from './DisplayTransition'; diff --git a/src/components/helpers/IconSwitch/IconSwitch.docs.mdx b/src/components/helpers/IconSwitch/IconSwitch.docs.mdx new file mode 100644 index 000000000..8431779b5 --- /dev/null +++ b/src/components/helpers/IconSwitch/IconSwitch.docs.mdx @@ -0,0 +1,173 @@ +import { Meta, Canvas, Story, Controls } from '@storybook/addon-docs/blocks'; +import * as IconSwitchStories from './IconSwitch.stories'; + + + +# IconSwitch + +A helper component that cross-fades between icons when children change. It provides smooth animated transitions between different icon states, making UI interactions feel more polished. + +## When to Use + +- When you need animated transitions between different icons based on state +- For toggle buttons that switch between two icons (e.g., play/pause, expand/collapse) +- When building interactive UI elements where icon changes should be visually smooth +- In components like `Item` or `ItemButton` where icons change based on modifiers + +## Component + + + +--- + +### Properties + + + +### Base Properties + +Supports [Base properties](/BaseProperties) + +## Examples + +### Nullish transitions (show / hide) + +`IconSwitch` preserves the last visible icon during the fade-out when switching to `null`/`undefined`/`false`, so you get a smooth “icon → empty” transition. + + + +### Basic Toggle + +Use `contentKey` to explicitly identify when the icon should transition: + +```jsx +import { IconSwitch } from '@cube-dev/ui-kit'; +import { DownIcon, UpIcon } from '@cube-dev/ui-kit/icons'; + +function ExpandToggle({ isExpanded }) { + return ( + + {isExpanded ? : } + + ); +} +``` + +### Play/Pause Button + + + +```jsx +import { IconSwitch } from '@cube-dev/ui-kit'; +import { PlayIcon, PauseIcon } from '@cube-dev/ui-kit/icons'; + +function MediaControl({ isPlaying }) { + return ( + + {isPlaying ? : } + + ); +} +``` + +### Multiple States + + + +The component handles multiple state transitions seamlessly: + +```jsx +import { IconSwitch } from '@cube-dev/ui-kit'; +import { DownIcon, CheckIcon, CloseIcon } from '@cube-dev/ui-kit/icons'; + +function StatusIcon({ status }) { + const icons = { + idle: , + success: , + error: , + }; + + return ( + + {icons[status]} + + ); +} +``` + +### Rapid Toggling + + + +The component handles rapid state changes gracefully, properly managing overlapping transitions. + +### With Dynamic Icons in Item Component + +```jsx +import { Item, IconSwitch } from '@cube-dev/ui-kit'; +import { DownIcon, UpIcon } from '@cube-dev/ui-kit/icons'; + +function ExpandableItem({ isExpanded, onToggle }) { + return ( + + {isExpanded ? : } + + } + onPress={onToggle} + > + {isExpanded ? 'Collapse' : 'Expand'} + + ); +} +``` + +## How It Works + +1. **Change Detection**: The component tracks icon changes using `contentKey` (if provided) or a best-effort derived key from the child element type (so prop-only updates don’t trigger transitions) +2. **Stack Management**: When content changes, both old and new icons are rendered simultaneously +3. **Cross-fade Animation**: The old icon fades out while the new icon fades in using CSS opacity transitions +4. **Cleanup**: After the exit transition completes, the old icon is removed from the DOM + +## Best Practices + +1. **Prefer `contentKey`**: The component can derive a key from the child element type, but explicit keys are more reliable (especially for custom “same type, different meaning” icons): + ```jsx + // Good - explicit key + + {isOpen ? : } + + + // Avoid - relies on implicit key derivation + + {isOpen ? : } + + ``` + +2. **Use consistent icon sizes**: Ensure all icons within the same IconSwitch have the same dimensions for smooth transitions + +3. **Keep transitions subtle**: The default `transition: 'theme'` timing is designed to be quick and non-distracting + +## Accessibility + +### Screen Reader Support + +- Icon transitions are purely visual and don't affect screen reader announcements +- Ensure the parent component provides appropriate `aria-label` or text content to convey state changes + +### Reduced Motion + +The component respects the user's `prefers-reduced-motion` preference through the underlying `DisplayTransition` component. + +## Suggested Improvements + +- Add `duration` prop to customize transition timing +- Support custom transition styles beyond opacity +- Add `onTransitionStart` and `onTransitionEnd` callbacks + +## Related Components + +- [DisplayTransition](/helpers/DisplayTransition) - Lower-level transition management +- [Item](/content/Item) - Uses dynamic icons that could benefit from IconSwitch +- [ItemButton](/actions/ItemButton) - Button variant with icon switching capability diff --git a/src/components/helpers/IconSwitch/IconSwitch.stories.tsx b/src/components/helpers/IconSwitch/IconSwitch.stories.tsx new file mode 100644 index 000000000..70c830575 --- /dev/null +++ b/src/components/helpers/IconSwitch/IconSwitch.stories.tsx @@ -0,0 +1,235 @@ +import { useState } from 'react'; + +import { CheckIcon } from '../../../icons/CheckIcon'; +import { CloseIcon } from '../../../icons/CloseIcon'; +import { DownIcon } from '../../../icons/DownIcon'; +import { PauseIcon } from '../../../icons/PauseIcon'; +import { PlayIcon } from '../../../icons/PlayIcon'; +import { UpIcon } from '../../../icons/UpIcon'; +import { tasty } from '../../../tasty'; +import { Button } from '../../actions/Button'; + +import { IconSwitch } from './IconSwitch'; + +import type { Meta, StoryObj } from '@storybook/react-vite'; + +const meta = { + title: 'Helpers/IconSwitch', + component: IconSwitch, + argTypes: { + contentKey: { + control: 'text', + description: 'Override key for detecting icon changes', + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const Container = tasty({ + styles: { + display: 'flex', + flow: 'column', + gap: '2x', + placeItems: 'center', + }, +}); + +const IconContainer = tasty({ + styles: { + display: 'flex', + placeItems: 'center', + gap: '2x', + }, +}); + +const IconBox = tasty({ + styles: { + display: 'grid', + placeItems: 'center', + width: '6x', + height: '6x', + fill: '#dark.04', + radius: true, + preset: 't2', + }, +}); + +export const Default: Story = { + render: () => { + const [isExpanded, setIsExpanded] = useState(false); + + return ( + + + + + Current state: {isExpanded ? 'Expanded' : 'Collapsed'} + + + {isExpanded ? : } + + + + + ); + }, +}; + +export const PlayPause: Story = { + render: () => { + const [isPlaying, setIsPlaying] = useState(false); + + return ( + + + + + Status: {isPlaying ? 'Playing' : 'Paused'} + + + {isPlaying ? : } + + + + + ); + }, +}; + +export const MultipleStates: Story = { + render: () => { + const [state, setState] = useState<'idle' | 'success' | 'error'>('idle'); + + const icons = { + idle: , + success: , + error: , + }; + + return ( + + + + + + + + + Current state: {state} + + {icons[state]} + + + + ); + }, +}; + +export const RapidToggle: Story = { + render: () => { + const [count, setCount] = useState(0); + const icons = [, , , ]; + + return ( + + + + + Count: {count} + + {icons[count % 4]} + + + + ); + }, +}; + +export const Nullish: Story = { + render: () => { + const [shown, setShown] = useState(true); + + return ( + + + + + Icon is {shown ? 'shown' : 'hidden'} + + {/* Intentionally constant key: IconSwitch should still animate icon ↔ nullish */} + + {shown ? : null} + + + + + ); + }, +}; + +// Mimics Button's exact usage pattern with noWrapper and changing contentKey +const IconSlotContainer = tasty({ + styles: { + display: 'grid', + placeItems: 'center', + width: '6x', + height: '6x', + fill: '#dark.04', + radius: true, + }, +}); + +export const NoWrapperWithKeyChange: Story = { + render: () => { + const [isLoading, setIsLoading] = useState(false); + + // Mimics Button's iconKey derivation + const iconKey = isLoading ? 'loading' : 'empty'; + const icon = undefined; // No icon prop, like in Button story + + return ( + + + + + + isLoading: {String(isLoading)}, iconKey: {iconKey} + + + {/* Exact same usage as Button: noWrapper + contentKey changes */} + + {isLoading ? : icon} + + + + + ); + }, +}; diff --git a/src/components/helpers/IconSwitch/IconSwitch.tsx b/src/components/helpers/IconSwitch/IconSwitch.tsx new file mode 100644 index 000000000..3af6b2444 --- /dev/null +++ b/src/components/helpers/IconSwitch/IconSwitch.tsx @@ -0,0 +1,189 @@ +import { + isValidElement, + ReactNode, + useCallback, + useLayoutEffect, + useRef, + useState, +} from 'react'; + +import { BaseProps, tasty } from '../../../tasty'; +import { DisplayTransition } from '../DisplayTransition/DisplayTransition'; + +export interface CubeIconSwitchProps extends BaseProps { + /** The icon to display */ + children: ReactNode; + /** Override the default key derivation for detecting icon changes */ + contentKey?: string | number; + /** When true, renders without wrapper element, expecting parent to provide grid context */ + noWrapper?: boolean; +} + +interface IconEntry { + key: number; + content: ReactNode; +} + +function isNullishContent(node: ReactNode) { + // React treats `null`, `undefined`, and `false` as "render nothing" + return node == null || node === false; +} + +const typeIds = new WeakMap(); +let lastTypeId = 0; + +function getTypeId(type: object) { + const existing = typeIds.get(type); + if (existing != null) return existing; + const next = (lastTypeId += 1); + typeIds.set(type, next); + return next; +} + +/** + * Best-effort key derivation for icon switching when `contentKey` is not provided. + * The goal is to: + * - Transition when icon component type changes + * - Not transition when only props change for the same icon type (self-animating icons) + * - Transition when toggling to/from nullish content + */ +function deriveContentKey(children: ReactNode): string { + if (isNullishContent(children)) return 'nullish'; + if (!isValidElement(children)) return `non-element:${typeof children}`; + + const { type, key } = children; + const keyPart = key == null ? '' : `:${String(key)}`; + + if (typeof type === 'string') return `host:${type}${keyPart}`; + if (typeof type === 'function') return `fn:${getTypeId(type)}${keyPart}`; + if (typeof type === 'object' && type != null) + return `obj:${getTypeId(type)}${keyPart}`; + if (typeof type === 'symbol') return `sym:${String(type)}${keyPart}`; + + return `unknown${keyPart}`; +} + +const IconSwitchElement = tasty({ + styles: { + display: 'grid', + placeItems: 'center', + }, +}); + +const IconSlotElement = tasty({ + styles: { + gridArea: '1 / 1', + display: 'grid', + placeItems: 'center', + opacity: { + '': 0, + entered: 1, + }, + transition: { + '': 'theme $transition ease-out', + 'exit | entered': 'theme $transition ease-in', + }, + }, +}); + +/** + * A component that cross-fades between icons when children change. + * Useful for animated icon transitions in buttons, items, etc. + * + * When `contentKey` changes, a transition is triggered. + * When only `children` changes (same `contentKey`), content is updated in-place + * without transition, allowing self-animating icons (like DirectionIcon) to work. + */ +export function IconSwitch(props: CubeIconSwitchProps) { + const { children, contentKey, noWrapper, ...rest } = props; + + const keyCounterRef = useRef(0); + const effectiveKey = contentKey ?? deriveContentKey(children); + const prevEffectiveKeyRef = useRef(effectiveKey); + const prevChildrenRef = useRef(children); + + const [icons, setIcons] = useState(() => [ + { key: keyCounterRef.current, content: children }, + ]); + + useLayoutEffect(() => { + const prevKey = prevEffectiveKeyRef.current; + const prevChildren = prevChildrenRef.current; + + const hasKeyChanged = effectiveKey !== prevKey; + const hasChildrenChanged = children !== prevChildren; + const hasNullishToggled = + isNullishContent(children) !== isNullishContent(prevChildren); + + // Transition rules: + // - If key changed -> transition + // - If we toggled nullish <-> non-nullish -> transition (even if key did not change) + // - Otherwise, if only children changed -> update in place (no transition) + if (hasKeyChanged || hasNullishToggled) { + keyCounterRef.current += 1; + const newEntry: IconEntry = { + key: keyCounterRef.current, + content: children, + }; + setIcons((prev) => [...prev, newEntry]); + } else if (hasChildrenChanged) { + // Same key, different children -> update in-place -> no transition + // This allows self-animating icons to receive updated props + setIcons((prev) => { + if (!prev.length) { + return [{ key: 0, content: children }]; + } + const lastIndex = prev.length - 1; + const next = [...prev]; + next[lastIndex] = { ...next[lastIndex], content: children }; + return next; + }); + } + + prevEffectiveKeyRef.current = effectiveKey; + prevChildrenRef.current = children; + }, [children, effectiveKey]); + + const handleExitComplete = useCallback((exitedKey: number) => { + setIcons((prev) => prev.filter((icon) => icon.key !== exitedKey)); + }, []); + + const latestKey = icons[icons.length - 1]?.key; + + const content = icons.map((icon) => { + const isActive = icon.key === latestKey; + + return ( + { + if (transition === 'exit') { + handleExitComplete(icon.key); + } + }} + > + {({ isShown, phase, ref }) => ( + + {icon.content} + + )} + + ); + }); + + if (noWrapper) { + return <>{content}; + } + + return {content}; +} + +export type { CubeIconSwitchProps as IconSwitchProps }; diff --git a/src/components/helpers/index.ts b/src/components/helpers/index.ts index 07f0adf1a..c4898bdbc 100644 --- a/src/components/helpers/index.ts +++ b/src/components/helpers/index.ts @@ -1 +1,2 @@ export * from './DisplayTransition/DisplayTransition'; +export * from './IconSwitch/IconSwitch'; diff --git a/src/components/layout/ResizablePanel.tsx b/src/components/layout/ResizablePanel.tsx index 3a3f4c5bb..dd6a02710 100644 --- a/src/components/layout/ResizablePanel.tsx +++ b/src/components/layout/ResizablePanel.tsx @@ -379,7 +379,7 @@ function ResizablePanel( }, innerStyles: { // The panel inner space compensation for the handler - margin: `@indent-compensation ${direction}`, + margin: `$indent-compensation ${direction}`, }, })} /> diff --git a/src/components/overlays/Dialog/Dialog.tsx b/src/components/overlays/Dialog/Dialog.tsx index 390811e6c..395030014 100644 --- a/src/components/overlays/Dialog/Dialog.tsx +++ b/src/components/overlays/Dialog/Dialog.tsx @@ -48,16 +48,16 @@ const DialogElement = tasty({ 'type=panel': 'absolute', }, width: { - '': '$min-dialog-size $dialog-size 90vw', - 'type=fullscreen': '90vw 90vw', - 'type=fullscreenTakeover': '100vw 100vw', + '': '$min-dialog-size $dialog-size (100dvw - 8x)', + 'type=fullscreen': '(100dvw - 8x) (100dvw - 8x)', + 'type=fullscreenTakeover': '100dvw 100dvw', 'type=panel': 'auto', }, height: { - '': 'auto 90vh', - 'type=fullscreen': '90vh 90vh', - 'type=fullscreenTakeover | type=panel': '100vh 100vh', - 'type=popover': 'initial initial (50vh - 5x)', + '': 'auto (100dvh - 8x)', + 'type=fullscreen': '(100dvh - 8x) (100dvh - 8x)', + 'type=fullscreenTakeover | type=panel': '100dvh 100dvh', + 'type=popover': 'initial initial (50dvh - 5x)', }, gap: 0, border: { diff --git a/src/components/overlays/Modal/Modal.tsx b/src/components/overlays/Modal/Modal.tsx index f8bbc979a..2a8d39a84 100644 --- a/src/components/overlays/Modal/Modal.tsx +++ b/src/components/overlays/Modal/Modal.tsx @@ -47,11 +47,11 @@ const ModalElement = tasty({ height: { '': 'max 90dvh', 'type=fullscreenTakeover | type=panel': '100dvh 100dvh', - 'type=fullscreen': '90dvh 90dvh', + 'type=fullscreen': '(100dvh - 8x) (100dvh - 8x)', 'type=panel': 'auto', }, width: { - '': '$min-dialog-size 90vw', + '': '$min-dialog-size (100dvw - 8x)', 'type=panel': 'auto', }, pointerEvents: 'none', diff --git a/src/data/item-themes.ts b/src/data/item-themes.ts index ccf6fc918..bedcfc288 100644 --- a/src/data/item-themes.ts +++ b/src/data/item-themes.ts @@ -590,6 +590,33 @@ export const SPECIAL_ITEM_STYLES: Styles = { }, } as const; +// ---------- CARD TYPE STYLES ---------- +// Card type only supports: default, success, danger, note themes + +export const DEFAULT_CARD_STYLES: Styles = { + border: '#dark.20', + fill: '#light', + color: '#dark-02', +} as const; + +export const SUCCESS_CARD_STYLES: Styles = { + border: '#success.20', + fill: '#success-bg', + color: '#success-text', +} as const; + +export const DANGER_CARD_STYLES: Styles = { + border: '#danger.20', + fill: '#danger-bg', + color: '#danger-text', +} as const; + +export const NOTE_CARD_STYLES: Styles = { + border: '#note.20', + fill: '#note-bg', + color: '#note-text', +} as const; + export type ItemVariant = | 'default.primary' | 'default.secondary' @@ -598,6 +625,7 @@ export type ItemVariant = | 'default.clear' | 'default.link' | 'default.item' + | 'default.card' | 'danger.primary' | 'danger.secondary' | 'danger.outline' @@ -605,6 +633,7 @@ export type ItemVariant = | 'danger.clear' | 'danger.link' | 'danger.item' + | 'danger.card' | 'success.primary' | 'success.secondary' | 'success.outline' @@ -612,10 +641,12 @@ export type ItemVariant = | 'success.clear' | 'success.link' | 'success.item' + | 'success.card' | 'special.primary' | 'special.secondary' | 'special.outline' | 'special.neutral' | 'special.clear' | 'special.link' - | 'special.item'; + | 'special.item' + | 'note.card'; diff --git a/src/stories/Tasty.docs.mdx b/src/stories/Tasty.docs.mdx index 0b68a545a..db1cd86cf 100644 --- a/src/stories/Tasty.docs.mdx +++ b/src/stories/Tasty.docs.mdx @@ -153,6 +153,15 @@ Property controlling combinator for sub-element selectors. Default: descendant ( Row: { $: '>', padding: '1x' } // Direct child selector ``` +### Tokens + +CSS custom properties defined via `tokens` prop, rendered as inline styles. Supports `$name` (regular properties) and `#name` (color properties with RGB variant). + +```jsx +tokens={{ $spacing: '2x', '#primary': '#purple' }} +// → style="--spacing: calc(2 * var(--gap)); --accent-color: var(--purple-color); --accent-color-rgb: var(--purple-color-rgb)" +``` + --- ## 🎯 Core Concepts @@ -853,6 +862,120 @@ const ThemedComponent = tasty({ // Fallback chains maintain RGB variants: var(--brand-color-rgb, var(--purple-color-rgb)) ``` +### Tokens Prop - Dynamic CSS Custom Properties + +The `tokens` prop allows you to set CSS custom properties as inline styles with design token processing. Unlike `styles` which generates CSS classes, `tokens` are rendered directly as inline styles. + +```jsx +// Define component with default tokens +const Card = tasty({ + tokens: { $spacing: '2x', $size: '10x' }, + styles: { + padding: '$spacing', + width: '$size', + }, +}); + +// Usage - tokens become inline CSS custom properties + +// Renders:
+ +// Override tokens at usage + +// Renders:
+ +// Add new tokens + +// Renders:
+``` + +#### Token Types + +| Prefix | Output | Example | +|--------|--------|---------| +| `$name` | `--name` | `$spacing: '2x'` → `--spacing: calc(2 * var(--gap))` | +| `#name` | `--name-color` + `--name-color-rgb` | `#accent: '#purple'` → `--accent-color: var(--purple-color)` | + +#### Key Differences from `styles` + +| Feature | `styles` | `tokens` | +|---------|----------|----------| +| Output | CSS classes | Inline styles | +| State mapping | ✅ Supported | ❌ Not supported | +| Responsive arrays | ✅ Supported | ❌ Not supported | +| Performance | Cached & deduplicated | Per-instance | +| Use case | Static styling | Dynamic values | + +#### Merging Priority + +Tokens are merged in order (later overrides earlier): +1. Default tokens (from `tasty({ tokens: {...} })`) +2. Instance tokens (from ``) +3. Instance `style` prop (from ``) + +```jsx +const Card = tasty({ + tokens: { $spacing: '1x' }, +}); + +// Instance tokens override defaults + + +// style prop overrides everything (not recommended - see warning below) + +// --spacing will be '100px' +``` + +> **⚠️ Warning:** Using the `style` prop directly is **not recommended** for general styling. It should only be used in rare cases when you need to spread raw styles from a third-party library to an element. For all other cases, use `tokens` or `styles` props instead. + +#### Valid Token Values + +- **String** - Processed through tasty parser (`'2x'` → `calc(2 * var(--gap))`) +- **Number** - Converted to string (`42` → `'42'`); `0` stays as `'0'` +- **undefined/null** - Silently skipped (no CSS property output) + +```jsx +// ✅ Valid +tokens={{ $spacing: '2x', $size: 100, $zero: 0 }} + +// ❌ Invalid - object values not allowed (no state mapping) +tokens={{ $spacing: { '': '1x', hovered: '2x' } }} +// Will log warning and skip this token +``` + +#### Use Cases + +```jsx +// Dynamic theming +const ThemedCard = tasty({ + tokens: { '#card-bg': '#surface', '#card-border': '#border' }, + styles: { + fill: '#card-bg', + border: '1bw solid #card-border', + }, +}); + +// Override theme per-instance + + +// Dynamic sizing based on props +function DynamicComponent({ columns }) { + return ; +} + +// Responsive values via parent CSS +const Parent = tasty({ + styles: { + '--child-spacing': ['4x', '2x', '1x'], // Responsive in styles + }, +}); +const Child = tasty({ + styles: { + padding: '$child-spacing', // References parent's responsive property + }, +}); +``` + ## ✅ Best Practices & Anti-patterns ### Do's ✅ @@ -952,7 +1075,10 @@ styles: { // ❌ Don't change styles prop at runtime (performance) const [dynamicStyles, setDynamicStyles] = useState({}); - // Use style prop for dynamic values + // Use tokens prop for dynamic values + +// ❌ Don't use style prop for custom styling + // Only for spreading third-party library styles ``` #### Tasty vs Native CSS Properties @@ -981,12 +1107,16 @@ styles: { ### Performance Tips ⚡ ```jsx -// ✅ Use native style prop for dynamic values +// ✅ Use tokens prop for dynamic CSS custom properties +// ⚠️ Avoid using style prop (only for spreading third-party library styles) +// ❌ Don't do this for custom styling: + + // ✅ Avoid changing styles prop at runtime // ❌ Don't do this: diff --git a/src/tasty/index.ts b/src/tasty/index.ts index c4d57c41c..ed9a26f34 100644 --- a/src/tasty/index.ts +++ b/src/tasty/index.ts @@ -11,10 +11,13 @@ export * from './providers/BreakpointsProvider'; export * from './utils/mergeStyles'; export * from './utils/warnings'; export * from './utils/getDisplayName'; +export * from './utils/processTokens'; export * from './injector'; export * from './debug'; export type { TastyProps, + TastyElementOptions, + TastyElementProps, GlobalTastyProps, AllBasePropsWithMods, } from './tasty'; @@ -38,6 +41,9 @@ export type { GlobalStyledProps, TagName, Mods, + ModValue, + Tokens, + TokenValue, } from './types'; export type { StylesInterface, diff --git a/src/tasty/styles/dimension.test.ts b/src/tasty/styles/dimension.test.ts index d69ac3ce1..92096ab91 100644 --- a/src/tasty/styles/dimension.test.ts +++ b/src/tasty/styles/dimension.test.ts @@ -138,4 +138,25 @@ describe('dimensionStyle – width & height helpers', () => { expect(res['min-height']).toBe('100%'); expect(res['max-height']).toBe('100%'); }); + + test('numeric zero height', () => { + const res = heightStyle({ height: 0 }) as any; + expect(res.height).toBe('0px'); + expect(res['min-height']).toBe('initial'); + expect(res['max-height']).toBe('initial'); + }); + + test('string zero height', () => { + const res = heightStyle({ height: '0' }) as any; + expect(res.height).toBe('0'); + expect(res['min-height']).toBe('initial'); + expect(res['max-height']).toBe('initial'); + }); + + test('numeric zero width', () => { + const res = widthStyle({ width: 0 }) as any; + expect(res.width).toBe('0px'); + expect(res['min-width']).toBe('initial'); + expect(res['max-width']).toBe('initial'); + }); }); diff --git a/src/tasty/styles/dimension.ts b/src/tasty/styles/dimension.ts index 2efe0c25a..966f1e094 100644 --- a/src/tasty/styles/dimension.ts +++ b/src/tasty/styles/dimension.ts @@ -16,7 +16,7 @@ export function dimensionStyle(name) { }; } - if (!val) return ''; + if (val == null) return ''; if (typeof val === 'number') { val = `${val}px`; diff --git a/src/tasty/styles/list.ts b/src/tasty/styles/list.ts index 058b552a8..1ca8612c7 100644 --- a/src/tasty/styles/list.ts +++ b/src/tasty/styles/list.ts @@ -4,6 +4,8 @@ export const BASE_STYLES = [ 'preset', 'hide', 'whiteSpace', + 'opacity', + 'transition', ] as const; export const POSITION_STYLES = [ @@ -34,7 +36,6 @@ export const BLOCK_OUTER_STYLES = [ 'border', 'radius', 'shadow', - 'opacity', 'outline', ] as const; diff --git a/src/tasty/styles/preset.ts b/src/tasty/styles/preset.ts index c6a79ba75..16b3d9899 100644 --- a/src/tasty/styles/preset.ts +++ b/src/tasty/styles/preset.ts @@ -62,9 +62,11 @@ export function presetStyle({ const isStrong = mods.includes('strong'); const isItalic = mods.includes('italic'); const isIcon = mods.includes('icon'); + const isTight = mods.includes('tight'); mods = mods.filter( - (mod) => mod !== 'bold' && mod !== 'italic' && mod !== 'icon', + (mod) => + mod !== 'bold' && mod !== 'italic' && mod !== 'icon' && mod !== 'tight', ); const name = mods[0] || 'default'; @@ -115,6 +117,10 @@ export function presetStyle({ styles['line-height'] = 'var(--icon-size)'; } + if (isTight) { + styles['line-height'] = 'var(--font-size)'; + } + return styles; } diff --git a/src/tasty/tasty.test.tsx b/src/tasty/tasty.test.tsx index c62bca181..bc4a80ca7 100644 --- a/src/tasty/tasty.test.tsx +++ b/src/tasty/tasty.test.tsx @@ -872,6 +872,147 @@ describe('style order consistency', () => { }); }); +describe('tokens prop', () => { + it('should process $name tokens into CSS custom properties', () => { + const Element = tasty({}); + + const { container } = render(); + const element = container.firstElementChild as HTMLElement; + + expect(element.style.getPropertyValue('--spacing')).toBe( + 'calc(2 * var(--gap))', + ); + }); + + it('should process #name color tokens into CSS custom properties', () => { + const Element = tasty({}); + + const { container } = render(); + const element = container.firstElementChild as HTMLElement; + + expect(element.style.getPropertyValue('--accent-color')).toBe( + 'var(--purple-color)', + ); + expect(element.style.getPropertyValue('--accent-color-rgb')).toBe( + 'var(--purple-color-rgb)', + ); + }); + + it('should merge default tokens with instance tokens', () => { + const Card = tasty({ + tokens: { $spacing: '1x', $size: '10x' }, + }); + + const { container } = render(); + const element = container.firstElementChild as HTMLElement; + + // Instance token overrides default + expect(element.style.getPropertyValue('--spacing')).toBe( + 'calc(4 * var(--gap))', + ); + // Default token preserved + expect(element.style.getPropertyValue('--size')).toBe( + 'calc(10 * var(--gap))', + ); + }); + + it('should merge tokens with style prop (style has priority)', () => { + const Element = tasty({}); + + const { container } = render( + , + ); + const element = container.firstElementChild as HTMLElement; + + // style prop overrides tokens + expect(element.style.getPropertyValue('--spacing')).toBe('100px'); + }); + + it('should handle number values correctly', () => { + const Element = tasty({}); + + const { container } = render( + , + ); + const element = container.firstElementChild as HTMLElement; + + // 0 should stay as "0" + expect(element.style.getPropertyValue('--zero')).toBe('0'); + // Other numbers are converted to string + expect(element.style.getPropertyValue('--number')).toBe('42'); + }); + + it('should skip undefined and null token values', () => { + const Element = tasty({}); + + const { container } = render( + , + ); + const element = container.firstElementChild as HTMLElement; + + expect(element.style.getPropertyValue('--defined')).toBe( + 'calc(2 * var(--gap))', + ); + expect(element.style.getPropertyValue('--undefined')).toBe(''); + expect(element.style.getPropertyValue('--null')).toBe(''); + }); + + it('should warn on object token values', () => { + const consoleWarnSpy = jest + .spyOn(console, 'warn') + .mockImplementation(() => {}); + + const Element = tasty({}); + + render( + , + ); + + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining('Object values are not allowed in tokens prop'), + ); + + consoleWarnSpy.mockRestore(); + }); + + it('should work with variants', () => { + const Card = tasty({ + tokens: { $spacing: '2x' }, + styles: { padding: '$spacing' }, + variants: { + compact: { padding: '1x' }, + spacious: { padding: '4x' }, + }, + }); + + const { container } = render( + , + ); + const element = container.firstElementChild as HTMLElement; + + expect(element.style.getPropertyValue('--spacing')).toBe( + 'calc(3 * var(--gap))', + ); + }); + + it('should handle hex color values for RGB extraction', () => { + const Element = tasty({}); + + const { container } = render(); + const element = container.firstElementChild as HTMLElement; + + // Should have color property + expect(element.style.getPropertyValue('--custom-color')).toBeTruthy(); + // Should have RGB property + expect(element.style.getPropertyValue('--custom-color-rgb')).toBeTruthy(); + }); +}); + describe('tastyGlobal() API', () => { beforeEach(() => { // Configure injector for test environment with text injection diff --git a/src/tasty/tasty.tsx b/src/tasty/tasty.tsx index 5800cf333..fe39fb4c4 100644 --- a/src/tasty/tasty.tsx +++ b/src/tasty/tasty.tsx @@ -1,9 +1,11 @@ import { + AllHTMLAttributes, ComponentType, createElement, FC, forwardRef, ForwardRefExoticComponent, + JSX, PropsWithoutRef, RefAttributes, useContext, @@ -17,11 +19,18 @@ import { allocateClassName, inject, injectGlobal } from './injector'; import { BreakpointsContext } from './providers/BreakpointsProvider'; import { BASE_STYLES } from './styles/list'; import { Styles, StylesInterface } from './styles/types'; -import { AllBaseProps, BaseProps, BaseStyleProps, Props } from './types'; +import { + AllBaseProps, + BaseProps, + BaseStyleProps, + Props, + Tokens, +} from './types'; import { cacheWrapper } from './utils/cache-wrapper'; import { getDisplayName } from './utils/getDisplayName'; import { mergeStyles } from './utils/mergeStyles'; import { modAttrs } from './utils/modAttrs'; +import { processTokens, stringifyTokens } from './utils/processTokens'; import { RenderResult, renderStyles } from './utils/renderStyles'; import { ResponsiveStyleValue, stringifyStyles } from './utils/styles'; @@ -89,18 +98,34 @@ export type TastyProps< V extends VariantMap, DefaultProps = Props, > = { - /** The tag name of the element. */ - as?: string; + /** The tag name of the element or a React component. */ + as?: string | ComponentType; /** Default styles of the element. */ styles?: Styles; /** The list of styles that can be provided by props */ styleProps?: K; element?: BaseProps['element']; variants?: V; -} & Partial> & + /** Default tokens for inline CSS custom properties */ + tokens?: Tokens; +} & Partial> & Pick & WithVariant; +/** + * TastyElementOptions is used for the element-creation overload of tasty(). + * It includes a Tag generic that allows TypeScript to infer the correct + * HTML element type from the `as` prop. + */ +export type TastyElementOptions< + K extends StyleList, + V extends VariantMap, + Tag extends keyof JSX.IntrinsicElements = 'div', +> = Omit, 'as'> & { + /** The tag name of the element or a React component. */ + as?: Tag | ComponentType; +}; + export interface GlobalTastyProps { breakpoints?: number[]; } @@ -109,6 +134,50 @@ export type AllBasePropsWithMods = AllBaseProps & { [key in K[number]]?: ResponsiveStyleValue; } & BaseStyleProps; +/** + * Keys from BasePropsWithoutChildren that should be omitted from HTML attributes. + * This excludes event handlers so they can be properly typed from JSX.IntrinsicElements. + */ +type TastySpecificKeys = + | 'as' + | 'qa' + | 'qaVal' + | 'element' + | 'styles' + | 'breakpoints' + | 'block' + | 'inline' + | 'mods' + | 'isHidden' + | 'isDisabled' + | 'css' + | 'style' + | 'theme' + | 'tokens' + | 'ref' + | 'color'; + +/** + * Props type for tasty elements that combines: + * - AllBasePropsWithMods for style props with strict tokens type + * - HTML attributes for flexibility (properly typed based on tag) + * - Variant support + * + * Uses AllHTMLAttributes as base for common attributes (like disabled), + * but overrides event handlers with tag-specific types from JSX.IntrinsicElements. + */ +export type TastyElementProps< + K extends StyleList, + V extends VariantMap, + Tag extends keyof JSX.IntrinsicElements = 'div', +> = AllBasePropsWithMods & + WithVariant & + Omit< + Omit, keyof JSX.IntrinsicElements[Tag]> & + JSX.IntrinsicElements[Tag], + TastySpecificKeys | K[number] + >; + type TastyComponentPropsWithDefaults< Props extends PropsWithStyles, DefaultProps extends Partial, @@ -120,10 +189,16 @@ type TastyComponentPropsWithDefaults< [key in keyof Omit]: Props[key]; }; -export function tasty( - options: TastyProps, +export function tasty< + K extends StyleList, + V extends VariantMap, + Tag extends keyof JSX.IntrinsicElements = 'div', +>( + options: TastyElementOptions, secondArg?: never, -): ComponentType & WithVariant>; +): ForwardRefExoticComponent< + PropsWithoutRef> & RefAttributes +>; export function tasty(selector: string, styles?: Styles); export function tasty< Props extends PropsWithStyles, @@ -254,6 +329,7 @@ function tastyElement( styles: defaultStyles, styleProps, variants, + tokens: defaultTokens, ...defaultProps } = tastyOptions; @@ -271,6 +347,7 @@ function tastyElement( as: originalAs, styles: mergeStyles(defaultStyles, variantStyles), styleProps, + tokens: defaultTokens, ...(defaultProps as Props), } as TastyProps) as unknown as VariantFC; return map; @@ -283,6 +360,7 @@ function tastyElement( as: originalAs, styles: defaultStyles, styleProps, + tokens: defaultTokens, ...(defaultProps as Props), } as TastyProps) as unknown as VariantFC; } @@ -341,9 +419,15 @@ function tastyElement( qa, qaVal, className: userClassName, + tokens, + style, ...otherProps } = allProps as Record as AllBasePropsWithMods & - WithVariant & { className?: string }; + WithVariant & { + className?: string; + tokens?: Tokens; + style?: Record; + }; // Optimize propStyles extraction - avoid creating empty objects let propStyles: Styles | null = null; @@ -446,6 +530,28 @@ function tastyElement( const injectedClassName = allocatedClassName; + // Merge default tokens with instance tokens (instance overrides defaults) + const tokensKey = stringifyTokens(tokens as Tokens | undefined); + const mergedTokens = useMemo(() => { + if (!defaultTokens && !tokens) return undefined; + if (!defaultTokens) return tokens as Tokens; + if (!tokens) return defaultTokens; + return { ...defaultTokens, ...tokens } as Tokens; + }, [tokensKey]); + + // Process merged tokens into inline style properties + const processedTokenStyle = useMemo(() => { + return processTokens(mergedTokens); + }, [mergedTokens]); + + // Merge processed tokens with explicit style prop (style has priority) + const mergedStyle = useMemo(() => { + if (!processedTokenStyle && !style) return undefined; + if (!processedTokenStyle) return style; + if (!style) return processedTokenStyle; + return { ...processedTokenStyle, ...style }; + }, [processedTokenStyle, style]); + let modProps: Record | undefined; if (mods) { const modsObject = mods as unknown as Record; @@ -468,6 +574,7 @@ function tastyElement( ...(modProps || {}), ...(otherProps as unknown as Record), className: finalClassName, + style: mergedStyle, ref, } as Record; diff --git a/src/tasty/types.ts b/src/tasty/types.ts index 4b998e1b8..b3ef8e266 100644 --- a/src/tasty/types.ts +++ b/src/tasty/types.ts @@ -1,5 +1,5 @@ import { AriaLabelingProps } from '@react-types/shared'; -import { AllHTMLAttributes, CSSProperties } from 'react'; +import { AllHTMLAttributes, ComponentType, CSSProperties } from 'react'; import { BASE_STYLES, @@ -21,9 +21,32 @@ export interface GlobalStyledProps { breakpoints?: number[]; } -/** Type for element modifiers (mods prop) */ -export type Mods = { - [key: string]: boolean | string | number | undefined | null; +/** Allowed mod value types */ +export type ModValue = boolean | string | number | undefined | null; + +/** + * Type for element modifiers (mods prop). + * Can be used as a generic to define known modifiers with autocomplete: + * @example + * type ButtonMods = Mods<{ + * loading?: boolean; + * selected?: boolean; + * }>; + */ +export type Mods = {}> = T & { + [key: string]: ModValue; +}; + +/** Token value: string or number (processed), undefined/null (skipped) */ +export type TokenValue = string | number | undefined | null; + +/** + * Tokens definition for inline CSS custom properties. + * - `$name` keys become `--name` CSS properties + * - `#name` keys become `--name-color` and `--name-color-rgb` CSS properties + */ +export type Tokens = { + [key: `$${string}` | `#${string}`]: TokenValue; }; type Caps = @@ -56,7 +79,8 @@ type Caps = export interface BasePropsWithoutChildren extends Pick, 'className' | 'role' | 'id'> { - as?: K; + /** The HTML tag or React component to render as */ + as?: K | ComponentType; /** QA ID for e2e testing. An alias for `data-qa` attribute. */ qa?: string; /** QA value for e2e testing. An alias for `data-qaval` attribute. */ @@ -82,9 +106,11 @@ export interface BasePropsWithoutChildren /** The CSS style map */ style?: | CSSProperties - | (CSSProperties & { [key: string]: string | number | null }); + | (CSSProperties & { [key: string]: string | number | null | undefined }); /** User-defined theme for the element. Mapped to data-theme attribute. Use `default`, or `danger`, or any custom string value you need. */ - theme?: 'default' | 'danger' | 'special' | 'success' | (string & {}); + theme?: 'default' | 'danger' | 'special' | 'success' | 'note' | (string & {}); + /** CSS custom property tokens rendered as inline styles */ + tokens?: Tokens; } export interface BaseProps diff --git a/src/tasty/utils/processTokens.ts b/src/tasty/utils/processTokens.ts new file mode 100644 index 000000000..e70923698 --- /dev/null +++ b/src/tasty/utils/processTokens.ts @@ -0,0 +1,143 @@ +import { CSSProperties } from 'react'; + +import { Tokens, TokenValue } from '../types'; + +import { getRgbValuesFromRgbaString, hexToRgb, parseStyle } from './styles'; + +const devMode = process.env.NODE_ENV !== 'production'; + +/** + * Extract RGB triplet from a color value. + * Returns the RGB values as a space-separated string (e.g., "255 128 0") + * or a CSS variable reference for token colors. + */ +function extractRgbValue(colorValue: string, parsedOutput: string): string { + // If the parsed output references a color variable, use the -rgb variant + const varMatch = parsedOutput.match(/var\(--([a-z0-9-]+)-color\)/); + if (varMatch) { + return `var(--${varMatch[1]}-color-rgb)`; + } + + // For rgb(...) values, extract the triplet + if (parsedOutput.startsWith('rgb(')) { + const rgbValues = getRgbValuesFromRgbaString(parsedOutput); + if (rgbValues && rgbValues.length >= 3) { + return rgbValues.join(' '); + } + } + + // For hex values, convert to RGB triplet + if (colorValue.startsWith('#') && /^#[0-9a-fA-F]{3,8}$/.test(colorValue)) { + const rgbResult = hexToRgb(colorValue); + if (rgbResult) { + const rgbValues = getRgbValuesFromRgbaString(rgbResult); + if (rgbValues && rgbValues.length >= 3) { + return rgbValues.join(' '); + } + } + } + + // Fallback: return the parsed output (may not be ideal but covers edge cases) + return parsedOutput; +} + +/** + * Check if a value is a valid token value (string or number, not object). + */ +function isValidTokenValue( + value: unknown, +): value is Exclude { + if (value === undefined || value === null) { + return false; + } + + if (typeof value === 'object') { + if (devMode) { + console.warn( + 'CubeUIKit: Object values are not allowed in tokens prop. ' + + 'Tokens do not support state-based styling. Use a primitive value instead.', + ); + } + return false; + } + + return typeof value === 'string' || typeof value === 'number'; +} + +/** + * Process a single token value through the tasty parser. + * Numbers are converted to strings; 0 stays as "0". + */ +function processTokenValue(value: string | number): string { + if (typeof value === 'number') { + // 0 should remain as "0", not converted to any unit + if (value === 0) { + return '0'; + } + return parseStyle(String(value)).output; + } + return parseStyle(value).output; +} + +/** + * Process tokens object into inline style properties. + * - $name -> --name with parsed value + * - #name -> --name-color AND --name-color-rgb with parsed values + * + * @param tokens - The tokens object to process + * @returns CSSProperties object or undefined if no tokens to process + */ +export function processTokens( + tokens: Tokens | undefined, +): CSSProperties | undefined { + if (!tokens) { + return undefined; + } + + const keys = Object.keys(tokens); + if (keys.length === 0) { + return undefined; + } + + let result: Record | undefined; + + for (const key of keys) { + const value = tokens[key as keyof Tokens]; + + // Skip undefined/null values + if (!isValidTokenValue(value)) { + continue; + } + + if (key.startsWith('$')) { + // Custom property token: $name -> --name + const propName = `--${key.slice(1)}`; + const processedValue = processTokenValue(value); + + if (!result) result = {}; + result[propName] = processedValue; + } else if (key.startsWith('#')) { + // Color token: #name -> --name-color and --name-color-rgb + const colorName = key.slice(1); + const originalValue = typeof value === 'number' ? String(value) : value; + const processedValue = processTokenValue(value); + + if (!result) result = {}; + result[`--${colorName}-color`] = processedValue; + result[`--${colorName}-color-rgb`] = extractRgbValue( + originalValue, + processedValue, + ); + } + } + + return result as CSSProperties | undefined; +} + +/** + * Stringify tokens for memoization key. + */ +export function stringifyTokens(tokens: Tokens | undefined): string { + if (!tokens) return ''; + return JSON.stringify(tokens); +} diff --git a/src/utils/react/index.ts b/src/utils/react/index.ts index 13047f1f6..157bc12e7 100644 --- a/src/utils/react/index.ts +++ b/src/utils/react/index.ts @@ -16,3 +16,5 @@ export type { UseControlledFocusVisibleResult } from './useControlledFocusVisibl export { RenderCache } from './RenderCache'; export type { RenderCacheProps } from './RenderCache'; export { useLocalStorage } from './useLocalStorage'; +export { resolveIcon } from './resolveIcon'; +export type { DynamicIcon, IconRenderFn, ResolvedIcon } from './resolveIcon'; diff --git a/src/utils/react/resolveIcon.ts b/src/utils/react/resolveIcon.ts new file mode 100644 index 000000000..5bfaee50c --- /dev/null +++ b/src/utils/react/resolveIcon.ts @@ -0,0 +1,55 @@ +import { ReactNode } from 'react'; + +import { Mods } from '../../tasty'; + +/** + * Function that receives mods (can be destructured) and returns an icon or true for empty slot. + * @example + * icon={({ loading, selected }) => loading ? : } + */ +export type IconRenderFn = (mods: T) => ReactNode | true; + +/** Dynamic icon prop: ReactNode, true for empty slot, or render function */ +export type DynamicIcon = + | ReactNode + | true + | IconRenderFn; + +export interface ResolvedIcon { + /** The icon content to render (null for empty slot) */ + content: ReactNode | null; + /** Whether the slot should be rendered (true even for empty slots) */ + hasSlot: boolean; +} + +/** + * Resolves a dynamic icon prop to its content and slot state. + * - If `true`: empty slot (hasSlot=true, content=null) + * - If function: call with mods, then process result + * - Otherwise: use as-is + */ +export function resolveIcon( + icon: DynamicIcon | undefined, + mods: T, +): ResolvedIcon { + if (icon === undefined || icon === null || icon === false) { + return { content: null, hasSlot: false }; + } + + if (icon === true) { + return { content: null, hasSlot: true }; + } + + if (typeof icon === 'function') { + const result = (icon as IconRenderFn)(mods); + if (result === true) { + return { content: null, hasSlot: true }; + } + if (result === undefined || result === null || result === false) { + return { content: null, hasSlot: false }; + } + return { content: result, hasSlot: true }; + } + + return { content: icon, hasSlot: true }; +}