From 632a5873ab623968c8c16fa08bc540d806c0e8ed Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Wed, 28 Jan 2026 09:59:30 +0530 Subject: [PATCH 01/12] feat: migrate checkbox to base ui --- .../components/checkbox/checkbox.module.css | 125 +++++++------- .../raystack/components/checkbox/checkbox.tsx | 77 ++------- .../raystack/components/checkbox/index.tsx | 2 +- packages/raystack/package.json | 3 +- pnpm-lock.yaml | 161 ++++++++++++++---- 5 files changed, 214 insertions(+), 154 deletions(-) diff --git a/packages/raystack/components/checkbox/checkbox.module.css b/packages/raystack/components/checkbox/checkbox.module.css index 8a8e3c303..4dc65bc52 100644 --- a/packages/raystack/components/checkbox/checkbox.module.css +++ b/packages/raystack/components/checkbox/checkbox.module.css @@ -1,66 +1,65 @@ .checkbox { - all: unset; - box-sizing: border-box; - display: inline-flex; - align-items: center; - justify-content: center; - width: var(--rs-space-5); - height: var(--rs-space-5); - min-width: var(--rs-space-5); - min-height: var(--rs-space-5); - border-radius: var(--rs-radius-1); - background: var(--rs-color-background-base-primary); - border: 1px solid var(--rs-color-border-base-secondary); - cursor: pointer; - flex-shrink: 0; - } - - .checkbox:hover { - background: var(--rs-color-background-base-primary-hover); - border-color: var(--rs-color-border-base-focus); - } - - .checkbox[data-state="checked"] { - background: var(--rs-color-background-accent-emphasis); - border: none; - } - - .checkbox[data-state="checked"]:hover { - background: var(--rs-color-background-accent-emphasis-hover); - } + all: unset; + box-sizing: border-box; + display: inline-flex; + align-items: center; + justify-content: center; + width: var(--rs-space-5); + height: var(--rs-space-5); + min-width: var(--rs-space-5); + min-height: var(--rs-space-5); + border-radius: var(--rs-radius-1); + background: var(--rs-color-background-base-primary); + border: 1px solid var(--rs-color-border-base-secondary); + cursor: pointer; + flex-shrink: 0; +} - /* Indeterminate state */ - .checkbox-indeterminate[data-state="checked"] { - background: var(--rs-color-background-neutral-tertiary); - border: none; - } +.checkbox:hover { + background: var(--rs-color-background-base-primary-hover); + border-color: var(--rs-color-border-base-focus); +} - .checkbox-indeterminate[data-state="checked"]:hover { - background: var(--rs-color-background-neutral-tertiary); - border: none; - } - - .checkbox-disabled { - opacity: 0.5; - cursor: not-allowed; - } - - .checkbox-disabled:hover { - background: var(--rs-color-background-base-primary); - border-color: var(--rs-color-border-base-primary); - } - - .indicator { - display: flex; - align-items: center; - justify-content: center; - width: 100%; - height: 100%; - color: var(--rs-color-foreground-accent-emphasis); - } - - .icon { - width: var(--rs-space-5); - height: var(--rs-space-5); - } - \ No newline at end of file +.checkbox[data-checked] { + background: var(--rs-color-background-accent-emphasis); + border: none; +} + +.checkbox[data-checked]:hover { + background: var(--rs-color-background-accent-emphasis-hover); +} + +/* Indeterminate state */ +.checkbox[data-indeterminate] { + background: var(--rs-color-background-neutral-tertiary); + border: none; +} + +.checkbox[data-indeterminate]:hover { + background: var(--rs-color-background-neutral-tertiary); + border: none; +} + +.checkbox[data-disabled] { + opacity: 0.5; + cursor: not-allowed; +} + +.checkbox[data-disabled]:hover { + background: var(--rs-color-background-base-primary); + border-color: var(--rs-color-border-base-primary); +} + +.indicator { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + color: var(--rs-color-foreground-accent-emphasis); +} + +.icon { + width: var(--rs-space-5); + height: var(--rs-space-5); +} diff --git a/packages/raystack/components/checkbox/checkbox.tsx b/packages/raystack/components/checkbox/checkbox.tsx index 2a1e086f6..d67664c78 100644 --- a/packages/raystack/components/checkbox/checkbox.tsx +++ b/packages/raystack/components/checkbox/checkbox.tsx @@ -1,8 +1,8 @@ 'use client'; -import { VariantProps, cva, cx } from 'class-variance-authority'; -import { Checkbox as CheckboxPrimitive } from 'radix-ui'; -import { ComponentPropsWithoutRef, ElementRef, forwardRef } from 'react'; +import { Checkbox as CheckboxPrimitive } from '@base-ui/react/checkbox'; +import { cx } from 'class-variance-authority'; +import { ElementRef, forwardRef } from 'react'; import styles from './checkbox.module.css'; @@ -42,63 +42,22 @@ const IndeterminateIcon = () => ( ); -const checkbox = cva(styles.checkbox); - -type CheckboxVariants = VariantProps; -type CheckedState = boolean | 'indeterminate'; - -export interface CheckboxProps - extends Omit< - ComponentPropsWithoutRef, - keyof CheckboxVariants - >, - CheckboxVariants { - checked?: CheckedState; - defaultChecked?: CheckedState; - onCheckedChange?: (checked: CheckedState) => void; -} - export const Checkbox = forwardRef< ElementRef, - CheckboxProps ->( - ( - { className, disabled, checked, defaultChecked, onCheckedChange, ...props }, - forwardedRef - ) => { - const isIndeterminate = - checked === 'indeterminate' || defaultChecked === 'indeterminate'; - - return ( - { - if (onCheckedChange) { - // If it's currently indeterminate, next state will be unchecked - if (checked === 'indeterminate') { - onCheckedChange(false); - } else { - onCheckedChange(value); - } - } - }} - disabled={disabled} - ref={forwardedRef} - {...props} - > - - {isIndeterminate ? : } - - - ); - } -); + CheckboxPrimitive.Root.Props +>(({ className, indeterminate, ...props }, ref) => { + return ( + + + {indeterminate ? : } + + + ); +}); Checkbox.displayName = 'Checkbox'; diff --git a/packages/raystack/components/checkbox/index.tsx b/packages/raystack/components/checkbox/index.tsx index 2d4f967d9..83a0ba15d 100644 --- a/packages/raystack/components/checkbox/index.tsx +++ b/packages/raystack/components/checkbox/index.tsx @@ -1 +1 @@ -export { Checkbox } from "./checkbox"; +export { Checkbox } from './checkbox'; diff --git a/packages/raystack/package.json b/packages/raystack/package.json index d86e1689c..35527459d 100644 --- a/packages/raystack/package.json +++ b/packages/raystack/package.json @@ -80,6 +80,7 @@ "license": "ISC", "devDependencies": { "@figma/code-connect": "^1.3.5", + "@raystack/tools-config": "workspace:*", "@rollup/plugin-commonjs": "^25.0.2", "@rollup/plugin-image": "^3.0.2", "@rollup/plugin-node-resolve": "^15.1.0", @@ -107,13 +108,13 @@ "rollup-plugin-postcss": "^4.0.2", "rollup-plugin-preserve-directives": "^0.4.0", "rollup-plugin-tsconfig-paths": "^1.5.2", - "@raystack/tools-config": "workspace:*", "semver": "^7.6.0", "typescript": "~5.4.3", "vitest": "^3.2.4" }, "dependencies": { "@ariakit/react": "^0.4.16", + "@base-ui/react": "^1.1.0", "@radix-ui/react-icons": "^1.3.2", "@tanstack/match-sorter-utils": "^8.8.4", "@tanstack/react-table": "^8.9.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 50068f491..d10663bd6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -175,6 +175,9 @@ importers: '@ariakit/react': specifier: ^0.4.16 version: 0.4.16(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + '@base-ui/react': + specifier: ^1.1.0 + version: 1.1.0(@types/react@19.1.9)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) '@radix-ui/react-icons': specifier: ^1.3.2 version: 1.3.2(react@19.2.1) @@ -965,14 +968,14 @@ packages: resolution: {integrity: sha512-5F7SDGs1T72ZczbRwbGO9lQi0NLjQxzl6i4lJxLxfW9U5UluCSyEJeniWvnhl3/euNiqQVbo8zruhsDfid0esA==} engines: {node: '>=6.9.0'} - '@babel/runtime@7.26.9': - resolution: {integrity: sha512-aA63XwOkcl4xxQa3HjPMqOP6LiK0ZDv3mUPYEFXkpHbaFjtGggE1A61FjFzJnB+p7/oy2gA8E+rcBNl/zC1tMg==} - engines: {node: '>=6.9.0'} - '@babel/runtime@7.27.0': resolution: {integrity: sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==} engines: {node: '>=6.9.0'} + '@babel/runtime@7.28.6': + resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==} + engines: {node: '>=6.9.0'} + '@babel/template@7.25.9': resolution: {integrity: sha512-9DGttpmPvIxBb/2uwpVo3dqJ+O6RooAFOS+lB+xDqoE2PVCE8nfoHMdZLpfCQRLwvohzXISPZcgxt80xLfsuwg==} engines: {node: '>=6.9.0'} @@ -997,6 +1000,27 @@ packages: resolution: {integrity: sha512-vN5p+1kl59GVKMvTHt55NzzmYVxprfJD+ql7U9NFIfKCBkYE55LYtS+WtPlaYOyzydrKI8Nezd+aZextrd+FMA==} engines: {node: '>=6.9.0'} + '@base-ui/react@1.1.0': + resolution: {integrity: sha512-ikcJRNj1mOiF2HZ5jQHrXoVoHcNHdBU5ejJljcBl+VTLoYXR6FidjTN86GjO6hyshi6TZFuNvv0dEOgaOFv6Lw==} + engines: {node: '>=14.0.0'} + peerDependencies: + '@types/react': ^17 || ^18 || ^19 + react: ^17 || ^18 || ^19 + react-dom: ^17 || ^18 || ^19 + peerDependenciesMeta: + '@types/react': + optional: true + + '@base-ui/utils@0.2.4': + resolution: {integrity: sha512-smZwpMhjO29v+jrZusBSc5T+IJ3vBb9cjIiBjtKcvWmRj9Z4DWGVR3efr1eHR56/bqY5a4qyY9ElkOY5ljo3ng==} + peerDependencies: + '@types/react': ^17 || ^18 || ^19 + react: ^17 || ^18 || ^19 + react-dom: ^17 || ^18 || ^19 + peerDependenciesMeta: + '@types/react': + optional: true + '@bconnorwhite/module@2.0.2': resolution: {integrity: sha512-ck1me5WMgZKp06gnJrVKEkytpehTTQbvsAMbF1nGPeHri/AZNhj87++PSE2LOxmZqM0EtGMaqeLdx7Lw7SUnTA==} @@ -1450,15 +1474,30 @@ packages: '@floating-ui/core@1.6.4': resolution: {integrity: sha512-a4IowK4QkXl4SCWTGUR0INAfEOX3wtsYw3rKK5InQEHMGObkR8Xk44qYQD9P4r6HHw0iIfK6GUKECmY8sTkqRA==} + '@floating-ui/core@1.7.3': + resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==} + '@floating-ui/dom@1.6.7': resolution: {integrity: sha512-wmVfPG5o2xnKDU4jx/m4w5qva9FWHcnZ8BvzEe90D/RpwsJaTAVYPEPdQ8sbr/N8zZTAHlZUTQdqg8ZUbzHmng==} + '@floating-ui/dom@1.7.4': + resolution: {integrity: sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==} + '@floating-ui/react-dom@2.1.1': resolution: {integrity: sha512-4h84MJt3CHrtG18mGsXuLCHMrug49d7DFkU0RMIyshRveBeyV2hmV/pDaF2Uxtu8kgq5r46llp5E5FQiR0K2Yg==} peerDependencies: react: '>=16.8.0' react-dom: '>=16.8.0' + '@floating-ui/react-dom@2.1.6': + resolution: {integrity: sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/utils@0.2.10': + resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} + '@floating-ui/utils@0.2.4': resolution: {integrity: sha512-dWO2pw8hhi+WrXq1YJy2yCuWoL20PddgGaqTgVe4cOS9Q6qklXCiA1tJEqX6BEwRNSCP84/afac9hd4MS+zEUA==} @@ -7999,6 +8038,9 @@ packages: requires-port@1.0.0: resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} + reselect@5.1.1: + resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==} + resolve-alpn@1.2.1: resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==} @@ -8499,6 +8541,9 @@ packages: symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tabbable@6.4.0: + resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==} + table@6.9.0: resolution: {integrity: sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==} engines: {node: '>=10.0.0'} @@ -8935,6 +8980,11 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + usehooks-ts@2.9.1: resolution: {integrity: sha512-2FAuSIGHlY+apM9FVlj8/oNhd+1y+Uwv5QNkMQz1oSfdHk4PXo1qoCw9I5M7j0vpH8CSWFJwXbVPeYDjLCx9PA==} engines: {node: '>=16.15.0', npm: '>=8'} @@ -10120,14 +10170,12 @@ snapshots: dependencies: regenerator-runtime: 0.14.1 - '@babel/runtime@7.26.9': - dependencies: - regenerator-runtime: 0.14.1 - '@babel/runtime@7.27.0': dependencies: regenerator-runtime: 0.14.1 + '@babel/runtime@7.28.6': {} + '@babel/template@7.25.9': dependencies: '@babel/code-frame': 7.26.2 @@ -10174,6 +10222,31 @@ snapshots: '@babel/helper-string-parser': 7.25.9 '@babel/helper-validator-identifier': 7.25.9 + '@base-ui/react@1.1.0(@types/react@19.1.9)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': + dependencies: + '@babel/runtime': 7.28.6 + '@base-ui/utils': 0.2.4(@types/react@19.1.9)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + '@floating-ui/react-dom': 2.1.6(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + '@floating-ui/utils': 0.2.10 + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + reselect: 5.1.1 + tabbable: 6.4.0 + use-sync-external-store: 1.6.0(react@19.2.1) + optionalDependencies: + '@types/react': 19.1.9 + + '@base-ui/utils@0.2.4(@types/react@19.1.9)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': + dependencies: + '@babel/runtime': 7.28.6 + '@floating-ui/utils': 0.2.10 + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + reselect: 5.1.1 + use-sync-external-store: 1.6.0(react@19.2.1) + optionalDependencies: + '@types/react': 19.1.9 + '@bconnorwhite/module@2.0.2': dependencies: find-up: 5.0.0 @@ -10249,7 +10322,7 @@ snapshots: '@emotion/babel-plugin@11.11.0': dependencies: '@babel/helper-module-imports': 7.25.9 - '@babel/runtime': 7.26.9 + '@babel/runtime': 7.27.0 '@emotion/hash': 0.9.1 '@emotion/memoize': 0.8.1 '@emotion/serialize': 1.1.4 @@ -10276,7 +10349,7 @@ snapshots: '@emotion/react@11.11.4(@types/react@18.2.12)(react@18.3.1)': dependencies: - '@babel/runtime': 7.26.9 + '@babel/runtime': 7.27.0 '@emotion/babel-plugin': 11.11.0 '@emotion/cache': 11.11.0 '@emotion/serialize': 1.1.4 @@ -10499,11 +10572,20 @@ snapshots: dependencies: '@floating-ui/utils': 0.2.4 + '@floating-ui/core@1.7.3': + dependencies: + '@floating-ui/utils': 0.2.10 + '@floating-ui/dom@1.6.7': dependencies: '@floating-ui/core': 1.6.4 '@floating-ui/utils': 0.2.4 + '@floating-ui/dom@1.7.4': + dependencies: + '@floating-ui/core': 1.7.3 + '@floating-ui/utils': 0.2.10 + '@floating-ui/react-dom@2.1.1(react-dom@19.2.1(react@18.3.1))(react@18.3.1)': dependencies: '@floating-ui/dom': 1.6.7 @@ -10516,6 +10598,14 @@ snapshots: react: 19.2.1 react-dom: 19.2.1(react@19.2.1) + '@floating-ui/react-dom@2.1.6(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': + dependencies: + '@floating-ui/dom': 1.7.4 + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + + '@floating-ui/utils@0.2.10': {} + '@floating-ui/utils@0.2.4': {} '@formatjs/intl-localematcher@0.6.2': @@ -11248,6 +11338,7 @@ snapshots: '@parcel/types': 2.12.0(@parcel/core@2.12.0) transitivePeerDependencies: - '@parcel/core' + - '@swc/helpers' '@parcel/plugin@2.9.2(@parcel/core@2.12.0)': dependencies: @@ -11666,6 +11757,8 @@ snapshots: '@parcel/types': 2.12.0(@parcel/core@2.12.0) '@parcel/utils': 2.12.0 nullthrows: 1.1.1 + transitivePeerDependencies: + - '@swc/helpers' '@parcel/workers@2.9.2(@parcel/core@2.12.0)': dependencies: @@ -11708,7 +11801,7 @@ snapshots: '@radix-ui/primitive@1.0.0': dependencies: - '@babel/runtime': 7.26.9 + '@babel/runtime': 7.27.0 '@radix-ui/primitive@1.1.2': {} @@ -11968,7 +12061,7 @@ snapshots: '@radix-ui/react-compose-refs@1.0.0(react@18.3.1)': dependencies: - '@babel/runtime': 7.26.9 + '@babel/runtime': 7.27.0 react: 18.3.1 '@radix-ui/react-compose-refs@1.1.2(@types/react@18.2.12)(react@18.3.1)': @@ -12005,7 +12098,7 @@ snapshots: '@radix-ui/react-context@1.0.0(react@18.3.1)': dependencies: - '@babel/runtime': 7.26.9 + '@babel/runtime': 7.27.0 react: 18.3.1 '@radix-ui/react-context@1.1.2(@types/react@18.2.12)(react@18.3.1)': @@ -12028,7 +12121,7 @@ snapshots: '@radix-ui/react-dialog@1.0.0(@types/react@18.2.12)(react-dom@19.2.1(react@18.3.1))(react@18.3.1)': dependencies: - '@babel/runtime': 7.26.9 + '@babel/runtime': 7.27.0 '@radix-ui/primitive': 1.0.0 '@radix-ui/react-compose-refs': 1.0.0(react@18.3.1) '@radix-ui/react-context': 1.0.0(react@18.3.1) @@ -12134,7 +12227,7 @@ snapshots: '@radix-ui/react-dismissable-layer@1.0.0(react-dom@19.2.1(react@18.3.1))(react@18.3.1)': dependencies: - '@babel/runtime': 7.26.9 + '@babel/runtime': 7.27.0 '@radix-ui/primitive': 1.0.0 '@radix-ui/react-compose-refs': 1.0.0(react@18.3.1) '@radix-ui/react-primitive': 1.0.0(react-dom@19.2.1(react@18.3.1))(react@18.3.1) @@ -12214,7 +12307,7 @@ snapshots: '@radix-ui/react-focus-guards@1.0.0(react@18.3.1)': dependencies: - '@babel/runtime': 7.26.9 + '@babel/runtime': 7.27.0 react: 18.3.1 '@radix-ui/react-focus-guards@1.1.2(@types/react@18.2.12)(react@18.3.1)': @@ -12237,7 +12330,7 @@ snapshots: '@radix-ui/react-focus-scope@1.0.0(react-dom@19.2.1(react@18.3.1))(react@18.3.1)': dependencies: - '@babel/runtime': 7.26.9 + '@babel/runtime': 7.27.0 '@radix-ui/react-compose-refs': 1.0.0(react@18.3.1) '@radix-ui/react-primitive': 1.0.0(react-dom@19.2.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-use-callback-ref': 1.0.0(react@18.3.1) @@ -12318,7 +12411,7 @@ snapshots: '@radix-ui/react-id@1.0.0(react@18.3.1)': dependencies: - '@babel/runtime': 7.26.9 + '@babel/runtime': 7.27.0 '@radix-ui/react-use-layout-effect': 1.0.0(react@18.3.1) react: 18.3.1 @@ -12627,7 +12720,7 @@ snapshots: '@radix-ui/react-portal@1.0.0(react-dom@19.2.1(react@18.3.1))(react@18.3.1)': dependencies: - '@babel/runtime': 7.26.9 + '@babel/runtime': 7.27.0 '@radix-ui/react-primitive': 1.0.0(react-dom@19.2.1(react@18.3.1))(react@18.3.1) react: 18.3.1 react-dom: 19.2.1(react@18.3.1) @@ -12664,7 +12757,7 @@ snapshots: '@radix-ui/react-presence@1.0.0(react-dom@19.2.1(react@18.3.1))(react@18.3.1)': dependencies: - '@babel/runtime': 7.26.9 + '@babel/runtime': 7.27.0 '@radix-ui/react-compose-refs': 1.0.0(react@18.3.1) '@radix-ui/react-use-layout-effect': 1.0.0(react@18.3.1) react: 18.3.1 @@ -12702,7 +12795,7 @@ snapshots: '@radix-ui/react-primitive@1.0.0(react-dom@19.2.1(react@18.3.1))(react@18.3.1)': dependencies: - '@babel/runtime': 7.26.9 + '@babel/runtime': 7.27.0 '@radix-ui/react-slot': 1.0.0(react@18.3.1) react: 18.3.1 react-dom: 19.2.1(react@18.3.1) @@ -12998,7 +13091,7 @@ snapshots: '@radix-ui/react-slot@1.0.0(react@18.3.1)': dependencies: - '@babel/runtime': 7.26.9 + '@babel/runtime': 7.27.0 '@radix-ui/react-compose-refs': 1.0.0(react@18.3.1) react: 18.3.1 @@ -13230,7 +13323,7 @@ snapshots: '@radix-ui/react-use-callback-ref@1.0.0(react@18.3.1)': dependencies: - '@babel/runtime': 7.26.9 + '@babel/runtime': 7.27.0 react: 18.3.1 '@radix-ui/react-use-callback-ref@1.1.1(@types/react@18.2.12)(react@18.3.1)': @@ -13253,7 +13346,7 @@ snapshots: '@radix-ui/react-use-controllable-state@1.0.0(react@18.3.1)': dependencies: - '@babel/runtime': 7.26.9 + '@babel/runtime': 7.27.0 '@radix-ui/react-use-callback-ref': 1.0.0(react@18.3.1) react: 18.3.1 @@ -13304,7 +13397,7 @@ snapshots: '@radix-ui/react-use-escape-keydown@1.0.0(react@18.3.1)': dependencies: - '@babel/runtime': 7.26.9 + '@babel/runtime': 7.27.0 '@radix-ui/react-use-callback-ref': 1.0.0(react@18.3.1) react: 18.3.1 @@ -13345,7 +13438,7 @@ snapshots: '@radix-ui/react-use-layout-effect@1.0.0(react@18.3.1)': dependencies: - '@babel/runtime': 7.26.9 + '@babel/runtime': 7.27.0 react: 18.3.1 '@radix-ui/react-use-layout-effect@1.1.1(@types/react@18.2.12)(react@18.3.1)': @@ -14538,7 +14631,7 @@ snapshots: babel-plugin-macros@3.1.0: dependencies: - '@babel/runtime': 7.26.9 + '@babel/runtime': 7.27.0 cosmiconfig: 7.1.0 resolve: 1.22.8 @@ -15325,7 +15418,7 @@ snapshots: dom-helpers@5.2.1: dependencies: - '@babel/runtime': 7.26.9 + '@babel/runtime': 7.27.0 csstype: 3.1.3 dom-serializer@1.4.1: @@ -18752,7 +18845,7 @@ snapshots: react-transition-group@4.4.5(react-dom@19.2.1(react@18.3.1))(react@18.3.1): dependencies: - '@babel/runtime': 7.26.9 + '@babel/runtime': 7.27.0 dom-helpers: 5.2.1 loose-envify: 1.4.0 prop-types: 15.8.1 @@ -18855,7 +18948,7 @@ snapshots: regenerator-transform@0.15.2: dependencies: - '@babel/runtime': 7.26.9 + '@babel/runtime': 7.27.0 regex-recursion@6.0.2: dependencies: @@ -18983,6 +19076,8 @@ snapshots: requires-port@1.0.0: {} + reselect@5.1.1: {} + resolve-alpn@1.2.1: {} resolve-cwd@3.0.0: @@ -19550,6 +19645,8 @@ snapshots: symbol-tree@3.2.4: {} + tabbable@6.4.0: {} + table@6.9.0: dependencies: ajv: 8.17.1 @@ -20028,6 +20125,10 @@ snapshots: dependencies: react: 19.2.1 + use-sync-external-store@1.6.0(react@19.2.1): + dependencies: + react: 19.2.1 + usehooks-ts@2.9.1(react-dom@19.2.1(react@18.3.1))(react@18.3.1): dependencies: react: 18.3.1 From ac925a7eacd258982cf834831e584bf94e2d821b Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Wed, 28 Jan 2026 14:28:44 +0530 Subject: [PATCH 02/12] feat: update checkbox tests and docs --- .../content/docs/components/checkbox/demo.ts | 15 ++++-- .../content/docs/components/checkbox/props.ts | 11 +++-- .../checkbox/__tests__/checkbox.test.tsx | 49 +++++++++---------- 3 files changed, 41 insertions(+), 34 deletions(-) diff --git a/apps/www/src/content/docs/components/checkbox/demo.ts b/apps/www/src/content/docs/components/checkbox/demo.ts index 3cc2a5765..505412262 100644 --- a/apps/www/src/content/docs/components/checkbox/demo.ts +++ b/apps/www/src/content/docs/components/checkbox/demo.ts @@ -13,9 +13,14 @@ export const playground = { type: 'playground', controls: { checked: { - type: 'select', - options: ['true', 'false', 'indeterminate'], - initialValue: 'true' + type: 'checkbox', + initialValue: false, + defaultValue: false + }, + indeterminate: { + type: 'checkbox', + initialValue: false, + defaultValue: false }, disabled: { type: 'checkbox', @@ -34,11 +39,11 @@ export const statesExamples = { }, { name: 'Checked', - code: `` + code: `` }, { name: 'Indeterminate', - code: `` + code: `` }, { name: 'Disabled', diff --git a/apps/www/src/content/docs/components/checkbox/props.ts b/apps/www/src/content/docs/components/checkbox/props.ts index 1d2a70bc7..220c452ac 100644 --- a/apps/www/src/content/docs/components/checkbox/props.ts +++ b/apps/www/src/content/docs/components/checkbox/props.ts @@ -2,17 +2,22 @@ export interface CheckboxProps { /** * The controlled state of the checkbox */ - checked?: boolean | 'indeterminate'; + checked?: boolean; /** * The default state when initially rendered */ - defaultChecked?: boolean | 'indeterminate'; + defaultChecked?: boolean; /** * Event handler called when the state changes */ - onCheckedChange?: (checked: boolean | 'indeterminate') => void; + onCheckedChange?: (checked: boolean) => void; + + /** + * When true, the checkbox is in an indeterminate state + */ + indeterminate?: boolean; /** * When true, prevents the user from interacting with the checkbox diff --git a/packages/raystack/components/checkbox/__tests__/checkbox.test.tsx b/packages/raystack/components/checkbox/__tests__/checkbox.test.tsx index 0cac22f06..eec6d6147 100644 --- a/packages/raystack/components/checkbox/__tests__/checkbox.test.tsx +++ b/packages/raystack/components/checkbox/__tests__/checkbox.test.tsx @@ -73,28 +73,25 @@ describe('Checkbox', () => { }); describe('Indeterminate State', () => { - it('applies indeterminate class', () => { - render(); + it('has data-indeterminate attribute when indeterminate', () => { + render(); const checkbox = screen.getByRole('checkbox'); - expect(checkbox).toHaveClass(styles['checkbox-indeterminate']); + expect(checkbox).toHaveAttribute('data-indeterminate'); }); - it('renders with defaultChecked as indeterminate', () => { - render(); + it('can be both checked and indeterminate', () => { + render(); const checkbox = screen.getByRole('checkbox'); - expect(checkbox).toHaveClass(styles['checkbox-indeterminate']); + expect(checkbox).toHaveAttribute('aria-checked', 'mixed'); + expect(checkbox).toHaveAttribute('data-indeterminate'); }); - it('transitions from indeterminate to unchecked on click', () => { - const handleChange = vi.fn(); - render( - - ); - - const checkbox = screen.getByRole('checkbox'); - fireEvent.click(checkbox); - - expect(handleChange).toHaveBeenCalledWith(false); + it('shows indeterminate icon when indeterminate', () => { + const { container } = render(); + const indicator = container.querySelector(`.${styles.indicator}`); + expect(indicator).toBeInTheDocument(); + const svg = indicator?.querySelector('svg'); + expect(svg).toBeInTheDocument(); }); }); @@ -102,13 +99,13 @@ describe('Checkbox', () => { it('renders as disabled when disabled prop is true', () => { render(); const checkbox = screen.getByRole('checkbox'); - expect(checkbox).toBeDisabled(); + expect(checkbox).toHaveAttribute('aria-disabled', 'true'); }); - it('applies disabled class', () => { + it('has data-disabled attribute when disabled', () => { render(); const checkbox = screen.getByRole('checkbox'); - expect(checkbox).toHaveClass(styles['checkbox-disabled']); + expect(checkbox).toHaveAttribute('data-disabled'); }); it('does not trigger onCheckedChange when disabled', () => { @@ -124,15 +121,15 @@ describe('Checkbox', () => { it('can be disabled while checked', () => { render(); const checkbox = screen.getByRole('checkbox'); - expect(checkbox).toBeDisabled(); + expect(checkbox).toHaveAttribute('aria-disabled', 'true'); expect(checkbox).toBeChecked(); }); it('can be disabled while indeterminate', () => { - render(); + render(); const checkbox = screen.getByRole('checkbox'); - expect(checkbox).toBeDisabled(); - expect(checkbox).toHaveClass(styles['checkbox-indeterminate']); + expect(checkbox).toHaveAttribute('aria-disabled', 'true'); + expect(checkbox).toHaveAttribute('data-indeterminate'); }); }); @@ -145,7 +142,7 @@ describe('Checkbox', () => { fireEvent.click(checkbox); expect(handleChange).toHaveBeenCalledTimes(1); - expect(handleChange).toHaveBeenCalledWith(true); + expect(handleChange).toHaveBeenCalledWith(true, expect.anything()); }); it('toggles from unchecked to checked', () => { @@ -155,7 +152,7 @@ describe('Checkbox', () => { const checkbox = screen.getByRole('checkbox'); fireEvent.click(checkbox); - expect(handleChange).toHaveBeenCalledWith(true); + expect(handleChange).toHaveBeenCalledWith(true, expect.anything()); }); it('toggles from checked to unchecked', () => { @@ -165,7 +162,7 @@ describe('Checkbox', () => { const checkbox = screen.getByRole('checkbox'); fireEvent.click(checkbox); - expect(handleChange).toHaveBeenCalledWith(false); + expect(handleChange).toHaveBeenCalledWith(false, expect.anything()); }); it('supports focus events', () => { From 0232244553d561c7855408d09826c6970a168656 Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Wed, 28 Jan 2026 15:08:07 +0530 Subject: [PATCH 03/12] feat: migrate avatar to base ui --- .../content/docs/components/avatar/index.mdx | 1 + .../content/docs/components/avatar/props.ts | 10 +++- .../raystack/components/avatar/avatar.tsx | 54 +++++++++---------- packages/raystack/components/avatar/index.tsx | 4 +- 4 files changed, 37 insertions(+), 32 deletions(-) diff --git a/apps/www/src/content/docs/components/avatar/index.mdx b/apps/www/src/content/docs/components/avatar/index.mdx index 9ff19f09e..1b9b07d7f 100644 --- a/apps/www/src/content/docs/components/avatar/index.mdx +++ b/apps/www/src/content/docs/components/avatar/index.mdx @@ -2,6 +2,7 @@ title: Avatar description: An image element with a fallback for representing the user. source: packages/raystack/components/avatar +tag: new --- import { diff --git a/apps/www/src/content/docs/components/avatar/props.ts b/apps/www/src/content/docs/components/avatar/props.ts index 0180ca048..c8db83473 100644 --- a/apps/www/src/content/docs/components/avatar/props.ts +++ b/apps/www/src/content/docs/components/avatar/props.ts @@ -44,8 +44,14 @@ export interface AvatarProps { | 'crimson' | 'gold'; - /** Boolean to merge props onto child element */ - asChild?: boolean; + /** + * Allows you to replace the component's HTML element with a different tag, + * or compose it with another component. Accepts a ReactElement or a function + * that returns the element to render. + * + * @remarks `ReactElement | function` + */ + render?: React.ReactElement; /** Additional CSS class names */ className?: string; diff --git a/packages/raystack/components/avatar/avatar.tsx b/packages/raystack/components/avatar/avatar.tsx index 838c47619..ee0a7170c 100644 --- a/packages/raystack/components/avatar/avatar.tsx +++ b/packages/raystack/components/avatar/avatar.tsx @@ -1,14 +1,12 @@ -import { VariantProps, cva, cx } from 'class-variance-authority'; -import { Avatar as AvatarPrimitive } from 'radix-ui'; +import { Avatar as AvatarPrimitive } from '@base-ui/react/avatar'; +import { cva, cx, VariantProps } from 'class-variance-authority'; import { ComponentPropsWithoutRef, - ElementRef, - ReactElement, - ReactNode, forwardRef, - isValidElement + isValidElement, + ReactElement, + ReactNode } from 'react'; -import { Box } from '../box'; import styles from './avatar.module.css'; import { AVATAR_COLORS } from './utils'; @@ -133,15 +131,17 @@ const image = cva(styles.image); * @desc Recursively get the avatar props even if it's * wrapped in another component like Tooltip, Flex, etc. */ -export const getAvatarProps = (element: ReactElement): AvatarProps => { - const { props } = element; +export const getAvatarProps = ( + element: ReactElement +): AvatarProps => { + const props = element.props as AvatarProps & { children?: ReactNode }; if (element.type === Avatar) { return props; } if (props.children) { - if (isValidElement(props.children)) { + if (isValidElement(props.children)) { return getAvatarProps(props.children); } } @@ -149,7 +149,7 @@ export const getAvatarProps = (element: ReactElement): AvatarProps => { }; export interface AvatarProps - extends ComponentPropsWithoutRef, + extends AvatarPrimitive.Root.Props, VariantProps { size?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13; src?: string; @@ -157,30 +157,28 @@ export interface AvatarProps fallback?: ReactNode; variant?: 'solid' | 'soft'; color?: AVATAR_COLORS; - asChild?: boolean; className?: string; } -const AvatarRoot = forwardRef< - ElementRef, - AvatarProps ->( +const AvatarRoot = forwardRef( ( { className, alt, src, fallback, size, radius, variant, color, ...props }, ref ) => ( - - - - - {fallback} - - - + + + + {fallback} + + ) ); diff --git a/packages/raystack/components/avatar/index.tsx b/packages/raystack/components/avatar/index.tsx index 70db771c1..80be713d5 100644 --- a/packages/raystack/components/avatar/index.tsx +++ b/packages/raystack/components/avatar/index.tsx @@ -1,2 +1,2 @@ -export { Avatar, AvatarGroup } from "./avatar"; -export { getAvatarColor } from "./utils"; +export { Avatar, AvatarGroup } from './avatar'; +export { getAvatarColor } from './utils'; From e46d9ccb3cf408e37fa9424de078da57b62a0ec5 Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Wed, 28 Jan 2026 15:26:40 +0530 Subject: [PATCH 04/12] fix: avatar tests --- .../avatar/__tests__/avatar.test.tsx | 25 +++++++++++++------ 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/packages/raystack/components/avatar/__tests__/avatar.test.tsx b/packages/raystack/components/avatar/__tests__/avatar.test.tsx index d8ff32a3c..641060525 100644 --- a/packages/raystack/components/avatar/__tests__/avatar.test.tsx +++ b/packages/raystack/components/avatar/__tests__/avatar.test.tsx @@ -1,5 +1,5 @@ import { render, screen } from '@testing-library/react'; -import { describe, expect, it, vi } from 'vitest'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import { Avatar, AvatarGroup } from '../avatar'; import styles from '../avatar.module.css'; import { getAvatarColor } from '../utils'; @@ -312,6 +312,8 @@ describe('Avatar', () => { class MockImage extends EventTarget { _src: string = ''; + _complete: boolean = false; + onload: (() => void) | null = null; constructor() { super(); @@ -327,20 +329,27 @@ class MockImage extends EventTarget { return; } this._src = src; - this.onSrcChange(); + // Simulate async image loading + setTimeout(() => { + this._complete = true; + // Call onload callback if set + if (this.onload) { + this.onload(); + } + // Also dispatch the event + this.dispatchEvent(new Event('load')); + }, 0); } get complete() { - return !this.src; + return this._complete; } get naturalWidth() { - return this.complete ? 300 : 0; + return this._complete ? 300 : 0; } - onSrcChange() { - setTimeout(() => { - this.dispatchEvent(new Event('load')); - }, 100); + get naturalHeight() { + return this._complete ? 300 : 0; } } From eccbe8fe73a9e492a14ffc8d2fbb26701b879f2b Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Wed, 28 Jan 2026 21:38:25 +0530 Subject: [PATCH 05/12] feat: migrate radio --- .../components/typetable/typetable.module.css | 5 + .../src/components/typetable/typetable.tsx | 12 +- .../src/content/docs/components/radio/demo.ts | 58 +++-- .../content/docs/components/radio/index.mdx | 9 +- .../content/docs/components/radio/props.ts | 54 +++-- .../components/radio/__tests__/radio.test.tsx | 201 +++++++----------- packages/raystack/components/radio/index.ts | 2 +- .../components/radio/radio.module.css | 12 +- packages/raystack/components/radio/radio.tsx | 81 +++---- 9 files changed, 198 insertions(+), 236 deletions(-) diff --git a/apps/www/src/components/typetable/typetable.module.css b/apps/www/src/components/typetable/typetable.module.css index 305aea381..9d422fb25 100644 --- a/apps/www/src/components/typetable/typetable.module.css +++ b/apps/www/src/components/typetable/typetable.module.css @@ -99,3 +99,8 @@ flex-wrap: wrap; gap: var(--rs-space-1); } +.required { + color: var(--rs-color-foreground-danger-primary); + position: relative; + top: -4px; +} diff --git a/apps/www/src/components/typetable/typetable.tsx b/apps/www/src/components/typetable/typetable.tsx index 90ec1d63d..2f15a29ff 100644 --- a/apps/www/src/components/typetable/typetable.tsx +++ b/apps/www/src/components/typetable/typetable.tsx @@ -45,7 +45,10 @@ export interface TypeNode { export function TypeTable({ type, className -}: { type: Record; className?: string }) { +}: { + type: Record; + className?: string; +}) { const entries = Object.entries(type); return ( @@ -87,7 +90,7 @@ function Item({ className={deprecated ? styles.propNameDeprecated : styles.propName} > {name} - {!required && '?'} + {required ? * : ''} {typeDescriptionLink ? ( @@ -130,7 +133,10 @@ function Item({ language='tsx' className={cx(styles.fieldCode, styles.fieldValue)} > - {String(typeDescription ?? type)} + {String(type) + + (!required && !String(type).includes('undefined') + ? ' | undefined' + : '')} diff --git a/apps/www/src/content/docs/components/radio/demo.ts b/apps/www/src/content/docs/components/radio/demo.ts index b0a3ca802..f52931f60 100644 --- a/apps/www/src/content/docs/components/radio/demo.ts +++ b/apps/www/src/content/docs/components/radio/demo.ts @@ -3,22 +3,22 @@ export const preview = { type: 'code', code: ` - - + + - - - - - - - - - - + + + + + + + + + + - ` + ` }; export const stateDemo = { @@ -27,22 +27,22 @@ export const stateDemo = { { name: 'Default', code: ` - + - + -` +` }, { name: 'Disabled', code: ` - + - + -` +` } ] }; @@ -50,22 +50,20 @@ export const stateDemo = { export const labelDemo = { type: 'code', code: ` - - + - + - + - + - - ` + ` }; export const formDemo = { @@ -77,18 +75,18 @@ export const formDemo = { alert(JSON.stringify(Object.fromEntries(formData))); }}> - + - + - + - + ` diff --git a/apps/www/src/content/docs/components/radio/index.mdx b/apps/www/src/content/docs/components/radio/index.mdx index 0123ac444..2433ca45c 100644 --- a/apps/www/src/content/docs/components/radio/index.mdx +++ b/apps/www/src/content/docs/components/radio/index.mdx @@ -16,13 +16,14 @@ import { Radio } from "@raystack/apsara"; ## Radio Props -### Radio Props +### Radio.Group Props + + - +### Radio Props -### Radio.Item Props + - ## Examples diff --git a/apps/www/src/content/docs/components/radio/props.ts b/apps/www/src/content/docs/components/radio/props.ts index b2a311d64..b490a44e9 100644 --- a/apps/www/src/content/docs/components/radio/props.ts +++ b/apps/www/src/content/docs/components/radio/props.ts @@ -1,12 +1,12 @@ -export interface RadioRootProps { +export interface RadioGroupProps { /** The value of the radio item that should be checked by default. */ - defaultValue?: string; + defaultValue?: any; /** The controlled value of the radio item that is checked. */ - value?: string; + value?: any; /** Event handler called when the value changes. */ - onValueChange?: (value: string) => void; + onValueChange?: (value: any, event: Event) => void; /** When true, prevents user interaction with the radio group. */ disabled?: boolean; @@ -14,29 +14,43 @@ export interface RadioRootProps { /** The name of the radio group when submitted as a form field. */ name?: string; - /** When true, indicates that a value must be selected before the form can be submitted. */ - required?: boolean; - - /** The orientation of the radio group. */ - orientation?: 'horizontal' | 'vertical'; - - /** The reading direction of the radio group. */ - dir?: 'ltr' | 'rtl'; - - /** A label for the radio group that is announced by screen readers. */ - ariaLabel?: string; + /** Additional CSS class name. */ + className?: string; + + /** + * Allows you to replace the component's HTML element with a different tag, or compose it with another component. + * Accepts a `ReactElement` or a function that returns the element to render. + * + * @remarks `ReactElement | function` + */ + render?: + | React.ReactElement + | ((props: React.HTMLAttributes) => React.ReactElement); } -export interface RadioItemProps { +export interface RadioProps { /** The unique value of the radio item. */ - value: string; + value: any; /** When true, prevents user interaction with this radio item. */ disabled?: boolean; - /** When true, indicates that this radio item must be checked. */ - required?: boolean; - /** The unique identifier for the radio item. */ id?: string; + + /** Additional CSS class name. */ + className?: string; + + /** + * Allows you to replace the component's HTML element with a different tag, or compose it with another component. + * Accepts a `ReactElement` or a function that returns the element to render. + * + * @remarks `ReactElement | function` + */ + render?: + | React.ReactElement + | (( + props: React.HTMLAttributes, + state: { checked: boolean } + ) => React.ReactElement); } diff --git a/packages/raystack/components/radio/__tests__/radio.test.tsx b/packages/raystack/components/radio/__tests__/radio.test.tsx index 00cd7ff89..8845b06ca 100644 --- a/packages/raystack/components/radio/__tests__/radio.test.tsx +++ b/packages/raystack/components/radio/__tests__/radio.test.tsx @@ -1,15 +1,15 @@ import { fireEvent, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { describe, expect, it, vi } from 'vitest'; -import { Radio, RadioItem } from '../radio'; +import { Radio } from '../radio'; describe('Radio', () => { describe('Basic Rendering', () => { it('renders radio group', () => { render( - - - + + + ); const radioGroup = screen.getByRole('radiogroup'); expect(radioGroup).toBeInTheDocument(); @@ -17,11 +17,11 @@ describe('Radio', () => { it('renders multiple radio items', () => { render( - - - - - + + + + + ); const radios = screen.getAllByRole('radio'); @@ -32,10 +32,10 @@ describe('Radio', () => { describe('Selection Behavior', () => { it('allows single selection', () => { render( - - - - + + + + ); const [radio1, radio2] = screen.getAllByRole('radio'); @@ -51,11 +51,11 @@ describe('Radio', () => { it('works with defaultValue', () => { render( - - - - - + + + + + ); const radios = screen.getAllByRole('radio'); @@ -66,10 +66,10 @@ describe('Radio', () => { it('works as controlled component', () => { const { rerender } = render( - - - - + + + + ); const [radio1, radio2] = screen.getAllByRole('radio'); @@ -77,10 +77,10 @@ describe('Radio', () => { expect(radio2).not.toBeChecked(); rerender( - - - - + + + + ); expect(radio1).not.toBeChecked(); @@ -90,56 +90,56 @@ describe('Radio', () => { it('calls onValueChange when selection changes', () => { const handleChange = vi.fn(); render( - - - - + + + + ); const radio2 = screen.getAllByRole('radio')[1]; fireEvent.click(radio2); - expect(handleChange).toHaveBeenCalledWith('option2'); + expect(handleChange).toHaveBeenCalledWith('option2', expect.anything()); }); }); describe('Disabled State', () => { it('disables entire radio group', () => { render( - - - - + + + + ); const radios = screen.getAllByRole('radio'); radios.forEach(radio => { - expect(radio).toBeDisabled(); + expect(radio).toHaveAttribute('data-disabled'); }); }); it('disables individual radio items', () => { render( - - - - - + + + + + ); const radios = screen.getAllByRole('radio'); - expect(radios[0]).not.toBeDisabled(); - expect(radios[1]).toBeDisabled(); - expect(radios[2]).not.toBeDisabled(); + expect(radios[0]).not.toHaveAttribute('data-disabled'); + expect(radios[1]).toHaveAttribute('data-disabled'); + expect(radios[2]).not.toHaveAttribute('data-disabled'); }); it('does not allow selection of disabled items', () => { const handleChange = vi.fn(); render( - - - - + + + + ); const disabledRadio = screen.getAllByRole('radio')[1]; @@ -154,17 +154,17 @@ describe('Radio', () => { it('supports arrow key navigation', async () => { const user = userEvent.setup(); render( - - - - - + + + + + ); const [radio1, radio2, radio3] = screen.getAllByRole('radio'); // Focus first radio - await radio1.focus(); + radio1.focus(); expect(document.activeElement).toBe(radio1); // Arrow down should move to next @@ -183,17 +183,17 @@ describe('Radio', () => { it('wraps around when navigating past boundaries', async () => { const user = userEvent.setup(); render( - - - - - + + + + + ); const [radio1, , radio3] = screen.getAllByRole('radio'); // Focus last radio - await radio3.focus(); + radio3.focus(); // Arrow down from last should wrap to first await user.keyboard('{ArrowDown}'); @@ -208,9 +208,9 @@ describe('Radio', () => { describe('Accessibility', () => { it('has correct ARIA attributes on group', () => { render( - - - + + + ); const radioGroup = screen.getByRole('radiogroup'); @@ -219,10 +219,10 @@ describe('Radio', () => { it('has correct ARIA attributes on items', () => { render( - - - - + + + + ); const radio1 = screen.getByLabelText('First option'); @@ -231,76 +231,31 @@ describe('Radio', () => { expect(radio1).toHaveAttribute('aria-checked', 'true'); expect(radio2).toHaveAttribute('aria-checked', 'false'); }); - - it('supports required attribute', () => { - render( - - - - ); - - const radioGroup = screen.getByRole('radiogroup'); - expect(radioGroup).toHaveAttribute('aria-required', 'true'); - }); - }); - - describe('Form Integration', () => { - it('works with form name attribute', () => { - const { container } = render( -
- - - - -
- ); - - const radios = container.querySelectorAll('input[type="radio"]'); - radios.forEach(radio => { - expect(radio).toHaveAttribute('name', 'preference'); - }); - }); - - it('respects form disabled state', () => { - render( -
- - - - -
- ); - - const radios = screen.getAllByRole('radio'); - radios.forEach(radio => { - expect(radio).toBeDisabled(); - }); - }); }); describe('Data Attributes', () => { - it('has data-state attribute on items', () => { + it('has data-checked attribute on selected items', () => { render( - - - - + + + + ); const [radio1, radio2] = screen.getAllByRole('radio'); - expect(radio1).toHaveAttribute('data-state', 'checked'); - expect(radio2).toHaveAttribute('data-state', 'unchecked'); + expect(radio1).toHaveAttribute('data-checked'); + expect(radio2).toHaveAttribute('data-unchecked'); }); it('has data-disabled attribute when disabled', () => { render( - - - + + + ); const radio = screen.getByRole('radio'); - expect(radio).toHaveAttribute('data-disabled', ''); + expect(radio).toHaveAttribute('data-disabled'); }); }); }); diff --git a/packages/raystack/components/radio/index.ts b/packages/raystack/components/radio/index.ts index c8a266a51..d9dae10e1 100644 --- a/packages/raystack/components/radio/index.ts +++ b/packages/raystack/components/radio/index.ts @@ -1 +1 @@ -export { Radio } from "./radio"; +export { Radio } from './radio'; diff --git a/packages/raystack/components/radio/radio.module.css b/packages/raystack/components/radio/radio.module.css index 74285b704..a30d81675 100644 --- a/packages/raystack/components/radio/radio.module.css +++ b/packages/raystack/components/radio/radio.module.css @@ -24,12 +24,12 @@ background: var(--rs-color-background-base-primary-hover); } -.radioitem[data-state="checked"] { +.radioitem[data-checked] { border: 1px solid var(--rs-color-background-accent-emphasis); background: var(--rs-color-background-accent-emphasis); } -.radioitem[data-state="checked"]:hover { +.radioitem[data-checked]:hover { border-color: var(--rs-color-background-accent-emphasis-hover); background: var(--rs-color-background-accent-emphasis-hover); } @@ -41,8 +41,8 @@ cursor: not-allowed; } -.radioitem[data-disabled][data-state="checked"], -.radioitem[data-disabled][data-state="checked"]:hover { +.radioitem[data-disabled][data-checked], +.radioitem[data-disabled][data-checked]:hover { background: var(--rs-color-background-accent-primary); border-color: var(--rs-color-background-accent-primary); } @@ -57,7 +57,7 @@ } .indicator::after { - content: ''; + content: ""; display: block; width: var(--rs-radius-3); height: var(--rs-radius-3); @@ -67,4 +67,4 @@ .radioitem[data-disabled] .indicator::after { background: var(--rs-color-foreground-base-emphasis); -} \ No newline at end of file +} diff --git a/packages/raystack/components/radio/radio.tsx b/packages/raystack/components/radio/radio.tsx index c79e2d1ea..ff1d0ac93 100644 --- a/packages/raystack/components/radio/radio.tsx +++ b/packages/raystack/components/radio/radio.tsx @@ -1,53 +1,36 @@ -import { VariantProps, cva } from 'class-variance-authority'; -import { RadioGroup as RadioGroupPrimitive } from 'radix-ui'; -import { ComponentPropsWithoutRef, ElementRef, forwardRef } from 'react'; +import { Radio as RadioPrimitive } from '@base-ui/react/radio'; +import { RadioGroup as RadioGroupPrimitive } from '@base-ui/react/radio-group'; +import { cx } from 'class-variance-authority'; +import { forwardRef } from 'react'; import styles from './radio.module.css'; -const RadioRoot = forwardRef< - ElementRef, - ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); - -const radioItem = cva(styles.radioitem); - -export interface RadioItemProps - extends ComponentPropsWithoutRef {} - -export const RadioItem = forwardRef< - ElementRef, - RadioItemProps ->(({ className, ...props }, forwardedRef) => ( - - - -)); - -const indicator = cva(styles.indicator); -export interface thumbProps - extends ComponentPropsWithoutRef, - VariantProps {} - -const Indicator = forwardRef< - ElementRef, - thumbProps ->(({ className, ...props }, ref) => ( - -)); - -Indicator.displayName = RadioGroupPrimitive.Indicator.displayName; - -export const Radio = Object.assign(RadioRoot, { - Indicator: Indicator, - Item: RadioItem +const RadioGroup = forwardRef( + ({ className, ...props }, ref) => ( + + ) +); + +RadioGroup.displayName = 'Radio.Group'; + +const RadioItem = forwardRef( + ({ className, ...props }, forwardedRef) => ( + + + + ) +); + +RadioItem.displayName = 'Radio'; + +export const Radio = Object.assign(RadioItem, { + Group: RadioGroup }); From 876a228189682d285158812412283ccf8af7412b Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Wed, 28 Jan 2026 21:55:30 +0530 Subject: [PATCH 06/12] feat: migrate separator props --- .../content/docs/components/separator/index.mdx | 9 --------- .../content/docs/components/separator/props.ts | 13 +++++++++++++ .../separator/__tests__/separator.test.tsx | 16 ++-------------- .../raystack/components/separator/separator.tsx | 14 +++----------- 4 files changed, 18 insertions(+), 34 deletions(-) diff --git a/apps/www/src/content/docs/components/separator/index.mdx b/apps/www/src/content/docs/components/separator/index.mdx index 3f7609c0e..af77f90cd 100644 --- a/apps/www/src/content/docs/components/separator/index.mdx +++ b/apps/www/src/content/docs/components/separator/index.mdx @@ -37,12 +37,3 @@ The Separator component supports three sizes. Separator can be rendered in both horizontal and vertical orientations. - -## Accessibility - -The Separator component follows accessibility best practices: - -- Uses `role="separator"` by default -- Is marked as `decorative` for visual separation -- Maintains proper color contrast -- Supports proper semantic structure in both orientations diff --git a/apps/www/src/content/docs/components/separator/props.ts b/apps/www/src/content/docs/components/separator/props.ts index 896b961c9..c6a7f2dde 100644 --- a/apps/www/src/content/docs/components/separator/props.ts +++ b/apps/www/src/content/docs/components/separator/props.ts @@ -16,4 +16,17 @@ export interface SeparatorProps { /** Additional CSS class names. */ className?: string; + + /** + * Allows you to replace the component's HTML element with a different tag, or compose it with another component. + * Accepts a `ReactElement` or a function that returns the element to render. + * + * @remarks `ReactElement | function` + */ + render?: + | React.ReactElement + | (( + props: React.HTMLAttributes, + state: { orientation: 'horizontal' | 'vertical' } + ) => React.ReactElement); } diff --git a/packages/raystack/components/separator/__tests__/separator.test.tsx b/packages/raystack/components/separator/__tests__/separator.test.tsx index 84ec9be8b..9fbc6c7e1 100644 --- a/packages/raystack/components/separator/__tests__/separator.test.tsx +++ b/packages/raystack/components/separator/__tests__/separator.test.tsx @@ -79,28 +79,16 @@ describe('Separator', () => { expect(screen.getByRole('separator')).toBeInTheDocument(); }); - it('has default aria-label for horizontal separator', () => { - render(); - const separator = screen.getByRole('separator'); - expect(separator).toHaveAttribute('aria-label', 'horizontal separator'); - }); - - it('has default aria-label for vertical separator', () => { - render(); - const separator = screen.getByRole('separator'); - expect(separator).toHaveAttribute('aria-label', 'vertical separator'); - }); - it('supports custom aria-label', () => { render(); const separator = screen.getByRole('separator'); expect(separator).toHaveAttribute('aria-label', 'Section divider'); }); - it('is marked as decorative', () => { + it('has aria-orientation attribute', () => { render(); const separator = screen.getByRole('separator'); - expect(separator).toHaveAttribute('aria-orientation'); + expect(separator).toHaveAttribute('aria-orientation', 'horizontal'); }); }); }); diff --git a/packages/raystack/components/separator/separator.tsx b/packages/raystack/components/separator/separator.tsx index 9aef13b14..fb98b3db7 100644 --- a/packages/raystack/components/separator/separator.tsx +++ b/packages/raystack/components/separator/separator.tsx @@ -1,5 +1,5 @@ +import { Separator as SeparatorPrimitive } from '@base-ui/react/separator'; import { cva } from 'class-variance-authority'; -import { Separator as SeparatorPrimitive } from 'radix-ui'; import styles from './separator.module.css'; @@ -22,12 +22,9 @@ const separator = cva(styles.separator, { } }); -interface SeparatorProps { - className?: string; - orientation?: 'horizontal' | 'vertical'; +interface SeparatorProps extends SeparatorPrimitive.Props { size?: 'small' | 'half' | 'full'; color?: 'primary' | 'secondary' | 'tertiary'; - 'aria-label'?: string; } export function Separator({ @@ -35,17 +32,12 @@ export function Separator({ orientation = 'horizontal', size, color, - 'aria-label': ariaLabel, ...props }: SeparatorProps) { return ( - ); From 1d4b6d1b103b8e4485312270bf917f13861e7dfa Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Wed, 28 Jan 2026 23:01:43 +0530 Subject: [PATCH 07/12] feat: migrate switch --- .../content/docs/components/switch/index.mdx | 10 ---- .../content/docs/components/switch/props.ts | 26 +++++++- .../switch/__tests__/switch.test.tsx | 58 +++++++----------- .../components/switch/switch.module.css | 14 ++--- .../raystack/components/switch/switch.tsx | 59 ++++--------------- 5 files changed, 65 insertions(+), 102 deletions(-) diff --git a/apps/www/src/content/docs/components/switch/index.mdx b/apps/www/src/content/docs/components/switch/index.mdx index 8110505ff..d3f75b98c 100644 --- a/apps/www/src/content/docs/components/switch/index.mdx +++ b/apps/www/src/content/docs/components/switch/index.mdx @@ -37,13 +37,3 @@ The Switch component comes in two sizes: large (default) and small. Use the Switch component in a controlled manner to manage its state externally. - -## Accessibility - -The Switch component follows WAI-ARIA guidelines for toggle buttons: - -- Uses proper ARIA attributes (`aria-checked`, `aria-required`, `aria-label`) -- Supports keyboard navigation (Space and Enter to toggle) -- Includes proper labeling and description support -- Changes cursor to 'not-allowed' when disabled -- Associates labels with the switch using htmlFor diff --git a/apps/www/src/content/docs/components/switch/props.ts b/apps/www/src/content/docs/components/switch/props.ts index c4cc9d2be..baa6000c5 100644 --- a/apps/www/src/content/docs/components/switch/props.ts +++ b/apps/www/src/content/docs/components/switch/props.ts @@ -6,7 +6,7 @@ export interface SwitchProps { defaultChecked?: boolean; /** Event handler called when the checked state changes. */ - onCheckedChange?: (checked: boolean) => void; + onCheckedChange?: (checked: boolean, event: Event) => void; /** When true, prevents the user from interacting with the switch. */ disabled?: boolean; @@ -22,4 +22,28 @@ export interface SwitchProps { /** A unique identifier for the switch. */ id?: string; + + /** Identifies the field when a form is submitted. */ + name?: string; + + /** Additional CSS class names. */ + className?: string; + + /** + * Allows you to replace the component's HTML element with a different tag, or compose it with another component. + * Accepts a `ReactElement` or a function that returns the element to render. + * + * @remarks `ReactElement | function` + */ + render?: + | React.ReactElement + | (( + props: React.HTMLAttributes, + state: { + checked: boolean; + disabled: boolean; + readOnly: boolean; + required: boolean; + } + ) => React.ReactElement); } diff --git a/packages/raystack/components/switch/__tests__/switch.test.tsx b/packages/raystack/components/switch/__tests__/switch.test.tsx index 7b008402c..51980567a 100644 --- a/packages/raystack/components/switch/__tests__/switch.test.tsx +++ b/packages/raystack/components/switch/__tests__/switch.test.tsx @@ -42,7 +42,7 @@ describe('Switch', () => { describe('Sizes', () => { const sizes = ['small', 'large'] as const; sizes.forEach(size => { - it(`renders ${size} size by default`, () => { + it(`renders ${size} size`, () => { render(); const switchElement = screen.getByRole('switch'); expect(switchElement).toHaveClass(styles[size]); @@ -60,14 +60,14 @@ describe('Switch', () => { render(); const switchElement = screen.getByRole('switch'); expect(switchElement).toHaveAttribute('aria-checked', 'false'); - expect(switchElement).toHaveAttribute('data-state', 'unchecked'); + expect(switchElement).toHaveAttribute('data-unchecked'); }); it('renders as checked when checked prop is true', () => { render(); const switchElement = screen.getByRole('switch'); expect(switchElement).toHaveAttribute('aria-checked', 'true'); - expect(switchElement).toHaveAttribute('data-state', 'checked'); + expect(switchElement).toHaveAttribute('data-checked'); }); it('renders with defaultChecked', () => { @@ -92,8 +92,7 @@ describe('Switch', () => { it('renders as disabled when disabled prop is true', () => { render(); const switchElement = screen.getByRole('switch'); - expect(switchElement).toBeDisabled(); - expect(switchElement).toHaveAttribute('data-disabled', 'true'); + expect(switchElement).toHaveAttribute('data-disabled'); }); it('does not toggle when disabled', () => { @@ -110,20 +109,19 @@ describe('Switch', () => { it('can be disabled while checked', () => { render(); const switchElement = screen.getByRole('switch'); - expect(switchElement).toBeDisabled(); + expect(switchElement).toHaveAttribute('data-disabled'); expect(switchElement).toHaveAttribute('aria-checked', 'true'); - expect(switchElement).toHaveAttribute('data-disabled', 'true'); }); it('maintains disabled state with different sizes', () => { const { rerender } = render(); let switchElement = screen.getByRole('switch'); - expect(switchElement).toBeDisabled(); + expect(switchElement).toHaveAttribute('data-disabled'); expect(switchElement).toHaveClass(styles.small); rerender(); switchElement = screen.getByRole('switch'); - expect(switchElement).toBeDisabled(); + expect(switchElement).toHaveAttribute('data-disabled'); expect(switchElement).toHaveClass(styles.large); }); }); @@ -137,7 +135,7 @@ describe('Switch', () => { fireEvent.click(switchElement); expect(handleChange).toHaveBeenCalledTimes(1); - expect(handleChange).toHaveBeenCalledWith(true); + expect(handleChange).toHaveBeenCalledWith(true, expect.anything()); }); it('toggles from checked to unchecked', () => { @@ -147,7 +145,7 @@ describe('Switch', () => { const switchElement = screen.getByRole('switch'); fireEvent.click(switchElement); - expect(handleChange).toHaveBeenCalledWith(false); + expect(handleChange).toHaveBeenCalledWith(false, expect.anything()); }); it('supports focus events', () => { @@ -169,14 +167,10 @@ describe('Switch', () => { render(); const switchElement = screen.getByRole('switch'); - await switchElement.focus(); + switchElement.focus(); await user.keyboard('[Space]'); - expect(handleChange).toHaveBeenCalledWith(true); - await user.keyboard('[Enter]'); - - expect(handleChange).toHaveBeenCalledWith(false); - expect(handleChange).toHaveBeenCalledTimes(2); + expect(handleChange).toHaveBeenCalledWith(true, expect.anything()); }); }); @@ -221,14 +215,14 @@ describe('Switch', () => { it('supports required attribute', () => { render(); const switchElement = screen.getByRole('switch'); - expect(switchElement).toHaveAttribute('aria-required', 'true'); + expect(switchElement).toHaveAttribute('data-required'); }); it('works with required and disabled', () => { render(); const switchElement = screen.getByRole('switch'); - expect(switchElement).toHaveAttribute('aria-required', 'true'); - expect(switchElement).toBeDisabled(); + expect(switchElement).toHaveAttribute('data-required'); + expect(switchElement).toHaveAttribute('data-disabled'); }); }); @@ -273,43 +267,31 @@ describe('Switch', () => { const switchElement = screen.getByRole('switch'); expect(switchElement).toHaveAttribute('aria-describedby', 'description'); }); - - it('supports aria-invalid', () => { - render(); - const switchElement = screen.getByRole('switch'); - expect(switchElement).toHaveAttribute('aria-invalid', 'true'); - }); }); - describe('HTML Attributes', () => { - it('supports name attribute', () => { - render(); - const hiddenInput = document.querySelector('input[name="notifications"]'); - expect(hiddenInput).toHaveAttribute('name', 'notifications'); - }); - + describe('Data Attributes', () => { it('supports data attributes', () => { render(); const switchElement = screen.getByTestId('custom-switch'); expect(switchElement).toHaveAttribute('data-theme', 'dark'); }); - it('has data-state attribute for unchecked state', () => { + it('has data-unchecked attribute for unchecked state', () => { render(); const switchElement = screen.getByRole('switch'); - expect(switchElement).toHaveAttribute('data-state', 'unchecked'); + expect(switchElement).toHaveAttribute('data-unchecked'); }); - it('has data-state attribute for checked state', () => { + it('has data-checked attribute for checked state', () => { render(); const switchElement = screen.getByRole('switch'); - expect(switchElement).toHaveAttribute('data-state', 'checked'); + expect(switchElement).toHaveAttribute('data-checked'); }); it('has data-disabled attribute when disabled', () => { render(); const switchElement = screen.getByRole('switch'); - expect(switchElement).toHaveAttribute('data-disabled', 'true'); + expect(switchElement).toHaveAttribute('data-disabled'); }); it('does not have data-disabled when enabled', () => { diff --git a/packages/raystack/components/switch/switch.module.css b/packages/raystack/components/switch/switch.module.css index d6cce5ba9..9b1827901 100644 --- a/packages/raystack/components/switch/switch.module.css +++ b/packages/raystack/components/switch/switch.module.css @@ -21,24 +21,24 @@ height: 16px; } -.switch:not([data-disabled="true"]):hover { +.switch:not([data-disabled]):hover { background: var(--rs-color-background-neutral-secondary-hover); } -.switch[data-state="checked"] { +.switch[data-checked] { background: var(--rs-color-background-accent-emphasis); } -.switch[data-state="checked"]:not([data-disabled="true"]):hover { +.switch[data-checked]:not([data-disabled]):hover { background: var(--rs-color-background-accent-emphasis-hover); } -.switch[data-disabled="true"] { +.switch[data-disabled] { cursor: not-allowed; background: var(--rs-color-background-neutral-primary); } -.switch[data-disabled="true"][data-state="checked"] { +.switch[data-disabled][data-checked] { background: var(--rs-color-background-accent-primary); } @@ -60,11 +60,11 @@ height: 12px; } -.switch[data-state="checked"] .thumb { +.switch[data-checked] .thumb { transform: translateX(16px); } /* Small switch thumb positioning */ -.switch.small[data-state="checked"] .thumb { +.switch.small[data-checked] .thumb { transform: translateX(12px); } diff --git a/packages/raystack/components/switch/switch.tsx b/packages/raystack/components/switch/switch.tsx index bc0a40606..dcdc9ad52 100644 --- a/packages/raystack/components/switch/switch.tsx +++ b/packages/raystack/components/switch/switch.tsx @@ -1,6 +1,6 @@ -import { VariantProps, cva, cx } from 'class-variance-authority'; -import { Switch as SwitchPrimitive } from 'radix-ui'; -import { ComponentPropsWithoutRef, ElementRef, forwardRef } from 'react'; +import { Switch as SwitchPrimitive } from '@base-ui/react/switch'; +import { cva, type VariantProps } from 'class-variance-authority'; +import { forwardRef } from 'react'; import styles from './switch.module.css'; @@ -17,52 +17,19 @@ const switchVariants = cva(styles.switch, { }); export interface SwitchProps - extends ComponentPropsWithoutRef, + extends SwitchPrimitive.Root.Props, VariantProps {} -export const Switch = forwardRef< - ElementRef, - SwitchProps ->( - ( - { className, disabled, required, size, name, value, ...props }, - forwardedRef - ) => ( - <> - - - - {name && ( - - )} - +export const Switch = forwardRef( + ({ className, size, ...props }, forwardedRef) => ( + + + ) ); -interface ThumbProps - extends ComponentPropsWithoutRef {} - -const SwitchThumb = forwardRef< - ElementRef, - ThumbProps ->(({ className, ...props }, ref) => ( - -)); - Switch.displayName = 'Switch'; From 8b1773e2e14d7c6684f43c02f85f762ea1535fef Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Thu, 29 Jan 2026 10:06:52 +0530 Subject: [PATCH 08/12] feat: migrate scroll area --- .../docs/components/scroll-area/demo.ts | 21 ++-------- .../docs/components/scroll-area/index.mdx | 12 +----- .../docs/components/scroll-area/props.ts | 21 +++++++--- .../__tests__/scroll-area.test.tsx | 39 +++++++---------- .../raystack/components/scroll-area/index.ts | 3 +- .../scroll-area/scroll-area-root.tsx | 42 ------------------- .../scroll-area/scroll-area-scrollbar.tsx | 18 ++++---- .../scroll-area/scroll-area.module.css | 19 +++++++-- .../components/scroll-area/scroll-area.tsx | 35 +++++++++++++++- packages/raystack/index.tsx | 2 +- 10 files changed, 96 insertions(+), 116 deletions(-) delete mode 100644 packages/raystack/components/scroll-area/scroll-area-root.tsx diff --git a/apps/www/src/content/docs/components/scroll-area/demo.ts b/apps/www/src/content/docs/components/scroll-area/demo.ts index b797f6c04..b1128e296 100644 --- a/apps/www/src/content/docs/components/scroll-area/demo.ts +++ b/apps/www/src/content/docs/components/scroll-area/demo.ts @@ -21,8 +21,8 @@ export const playground = { controls: { type: { type: 'select', - options: ['auto', 'always', 'scroll', 'hover'], - defaultValue: 'auto' + options: ['always', 'hover', 'scroll'], + defaultValue: 'hover' } }, getCode @@ -100,9 +100,9 @@ export const typeDemo = { type: 'code', tabs: [ { - name: 'Auto (default)', + name: 'Hover (default)', code: ` - + {Array.from({ length: 20 }, (_, i) => ( @@ -123,19 +123,6 @@ export const typeDemo = { ))} -` - }, - { - name: 'Hover', - code: ` - - - {Array.from({ length: 20 }, (_, i) => ( - - Item {i + 1} - - ))} - ` }, { diff --git a/apps/www/src/content/docs/components/scroll-area/index.mdx b/apps/www/src/content/docs/components/scroll-area/index.mdx index a8cd98d93..c2a3ce592 100644 --- a/apps/www/src/content/docs/components/scroll-area/index.mdx +++ b/apps/www/src/content/docs/components/scroll-area/index.mdx @@ -25,7 +25,7 @@ import { ScrollArea } from "@raystack/apsara"; The Scroll Area component extends standard HTML div attributes, so you can use props like `style`, `id`, `onClick`, and other standard HTML attributes in addition to the props listed below. - + ## Examples @@ -61,13 +61,3 @@ Control when the scrollbar appears using the `type` prop. - **Auto Corner**: Corner element is automatically added when both scrollbars are visible - **Scroll Chaining**: Scroll continues to parent page when reaching container boundaries - **Customizable Visibility**: Control when scrollbars appear using the `type` prop - -## Accessibility - -The Scroll Area component is built on Radix UI primitives and provides: - -- Keyboard navigation support -- Screen reader compatibility -- Proper ARIA attributes -- Focus management - diff --git a/apps/www/src/content/docs/components/scroll-area/props.ts b/apps/www/src/content/docs/components/scroll-area/props.ts index d9347fc97..06b6d02d6 100644 --- a/apps/www/src/content/docs/components/scroll-area/props.ts +++ b/apps/www/src/content/docs/components/scroll-area/props.ts @@ -1,15 +1,14 @@ import type React from 'react'; -export interface ScrollAreaRootProps { +export interface ScrollAreaProps { /** * Controls when the scrollbar appears. - * - `auto`: Scrollbar appears only when content overflows (default) * - `always`: Scrollbar is always visible + * - `hover`: Scrollbar appears on hover (default) * - `scroll`: Scrollbar appears during scrolling - * - `hover`: Scrollbar appears on hover - * @default 'auto' + * @default 'hover' */ - type?: 'auto' | 'always' | 'scroll' | 'hover'; + type?: 'always' | 'hover' | 'scroll'; /** * Custom className for the root element. @@ -22,7 +21,17 @@ export interface ScrollAreaRootProps { style?: React.CSSProperties; /** - * The content to be scrolled. Both vertical and horizontal scrollbars are automatically rendered and shown when content overflows. + * The content to be scrolled. Both vertical and horizontal scrollbars are automatically rendered. */ children?: React.ReactNode; + + /** + * Allows you to replace the component's HTML element with a different tag, or compose it with another component. + * Accepts a `ReactElement` or a function that returns the element to render. + * + * @remarks `ReactElement | function` + */ + render?: + | React.ReactElement + | ((props: React.HTMLAttributes) => React.ReactElement); } diff --git a/packages/raystack/components/scroll-area/__tests__/scroll-area.test.tsx b/packages/raystack/components/scroll-area/__tests__/scroll-area.test.tsx index 368481641..7fd59a877 100644 --- a/packages/raystack/components/scroll-area/__tests__/scroll-area.test.tsx +++ b/packages/raystack/components/scroll-area/__tests__/scroll-area.test.tsx @@ -1,17 +1,16 @@ import { render, screen } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; -import { ScrollArea } from '../scroll-area'; -import { ScrollAreaRootProps } from '../scroll-area-root'; +import { ScrollArea, ScrollAreaProps } from '../scroll-area'; import styles from '../scroll-area.module.css'; const CONTENT_TEXT = 'Scrollable content'; const TEST_ID = 'test-scroll-area'; const BasicScrollArea = ({ - type = 'auto', + type = 'hover', children, ...props -}: ScrollAreaRootProps) => ( +}: ScrollAreaProps) => ( {children} @@ -90,7 +89,7 @@ describe('ScrollArea', () => { }); describe('Type Prop', () => { - const types = ['auto', 'always', 'scroll', 'hover'] as const; + const types = ['always', 'hover', 'scroll'] as const; it.each(types)('renders with type %s', type => { const { container } = render( @@ -101,22 +100,26 @@ describe('ScrollArea', () => { const root = container.querySelector(`[data-testid="${TEST_ID}"]`); expect(root).toBeInTheDocument(); + + // Check scrollbar has the correct type class + const scrollbar = container.querySelector(`.${styles.scrollbar}`); + expect(scrollbar).toHaveClass(styles[`scrollbar-${type}`]); }); - it('defaults to auto type', () => { + it('defaults to hover type', () => { const { container } = render(
Content
); - const root = container.querySelector(`[data-testid="${TEST_ID}"]`); - expect(root).toBeInTheDocument(); + const scrollbar = container.querySelector(`.${styles.scrollbar}`); + expect(scrollbar).toHaveClass(styles['scrollbar-hover']); }); }); describe('Scrollbars', () => { - it('renders vertical scrollbar automatically', () => { + it('renders vertical scrollbar', () => { const { container } = render( { expect(scrollbar).toBeInTheDocument(); }); - it('renders horizontal scrollbar automatically', () => { + it('renders horizontal scrollbar', () => { const { container } = render( { ); - const scrollbar = container.querySelector( - `[data-orientation="vertical"]` - ); - expect(scrollbar).toBeInTheDocument(); - // Thumb is a child of scrollbar (Radix UI controls its rendering based on scroll position) - const thumb = scrollbar?.querySelector(`.${styles.thumb}`); - // If thumb exists, verify it has the correct class - if (thumb) { - expect(thumb).toHaveClass(styles.thumb); - } else { - // Thumb may not render if Radix UI determines no scroll is needed - // This is expected behavior - we verify the scrollbar structure is correct - expect(scrollbar).toBeInTheDocument(); - } + const thumb = container.querySelector(`.${styles.thumb}`); + expect(thumb).toBeInTheDocument(); }); }); diff --git a/packages/raystack/components/scroll-area/index.ts b/packages/raystack/components/scroll-area/index.ts index 1dab8413d..553a4c31d 100644 --- a/packages/raystack/components/scroll-area/index.ts +++ b/packages/raystack/components/scroll-area/index.ts @@ -1,3 +1,4 @@ +export type { ScrollAreaProps, ScrollAreaType } from './scroll-area'; export { ScrollArea } from './scroll-area'; -export type { ScrollAreaRootProps } from './scroll-area-root'; export type { ScrollAreaScrollbarProps } from './scroll-area-scrollbar'; +export { ScrollAreaScrollbar } from './scroll-area-scrollbar'; diff --git a/packages/raystack/components/scroll-area/scroll-area-root.tsx b/packages/raystack/components/scroll-area/scroll-area-root.tsx deleted file mode 100644 index a64ed95ec..000000000 --- a/packages/raystack/components/scroll-area/scroll-area-root.tsx +++ /dev/null @@ -1,42 +0,0 @@ -'use client'; - -import { cx } from 'class-variance-authority'; -import { ScrollArea as ScrollAreaPrimitive } from 'radix-ui'; -import { - ComponentPropsWithoutRef, - ComponentRef, - ReactNode, - forwardRef -} from 'react'; -import { ScrollAreaScrollbar } from './scroll-area-scrollbar'; -import styles from './scroll-area.module.css'; - -export interface ScrollAreaRootProps - extends ComponentPropsWithoutRef { - type?: 'auto' | 'always' | 'scroll' | 'hover'; - className?: string; - children?: ReactNode; -} - -export const ScrollAreaRoot = forwardRef< - ComponentRef, - ScrollAreaRootProps ->(({ className, type = 'auto', children, ...props }, ref) => { - return ( - - - {children} - - - - - - ); -}); - -ScrollAreaRoot.displayName = ScrollAreaPrimitive.Root.displayName; diff --git a/packages/raystack/components/scroll-area/scroll-area-scrollbar.tsx b/packages/raystack/components/scroll-area/scroll-area-scrollbar.tsx index b915a2fb7..bcb3ca4ce 100644 --- a/packages/raystack/components/scroll-area/scroll-area-scrollbar.tsx +++ b/packages/raystack/components/scroll-area/scroll-area-scrollbar.tsx @@ -1,25 +1,25 @@ 'use client'; +import { ScrollArea as ScrollAreaPrimitive } from '@base-ui/react/scroll-area'; import { cx } from 'class-variance-authority'; -import { ScrollArea as ScrollAreaPrimitive } from 'radix-ui'; -import { ComponentPropsWithoutRef, ComponentRef, forwardRef } from 'react'; +import { forwardRef } from 'react'; +import type { ScrollAreaType } from './scroll-area'; import styles from './scroll-area.module.css'; export interface ScrollAreaScrollbarProps - extends ComponentPropsWithoutRef { - orientation?: 'vertical' | 'horizontal'; - className?: string; + extends ScrollAreaPrimitive.Scrollbar.Props { + type?: ScrollAreaType; } export const ScrollAreaScrollbar = forwardRef< - ComponentRef, + HTMLDivElement, ScrollAreaScrollbarProps ->(({ className, orientation = 'vertical', ...props }, ref) => { +>(({ className, orientation = 'vertical', type = 'hover', ...props }, ref) => { return ( @@ -27,4 +27,4 @@ export const ScrollAreaScrollbar = forwardRef< ); }); -ScrollAreaScrollbar.displayName = ScrollAreaPrimitive.Scrollbar.displayName; +ScrollAreaScrollbar.displayName = 'ScrollAreaScrollbar'; diff --git a/packages/raystack/components/scroll-area/scroll-area.module.css b/packages/raystack/components/scroll-area/scroll-area.module.css index 86c424a97..39be70a82 100644 --- a/packages/raystack/components/scroll-area/scroll-area.module.css +++ b/packages/raystack/components/scroll-area/scroll-area.module.css @@ -25,7 +25,10 @@ background: transparent; pointer-events: auto; position: relative; - transition: width 150ms ease-out, height 150ms ease-out; + transition: + width 150ms ease-out, + height 150ms ease-out, + opacity 150ms ease-out; } .scrollbar[data-orientation="vertical"] { @@ -48,7 +51,6 @@ .thumb { flex: 1; background: var(--rs-color-border-base-primary); - /* TODO: Change to appropriate background var after the correct var is introduced */ border-radius: var(--rs-radius-2); transition: opacity 150ms ease-out; opacity: 1; @@ -58,4 +60,15 @@ .corner { background: transparent; -} \ No newline at end of file +} +.scrollbar:hover, +.scrollbar-always, +.scrollbar-hover[data-hovering], +.scrollbar-scroll[data-scrolling] { + opacity: 1; +} + +.scrollbar-hover, +.scrollbar-scroll { + opacity: 0; +} diff --git a/packages/raystack/components/scroll-area/scroll-area.tsx b/packages/raystack/components/scroll-area/scroll-area.tsx index bd45a551f..a6e0c767f 100644 --- a/packages/raystack/components/scroll-area/scroll-area.tsx +++ b/packages/raystack/components/scroll-area/scroll-area.tsx @@ -1,3 +1,34 @@ -import { ScrollAreaRoot } from './scroll-area-root'; +'use client'; -export const ScrollArea = ScrollAreaRoot; +import { ScrollArea as ScrollAreaPrimitive } from '@base-ui/react/scroll-area'; +import { cx } from 'class-variance-authority'; +import { forwardRef } from 'react'; +import styles from './scroll-area.module.css'; +import { ScrollAreaScrollbar } from './scroll-area-scrollbar'; + +export type ScrollAreaType = 'always' | 'hover' | 'scroll'; + +export interface ScrollAreaProps extends ScrollAreaPrimitive.Root.Props { + type?: ScrollAreaType; +} + +export const ScrollArea = forwardRef( + ({ className, type = 'hover', children, ...props }, ref) => { + return ( + + + {children} + + + + + + ); + } +); + +ScrollArea.displayName = 'ScrollArea'; diff --git a/packages/raystack/index.tsx b/packages/raystack/index.tsx index f7eed35ef..24d5d72e9 100644 --- a/packages/raystack/index.tsx +++ b/packages/raystack/index.tsx @@ -44,8 +44,8 @@ export { List } from './components/list'; export { Navbar } from './components/navbar'; export { Popover } from './components/popover'; export { Radio } from './components/radio'; -export { Search } from './components/search'; export { ScrollArea } from './components/scroll-area'; +export { Search } from './components/search'; export { Select } from './components/select'; export { Separator } from './components/separator'; export { Sheet } from './components/sheet'; From d3e9138a7eb2fecbebf78995e6e20a7433b351a6 Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Fri, 30 Jan 2026 15:19:49 +0530 Subject: [PATCH 09/12] feat: migrate sidebar --- .../content/docs/components/sidebar/props.ts | 6 +- .../sidebar/__tests__/sidebar.test.tsx | 5 +- .../components/sidebar/sidebar-root.tsx | 86 +++++++++---------- .../components/sidebar/sidebar.module.css | 18 ++-- 4 files changed, 55 insertions(+), 60 deletions(-) diff --git a/apps/www/src/content/docs/components/sidebar/props.ts b/apps/www/src/content/docs/components/sidebar/props.ts index bca5abc7e..ccf03e15a 100644 --- a/apps/www/src/content/docs/components/sidebar/props.ts +++ b/apps/www/src/content/docs/components/sidebar/props.ts @@ -7,11 +7,13 @@ export interface SidebarRootProps { /** Callback when expanded/collapsed state changes. */ onOpenChange?: (open: boolean) => void; - /** Default expanded/collapsed state.*/ + /** Default expanded/collapsed state. + * @default true + */ defaultOpen?: boolean; /** Disable the click to collapse/expand the Sidebar. - * @default undefined + * @default true */ collapsible?: boolean; diff --git a/packages/raystack/components/sidebar/__tests__/sidebar.test.tsx b/packages/raystack/components/sidebar/__tests__/sidebar.test.tsx index 12f9c2227..bbbc6e3e7 100644 --- a/packages/raystack/components/sidebar/__tests__/sidebar.test.tsx +++ b/packages/raystack/components/sidebar/__tests__/sidebar.test.tsx @@ -1,8 +1,8 @@ import { fireEvent, render, screen } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; import { Sidebar } from '../sidebar'; -import { SidebarRootProps } from '../sidebar-root'; import styles from '../sidebar.module.css'; +import { SidebarRootProps } from '../sidebar-root'; const HEADER_TEXT = 'Apsara'; const MAIN_GROUP_LABEL = 'Main'; @@ -101,7 +101,8 @@ describe('Sidebar', () => { render(); const nav = screen.getByRole('navigation'); - expect(nav).toHaveAttribute('data-state', 'collapsed'); + expect(nav).toHaveAttribute('data-closed'); + expect(nav).not.toHaveAttribute('data-open'); }); it('does not show handle when not collapsible', () => { diff --git a/packages/raystack/components/sidebar/sidebar-root.tsx b/packages/raystack/components/sidebar/sidebar-root.tsx index d9945cf07..463d44a2a 100644 --- a/packages/raystack/components/sidebar/sidebar-root.tsx +++ b/packages/raystack/components/sidebar/sidebar-root.tsx @@ -1,13 +1,11 @@ 'use client'; import { cx } from 'class-variance-authority'; -import { Collapsible } from 'radix-ui'; import { ComponentPropsWithoutRef, - ComponentRef, - ReactNode, createContext, forwardRef, + ReactNode, useCallback, useState } from 'react'; @@ -23,18 +21,17 @@ export const SidebarContext = createContext({ isCollapsed: false }); -export interface SidebarRootProps - extends ComponentPropsWithoutRef { +export interface SidebarRootProps extends ComponentPropsWithoutRef<'aside'> { position?: 'left' | 'right'; hideCollapsedItemTooltip?: boolean; collapsible?: boolean; tooltipMessage?: ReactNode; + open?: boolean; + defaultOpen?: boolean; + onOpenChange?: (open: boolean) => void; } -export const SidebarRoot = forwardRef< - ComponentRef, - SidebarRootProps ->( +export const SidebarRoot = forwardRef( ( { className, @@ -44,14 +41,13 @@ export const SidebarRoot = forwardRef< hideCollapsedItemTooltip, collapsible = true, tooltipMessage, - defaultOpen, + defaultOpen = true, children, ...props }, ref ) => { const [internalOpen, setInternalOpen] = useState(defaultOpen); - const open = providedOpen ?? internalOpen; const handleOpenChange = useCallback( @@ -67,50 +63,46 @@ export const SidebarRoot = forwardRef< value={{ isCollapsed: !open, hideCollapsedItemTooltip }} > - - - + {collapsible && ( + +
handleOpenChange(!open)} + role='button' + tabIndex={0} + aria-label={open ? 'Collapse sidebar' : 'Expand sidebar'} + onKeyDown={e => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleOpenChange(!open); + } + }} + /> + + )} + {children} + ); diff --git a/packages/raystack/components/sidebar/sidebar.module.css b/packages/raystack/components/sidebar/sidebar.module.css index 018f97c22..ed084adf8 100644 --- a/packages/raystack/components/sidebar/sidebar.module.css +++ b/packages/raystack/components/sidebar/sidebar.module.css @@ -20,11 +20,11 @@ border-left: 1px solid var(--rs-color-border-base-primary); } -.root[data-state="expanded"] { +.root[data-open] { width: 240px; } -.root[data-state="collapsed"] { +.root[data-closed] { width: 57px; } @@ -46,11 +46,11 @@ align-items: flex-start; } -.root[data-state="collapsed"] .main { +.root[data-closed] .main { align-items: center; } -.root[data-state="collapsed"] [data-collapse-hidden="true"] { +.root[data-closed] [data-collapse-hidden] { display: none; } @@ -73,7 +73,7 @@ box-sizing: border-box; } -.root[data-state="collapsed"] .nav-item { +.root[data-closed] .nav-item { justify-content: center; } @@ -119,7 +119,7 @@ transition: opacity 0.2s ease; } -.root[data-state="collapsed"] .nav-text { +.root[data-closed] .nav-text { width: 0; opacity: 0; display: none; @@ -136,7 +136,7 @@ transform: translateX(50%); } -.root[data-collapse-disabled="true"] .resizeHandle { +.root[data-collapse-disabled] .resizeHandle { display: none; } @@ -158,7 +158,7 @@ width: 100%; } -.root[data-state="collapsed"] .nav-group { +.root[data-closed] .nav-group { align-items: center; } @@ -186,7 +186,7 @@ width: 100%; } -.root[data-state="collapsed"] .nav-group-label { +.root[data-closed] .nav-group-label { width: 0; opacity: 0; display: none; From 0b5e20f4f1978ffa53208e859e71110bdc206996 Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Thu, 5 Feb 2026 15:35:52 +0530 Subject: [PATCH 10/12] feat: migrate tooltip to base ui --- .../playground/tooltip-examples.tsx | 32 ++- .../content/docs/components/tooltip/demo.ts | 190 ++++++++++-------- .../content/docs/components/tooltip/index.mdx | 40 ++-- .../content/docs/components/tooltip/props.ts | 94 +++++---- .../tooltip/__tests__/tooltip.test.tsx | 80 ++++++-- .../components/tooltip/tooltip-content.tsx | 76 +++++++ .../components/tooltip/tooltip-misc.tsx | 21 ++ .../components/tooltip/tooltip-provider.tsx | 36 ---- .../components/tooltip/tooltip-root.tsx | 177 ---------------- .../components/tooltip/tooltip.module.css | 105 ++++------ .../raystack/components/tooltip/tooltip.tsx | 11 +- packages/raystack/components/tooltip/utils.ts | 39 ---- 12 files changed, 406 insertions(+), 495 deletions(-) create mode 100644 packages/raystack/components/tooltip/tooltip-content.tsx create mode 100644 packages/raystack/components/tooltip/tooltip-misc.tsx delete mode 100644 packages/raystack/components/tooltip/tooltip-provider.tsx delete mode 100644 packages/raystack/components/tooltip/tooltip-root.tsx delete mode 100644 packages/raystack/components/tooltip/utils.ts diff --git a/apps/www/src/components/playground/tooltip-examples.tsx b/apps/www/src/components/playground/tooltip-examples.tsx index 7da5f7fea..807182877 100644 --- a/apps/www/src/components/playground/tooltip-examples.tsx +++ b/apps/www/src/components/playground/tooltip-examples.tsx @@ -7,29 +7,21 @@ export function TooltipExamples() { return ( - - + + }>Top + Top tooltip - - + + }>Right + Right tooltip - - + + }>Bottom + Bottom tooltip - - - - - - - - - - - - - - + + }>Left + Left tooltip diff --git a/apps/www/src/content/docs/components/tooltip/demo.ts b/apps/www/src/content/docs/components/tooltip/demo.ts index 2d20a5b19..bec9364e8 100644 --- a/apps/www/src/content/docs/components/tooltip/demo.ts +++ b/apps/www/src/content/docs/components/tooltip/demo.ts @@ -3,50 +3,52 @@ import { getPropsString } from '@/lib/utils'; export const getCode = (props: any) => { + const { children = 'Tooltip message', trackCursorAxis, ...rest } = props; return ` - - + + }> + Hover me + + + ${children} + `; }; export const playground = { type: 'playground', controls: { - message: { + children: { type: 'text', initialValue: 'Tooltip message' }, side: { type: 'select', - options: [ - 'top', - 'right', - 'bottom', - 'left', - 'top-left', - 'top-right', - 'bottom-left', - 'bottom-right' - ], + options: ['top', 'right', 'bottom', 'left'], defaultValue: 'top' }, - disabled: { - type: 'checkbox', - defaultValue: false + align: { + type: 'select', + options: ['start', 'center', 'end'], + defaultValue: 'center' }, - delayDuration: { + sideOffset: { type: 'number', - defaultValue: 200, - min: 0 + defaultValue: 4 }, - skipDelayDuration: { + alignOffset: { type: 'number', - defaultValue: 200, - min: 0 + defaultValue: 0 }, - followCursor: { + showArrow: { type: 'checkbox', + initialValue: false, defaultValue: false + }, + trackCursorAxis: { + type: 'select', + options: ['none', 'x', 'y', 'both'], + defaultValue: 'none' } }, getCode @@ -56,92 +58,108 @@ export const sideDemo = { type: 'code', code: ` - - - - - + + }>Top + Top tooltip - - + + }>Right + Right tooltip - - + + }>Bottom + Bottom tooltip - - + + }>Left + Left tooltip - - + ` +}; +export const alignDemo = { + type: 'code', + code: ` + + + }>Start + Start tooltip - - + + }>Center + Center tooltip - - + + }>End + End tooltip ` }; -export const followCursorDemo = { + +export const customDemo = { type: 'code', code: ` - - - - ` + + }>Hover me + +
+ Custom Tooltip +
+
+
` }; -export const customDemo = { + +export const providerDemo = { type: 'code', code: ` - - Custom Tooltip -
-}> - -
` + + + + }>Tooltip 1 + Top Left tooltip + + + }>Tooltip 2 + Top Right tooltip + + + ` }; -export const providerDemo = { +export const trackCursorDemo = { type: 'code', tabs: [ { - name: 'With Provider', + name: 'Both', code: ` - - - - - - - - - - - - - - - - ` + + }>Hover me + Tooltip follows cursor + ` }, { - name: 'Without Provider', + name: 'X', code: ` - - - - - - - - - - - - - - ` + + }>Hover me + Tooltip follows cursor + ` + }, + { + name: 'Y', + code: ` + + }>Hover me + Tooltip follows cursor + ` } ] }; + +export const arrowDemo = { + type: 'code', + code: ` + + }>Hover me + Tooltip with arrow + ` +}; diff --git a/apps/www/src/content/docs/components/tooltip/index.mdx b/apps/www/src/content/docs/components/tooltip/index.mdx index bd0485b06..d1a309373 100644 --- a/apps/www/src/content/docs/components/tooltip/index.mdx +++ b/apps/www/src/content/docs/components/tooltip/index.mdx @@ -4,7 +4,7 @@ description: A popup that displays information related to an element when it rec source: packages/raystack/components/tooltip --- -import { playground, sideDemo, customDemo,followCursorDemo, providerDemo } from "./demo.ts"; +import { playground, sideDemo, alignDemo, customDemo, providerDemo, trackCursorDemo, noArrowDemo, arrowDemo } from "./demo.ts"; @@ -20,9 +20,21 @@ The Tooltip component accepts various props to customize its behavior and appear +## Tooltip.Trigger Props + +The Trigger component wraps the element that activates the tooltip. + + + +## Tooltip.Content Props + +The Content component displays the tooltip content and controls positioning. + + + ## Tooltip.Provider Props -The TooltipProvider component serves as a context wrapper that provides global configuration and functionality to all tooltip instances within your application. +The Provider component serves as a context wrapper that provides global configuration and functionality to all tooltip instances within your application. @@ -30,10 +42,16 @@ The TooltipProvider component serves as a context wrapper that provides global c ### Side -The Tooltip component can be positioned in different directions using the `side` prop: +The Tooltip Content component can be positioned in different directions using the `side` prop: +### Align + +The Tooltip Content component can be aligned in different directions using the `align` prop: + + + ### Custom Content Tooltips can contain custom content using ReactNode: @@ -46,18 +64,14 @@ The TooltipProvider component can be used to provide a global configuration for -### Follow Cursor +### Track Cursor -When `followCursor` is true, the tooltip will follow the cursor and will be positioned relative to the cursor. +Use `trackCursorAxis` prop on the Root component to make the tooltip follow the cursor: - + -## Accessibility +### With Arrow -The Tooltip component follows WAI-ARIA guidelines for tooltips: +Show the arrow by setting `showArrow={true}` on the Content component: -- Uses `role="tooltip"` for proper semantic meaning -- Automatically manages focus and hover interactions -- Supports keyboard navigation -- Provides appropriate ARIA attributes for accessibility -- Manages enter/exit animations for smooth user experience + diff --git a/apps/www/src/content/docs/components/tooltip/props.ts b/apps/www/src/content/docs/components/tooltip/props.ts index 41c94f013..7f3111747 100644 --- a/apps/www/src/content/docs/components/tooltip/props.ts +++ b/apps/www/src/content/docs/components/tooltip/props.ts @@ -1,95 +1,101 @@ export interface TooltipProps { /** - * Content to display in the tooltip. + * The controlled open state of the tooltip. */ - message: string | React.ReactNode; + open?: boolean; /** - * Element that triggers the tooltip. + * The initial open state of the tooltip. */ - children: React.ReactNode; + defaultOpen?: boolean; /** - * Position of the tooltip relative to the trigger. - * @default "top" + * Event handler called when the open state of the tooltip changes. */ - side?: - | 'top' - | 'right' - | 'bottom' - | 'left' - | 'top-left' - | 'top-right' - | 'bottom-left' - | 'bottom-right'; + onOpenChange?: (open: boolean) => void; /** - * Whether the tooltip should follow the cursor. - * @default false + * Delay before showing the tooltip, in milliseconds. + * @default 200 */ - followCursor?: boolean; + delayDuration?: number; /** - * Whether the tooltip is disabled. - * @default false + * Prevents Tooltip from remaining open when hovering. Disabling this has accessibility consequences. */ - disabled?: boolean; + disableHoverableContent?: boolean; /** - * The controlled open state of the tooltip. + * Track cursor axis ('none', 'x', 'y', or 'both') + * @default 'none' */ - open?: boolean; + trackCursorAxis?: 'none' | 'x' | 'y' | 'both'; +} +export interface TooltipTriggerProps { /** - * The initial open state of the tooltip. + * React element to render as the trigger. Props will be merged onto this element. */ - defaultOpen?: boolean; + render?: React.ReactElement; /** - * Event handler called when the open state of the tooltip changes. + * Additional CSS class names + */ + className?: string; +} + +export interface TooltipContentProps { + /** + * Controls whether to show the arrow * @default false */ - onOpenChange?: (open: boolean) => void; + showArrow?: boolean; /** - * Delay before showing the tooltip, in milliseconds. - * Overrides the prop of TooltipProvider. - * @default 200 + * Side placement of the tooltip + * @default "top" */ - delayDuration?: number; + side?: 'top' | 'bottom' | 'left' | 'right'; /** - * Prevents Tooltip from remaining open when hovering. Disabling this has accessibility consequences. - * Overrides the prop of TooltipProvider. + * Alignment of the tooltip + * @default "center" */ - disableHoverableContent?: boolean; + align?: 'start' | 'center' | 'end'; + + /** + * Side offset for positioning + * @default 4 + */ + sideOffset?: number; /** - * Additional ID for Tooltip Content + * Align offset for positioning + * @default 0 */ - id?: string; + alignOffset?: number; /** - * Additional CSS class names. + * Additional CSS class names */ className?: string; } export interface TooltipProviderProps { /** - * Delay before showing the tooltip, in milliseconds. + * How long to wait before opening a tooltip. Specified in milliseconds. * @default 200 */ - delayDuration?: number; + delay?: number; /** - * Delay before showing the tooltip when moving between tooltips, in milliseconds. - * @default 200 + * How long to wait before closing a tooltip. Specified in milliseconds. */ - skipDelayDuration?: number; + closeDelay?: number; /** - * Prevents Tooltip from remaining open when hovering. Disabling this has accessibility consequences. + * Another tooltip will open instantly if the previous tooltip is closed within this timeout. Specified in milliseconds. + * @default 400 */ - disableHoverableContent?: boolean; + timeout?: number; } diff --git a/packages/raystack/components/tooltip/__tests__/tooltip.test.tsx b/packages/raystack/components/tooltip/__tests__/tooltip.test.tsx index 94a791e95..bc1f776b6 100644 --- a/packages/raystack/components/tooltip/__tests__/tooltip.test.tsx +++ b/packages/raystack/components/tooltip/__tests__/tooltip.test.tsx @@ -1,8 +1,7 @@ -import { render, screen } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { Tooltip } from '../tooltip'; -import { TooltipProps } from '../tooltip-root'; const TRIGGER_TEXT = 'Hover me'; const MESSAGE_TEXT = 'Tooltip text'; @@ -11,13 +10,21 @@ const BasicTooltip = ({ message = MESSAGE_TEXT, children = TRIGGER_TEXT, ...props -}: Partial) => { +}: { + message?: string; + children?: React.ReactNode; + open?: boolean; + onOpenChange?: (open: boolean) => void; + delay?: number; +}) => { return ( - - + + {children}} /> + {message} ); }; + describe('Tooltip', () => { describe('Basic Rendering', () => { it('renders trigger content', () => { @@ -32,7 +39,7 @@ describe('Tooltip', () => { it('respects open prop', () => { render(); - expect(screen.queryAllByText(MESSAGE_TEXT)[0]).toBeInTheDocument(); + expect(screen.queryByText(MESSAGE_TEXT)).toBeInTheDocument(); }); it('shows tooltip on hover', async () => { @@ -42,8 +49,11 @@ describe('Tooltip', () => { const trigger = screen.getByText(TRIGGER_TEXT); await user.hover(trigger); - expect(screen.getAllByText(MESSAGE_TEXT)[0]).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByText(MESSAGE_TEXT)).toBeInTheDocument(); + }); }); + it('hides tooltip on mouse leave', async () => { const user = userEvent.setup(); render( @@ -67,7 +77,7 @@ describe('Tooltip', () => { const trigger = screen.getByText(TRIGGER_TEXT); await trigger.focus(); - expect(screen.getAllByText(MESSAGE_TEXT)[0]).toBeInTheDocument(); + expect(screen.getByText(MESSAGE_TEXT)).toBeInTheDocument(); }); it('hides tooltip on blur', async () => { @@ -75,9 +85,16 @@ describe('Tooltip', () => { const trigger = screen.getByText(TRIGGER_TEXT); await trigger.focus(); + + await waitFor(() => { + expect(screen.getByText(MESSAGE_TEXT)).toBeInTheDocument(); + }); + await trigger.blur(); - expect(screen.queryByText(MESSAGE_TEXT)).not.toBeInTheDocument(); + await waitFor(() => { + expect(screen.queryByText(MESSAGE_TEXT)).not.toBeInTheDocument(); + }); }); it('calls onOpenChange when state changes', async () => { @@ -87,7 +104,10 @@ describe('Tooltip', () => { const trigger = screen.getByText(TRIGGER_TEXT); await user.hover(trigger); - expect(onOpenChange).toHaveBeenCalled(); + + await waitFor(() => { + expect(onOpenChange).toHaveBeenCalled(); + }); }); }); @@ -95,8 +115,14 @@ describe('Tooltip', () => { it('works with explicit provider', () => { render( - Trigger 1 - Trigger 2 + + Trigger 1} /> + Tooltip 1 + + + Trigger 2} /> + Tooltip 2 + ); @@ -110,4 +136,32 @@ describe('Tooltip', () => { expect(screen.getByText(TRIGGER_TEXT)).toBeInTheDocument(); }); }); + + describe('Content', () => { + it('hides arrow when showArrow is false', () => { + render( + + Trigger} /> + Tooltip + + ); + + const tooltip = screen.getByText('Tooltip'); + const arrow = tooltip.parentElement?.querySelector('[class*="arrow"]'); + expect(arrow).not.toBeInTheDocument(); + }); + + it('shows arrow by default', () => { + render( + + Trigger} /> + Tooltip + + ); + + const tooltip = screen.getByText('Tooltip'); + const arrow = tooltip.parentElement?.querySelector('[class*="arrow"]'); + expect(arrow).toBeInTheDocument(); + }); + }); }); diff --git a/packages/raystack/components/tooltip/tooltip-content.tsx b/packages/raystack/components/tooltip/tooltip-content.tsx new file mode 100644 index 000000000..411a59894 --- /dev/null +++ b/packages/raystack/components/tooltip/tooltip-content.tsx @@ -0,0 +1,76 @@ +'use client'; + +import { Tooltip as TooltipPrimitive } from '@base-ui/react'; +import { cx } from 'class-variance-authority'; +import { type ElementRef, forwardRef } from 'react'; +import { Text } from '../text'; +import styles from './tooltip.module.css'; + +export interface TooltipContentProps + extends Omit< + TooltipPrimitive.Positioner.Props, + 'className' | 'style' | 'render' + >, + TooltipPrimitive.Popup.Props { + /** + * Controls whether to show the arrow + * @default true + */ + showArrow?: boolean; +} + +export const TooltipContent = forwardRef< + ElementRef, + TooltipContentProps +>( + ( + { + className, + children, + showArrow = false, + style, + render, + ...positionerProps + }, + ref + ) => { + return ( + + + + {typeof children === 'string' ? {children} : children} + {showArrow && ( + + + + + + )} + + + + ); + } +); + +TooltipContent.displayName = 'Tooltip.Content'; diff --git a/packages/raystack/components/tooltip/tooltip-misc.tsx b/packages/raystack/components/tooltip/tooltip-misc.tsx new file mode 100644 index 000000000..e273e0f2b --- /dev/null +++ b/packages/raystack/components/tooltip/tooltip-misc.tsx @@ -0,0 +1,21 @@ +'use client'; + +import { Tooltip as TooltipPrimitive } from '@base-ui/react'; +import { forwardRef } from 'react'; + +export interface TooltipTriggerProps extends TooltipPrimitive.Trigger.Props {} + +export const TooltipTrigger = forwardRef< + HTMLButtonElement, + TooltipPrimitive.Trigger.Props +>((props, ref) => { + return ; +}); + +TooltipTrigger.displayName = 'Tooltip.Trigger'; + +export const TooltipProvider = (props: TooltipPrimitive.Provider.Props) => { + return ; +}; + +TooltipProvider.displayName = 'Tooltip.Provider'; diff --git a/packages/raystack/components/tooltip/tooltip-provider.tsx b/packages/raystack/components/tooltip/tooltip-provider.tsx deleted file mode 100644 index 7cb01ccd9..000000000 --- a/packages/raystack/components/tooltip/tooltip-provider.tsx +++ /dev/null @@ -1,36 +0,0 @@ -'use client'; - -import { Tooltip as TooltipPrimitive } from 'radix-ui'; -import { createContext, useContext } from 'react'; - -interface TooltipProviderContextValue - extends Omit {} - -const TooltipProviderContext = createContext< - TooltipProviderContextValue | undefined ->(undefined); - -export const useTooltipProvider = () => { - return useContext(TooltipProviderContext); -}; - -export interface TooltipProviderProps - extends TooltipPrimitive.TooltipProviderProps {} - -export const TooltipProvider = ({ - delayDuration = 200, - skipDelayDuration = 200, - ...props -}: TooltipProviderProps) => { - return ( - - - - ); -}; diff --git a/packages/raystack/components/tooltip/tooltip-root.tsx b/packages/raystack/components/tooltip/tooltip-root.tsx deleted file mode 100644 index 178ca1778..000000000 --- a/packages/raystack/components/tooltip/tooltip-root.tsx +++ /dev/null @@ -1,177 +0,0 @@ -'use client'; - -import { VariantProps, cva, cx } from 'class-variance-authority'; -import { Tooltip as TooltipPrimitive } from 'radix-ui'; -import { CSSProperties, ReactNode, useId, useMemo } from 'react'; -import { useMouse } from '~/hooks'; -import { Text } from '../text'; -import { TooltipProvider, useTooltipProvider } from './tooltip-provider'; -import styles from './tooltip.module.css'; -import { getTransformForPlacement } from './utils'; - -const tooltip = cva(styles.content, { - variants: { - side: { - top: styles['side-top'], - right: styles['side-right'], - bottom: styles['side-bottom'], - left: styles['side-left'], - 'top-left': styles['side-top-left'], - 'top-right': styles['side-top-right'], - 'bottom-left': styles['side-bottom-left'], - 'bottom-right': styles['side-bottom-right'] - } - }, - defaultVariants: { - side: 'top' - } -}); - -export interface TooltipProps - extends TooltipPrimitive.TooltipProps, - Omit, - VariantProps { - disabled?: boolean; - message: ReactNode; - classNames?: { - trigger?: string; - content?: string; - arrow?: string; - }; - triggerStyle?: CSSProperties; - contentStyle?: CSSProperties; - 'aria-label'?: string; - asChild?: boolean; - id?: string; - showArrow?: boolean; - followCursor?: boolean; -} -type TooltipSide = NonNullable; -type TooltipAlign = NonNullable; - -export const TooltipBase = ({ - children, - message, - disabled, - side = 'top', - classNames, - triggerStyle, - contentStyle, - 'aria-label': ariaLabel, - asChild = true, - showArrow = true, - id, - followCursor = false, - sideOffset = 4, - alignOffset = 0, - open, - defaultOpen, - delayDuration = 200, - onOpenChange, - disableHoverableContent, - ...props -}: TooltipProps) => { - const generatedId = useId(); - const tooltipId = id ?? generatedId; - const { - ref, - value: mouseValue, - reset - } = useMouse({ - resetOnExit: false, - enabled: followCursor - }); - - const computedSide = useMemo( - () => (side?.split('-')[0] || 'top') as TooltipSide, - [side] - ); - const computedAlign = useMemo( - () => - (side?.includes('-') - ? side.split('-')[1] === 'left' - ? 'start' - : 'end' - : 'center') as TooltipAlign, - [side] - ); - - if (disabled) return children; - - return ( - - -
- {children} -
-
- - - {typeof message === 'string' ? {message} : message} - {showArrow && ( - - )} - - -
- ); -}; - -export const TooltipRoot = (props: TooltipProps) => { - const provider = useTooltipProvider(); - - // If already inside a provider, just return the tooltip - if (provider) return ; - - // If not inside a provider, wrap with our own provider - return ( - - - - ); -}; - -TooltipRoot.displayName = 'TooltipRoot'; diff --git a/packages/raystack/components/tooltip/tooltip.module.css b/packages/raystack/components/tooltip/tooltip.module.css index 2794b9fcd..7029ab080 100644 --- a/packages/raystack/components/tooltip/tooltip.module.css +++ b/packages/raystack/components/tooltip/tooltip.module.css @@ -2,16 +2,18 @@ width: fit-content; display: flex; } +.positioner { + z-index: var(--rs-z-index-portal); +} .content { position: relative; box-sizing: border-box; - z-index: var(--rs-z-index-portal); padding: var(--rs-space-2) var(--rs-space-3); border-radius: var(--rs-radius-2); background: var(--rs-color-background-base-primary); border: 0.5px solid var(--rs-color-border-base-primary); - box-shadow: var(--rs-shadow-soft); + box-shadow: var(--rs-shadow-lifted); color: var(--rs-color-foreground-base-primary); font-size: var(--rs-font-size-mini); font-weight: var(--rs-font-weight-medium); @@ -20,64 +22,69 @@ width: fit-content; max-width: 400px; animation-duration: 400ms; + transform-origin: var(--transform-origin); } -.content[data-state="delayed-open"][data-side="top"]:not( - [data-follow-cursor="true"] - ) { +.content[data-open][data-side="top"]:not([data-instant]) { animation-name: slideDownAndFade; } -.content[data-state="delayed-open"][data-side="right"]:not( - [data-follow-cursor="true"] - ) { +.content[data-open][data-side="right"]:not([data-instant]) { animation-name: slideLeftAndFade; } -.content[data-state="delayed-open"][data-side="bottom"]:not( - [data-follow-cursor="true"] - ) { +.content[data-open][data-side="bottom"]:not([data-instant]) { animation-name: slideUpAndFade; } -.content[data-state="delayed-open"][data-side="left"]:not( - [data-follow-cursor="true"] - ) { +.content[data-open][data-side="left"]:not([data-instant]) { animation-name: slideRightAndFade; } +.content[data-side="top"][data-align="start"]:not([data-instant]) { + animation-name: slideDownRightAndFade; +} + +.content[data-side="top"][data-align="end"]:not([data-instant]) { + animation-name: slideDownLeftAndFade; +} + +.content[data-side="bottom"][data-align="start"]:not([data-instant]) { + animation-name: slideUpRightAndFade; +} +.content[data-side="bottom"][data-align="end"]:not([data-instant]) { + animation-name: slideUpLeftAndFade; +} + +.arrow svg { + color: var(--rs-color-background-base-primary); +} .arrow { - fill: var(--rs-color-background-base-primary); - width: 100%; - height: 100%; + z-index: var(--rs-z-index-portal); filter: drop-shadow(0 1px 0 var(--rs-color-border-base-primary)) drop-shadow(0 1px 1px var(--rs-color-border-base-primary)); - transform: translateX(-1px); - margin-top: -1px; } -/* Corner positions */ -.content[data-side="top"][data-align="start"]:not([data-follow-cursor="true"]) { - animation-name: slideDownRightAndFade; +/* Arrow positioning based on side */ +.arrow[data-side="top"] { + bottom: -7px; } -.content[data-side="top"][data-align="end"]:not([data-follow-cursor="true"]) { - animation-name: slideDownLeftAndFade; +.arrow[data-side="bottom"] { + transform: translateY(-100%) rotate(180deg); + top: 0; } -.content[data-side="bottom"][data-align="start"]:not( - [data-follow-cursor="true"] - ) { - animation-name: slideUpRightAndFade; +.arrow[data-side="left"], +.arrow[data-side="inline-start"] { + right: 0; + transform: translateY(-50%) translateX(100%) rotate(-90deg); } -.content[data-side="bottom"][data-align="end"]:not( - [data-follow-cursor="true"] - ) { - animation-name: slideUpLeftAndFade; -} -.content[data-follow-cursor="true"] { - animation-name: fade; +.arrow[data-side="right"], +.arrow[data-side="inline-end"] { + left: 0; + transform: translateY(-50%) translateX(-100%) rotate(90deg); } @keyframes fade { @@ -99,10 +106,6 @@ opacity: 0; transform: translateX(-2px); } - to { - opacity: 1; - transform: translateX(0); - } } @keyframes slideDownAndFade { @@ -110,10 +113,6 @@ opacity: 0; transform: translateY(-2px); } - to { - opacity: 1; - transform: translateY(0); - } } @keyframes slideLeftAndFade { @@ -121,10 +120,6 @@ opacity: 0; transform: translateX(2px); } - to { - opacity: 1; - transform: translateX(0); - } } @keyframes slideDownRightAndFade { @@ -132,10 +127,6 @@ opacity: 0; transform: translate(-2px, -2px); } - to { - opacity: 1; - transform: translate(0, 0); - } } @keyframes slideDownLeftAndFade { @@ -143,10 +134,6 @@ opacity: 0; transform: translate(2px, -2px); } - to { - opacity: 1; - transform: translate(0, 0); - } } @keyframes slideUpRightAndFade { @@ -154,10 +141,6 @@ opacity: 0; transform: translate(-2px, 2px); } - to { - opacity: 1; - transform: translate(0, 0); - } } @keyframes slideUpLeftAndFade { @@ -165,8 +148,4 @@ opacity: 0; transform: translate(2px, 2px); } - to { - opacity: 1; - transform: translate(0, 0); - } } diff --git a/packages/raystack/components/tooltip/tooltip.tsx b/packages/raystack/components/tooltip/tooltip.tsx index f0c80b2bc..a9638c80a 100644 --- a/packages/raystack/components/tooltip/tooltip.tsx +++ b/packages/raystack/components/tooltip/tooltip.tsx @@ -1,6 +1,9 @@ -import { TooltipProvider } from './tooltip-provider'; -import { TooltipRoot } from './tooltip-root'; +import { Tooltip as TooltipPrimitive } from '@base-ui/react'; +import { TooltipContent } from './tooltip-content'; +import { TooltipProvider, TooltipTrigger } from './tooltip-misc'; -export const Tooltip = Object.assign(TooltipRoot, { - Provider: TooltipProvider +export const Tooltip = Object.assign(TooltipPrimitive.Root, { + Provider: TooltipProvider, + Trigger: TooltipTrigger, + Content: TooltipContent }); diff --git a/packages/raystack/components/tooltip/utils.ts b/packages/raystack/components/tooltip/utils.ts deleted file mode 100644 index da42bad83..000000000 --- a/packages/raystack/components/tooltip/utils.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { Tooltip as TooltipPrimitive } from 'radix-ui'; - -type MousePosition = { - x: number; - y: number; - width: number; - height: number; -}; - -export const getTransformForPlacement = ( - side: NonNullable, - align: NonNullable, - { x, y, width, height }: MousePosition -): string => { - const transforms = { - top: { - start: `translate(${x}px, ${y}px)`, - center: `translate(${x - width / 2}px, ${y}px)`, - end: `translate(${x - width}px, ${y}px)` - }, - bottom: { - start: `translate(${x}px, ${y - height}px)`, - center: `translate(${x - width / 2}px, ${y - height}px)`, - end: `translate(${x - width}px, ${y - height}px)` - }, - left: { - start: `translate(${x}px, ${y}px)`, - center: `translate(${x}px, ${y - height / 2}px)`, - end: `translate(${x}px, ${y - height}px)` - }, - right: { - start: `translate(${x - width}px, ${y}px)`, - center: `translate(${x - width}px, ${y - height / 2}px)`, - end: `translate(${x - width}px, ${y - height}px)` - } - }; - - return transforms[side][align]; -}; From 59b2465f1b985ee41838779d9daa6ac2a0a1db1d Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Thu, 5 Feb 2026 15:42:01 +0530 Subject: [PATCH 11/12] feat: update tooltip usage in sidebar --- .../components/sidebar/sidebar-root.tsx | 76 +++++++++---------- 1 file changed, 38 insertions(+), 38 deletions(-) diff --git a/packages/raystack/components/sidebar/sidebar-root.tsx b/packages/raystack/components/sidebar/sidebar-root.tsx index 463d44a2a..b30915ebf 100644 --- a/packages/raystack/components/sidebar/sidebar-root.tsx +++ b/packages/raystack/components/sidebar/sidebar-root.tsx @@ -62,48 +62,48 @@ export const SidebarRoot = forwardRef( - - - + {tooltipMessage ?? + (open ? 'Click to collapse' : 'Click to expand')} + +
+ )} + {children} + ); } From 6b02682d283f3c4ea62047f966545b101662a0d4 Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Thu, 5 Feb 2026 15:48:36 +0530 Subject: [PATCH 12/12] fix: tooltip test --- .../raystack/components/tooltip/__tests__/tooltip.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/raystack/components/tooltip/__tests__/tooltip.test.tsx b/packages/raystack/components/tooltip/__tests__/tooltip.test.tsx index bc1f776b6..14d08141c 100644 --- a/packages/raystack/components/tooltip/__tests__/tooltip.test.tsx +++ b/packages/raystack/components/tooltip/__tests__/tooltip.test.tsx @@ -151,11 +151,11 @@ describe('Tooltip', () => { expect(arrow).not.toBeInTheDocument(); }); - it('shows arrow by default', () => { + it('shows arrow when showArrow is true', () => { render( Trigger} /> - Tooltip + Tooltip );