feat(rocket): add AlertDialog, Collapsible, Sheet, Sonner + Dialog overflow border (#15854)

* feat(rocket/alert-dialog): implement AlertDialog component with variants and documentation

* feat(rocket/collapsible): add Rocket Collapsible component

Bordered and ghost variants with animated expand/collapse via CSS grid-rows.
Includes styled trigger with auto-rotating chevron, variant context,
and Storybook stories.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore(storybook): fix quote style and centered layout sizing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(rocket/toaster): implement Sonner toast notification system with documentation and stories

* feat(rocket/sheet): add Rocket Sheet component

Right-side slide-in panel for multi-step forms (e.g. add datasource).
Three sizes (small/default/large), header/body/footer structure mirroring Dialog,
conditional footer overflow border via ResizeObserver context, preventClose support.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(rocket/table): add Rocket Table primitive

Low-level table primitive with 8 sub-components (Table, TableHeader, TableBody,
TableFooter, TableRow, TableHead, TableCell, TableCaption). Density variants
(default 52px / compact 36px) via TableDensityContext. Borderless rows with
rounded pill hover/selected highlights via first/last cell rounded corners.
Header bottom border on cells (border-separate mode). All ToolJet tokens.

Brought forward from PR #14498 with improved organization and Figma-aligned
defaults from the apps list design.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(rocket): add Skeleton primitive + DataTable block

- Skeleton: Rocket primitive with pulsing bg-interactive-hover token
- TableSkeleton: TanStack-agnostic skeleton rows for use inside <Table>
- DataTable: TanStack-driven table block (header, body, loading, empty states)
  brought from PR #14498 with ToolJet token cleanup and proper file structure

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(rocket): add Checkbox and RadioGroup components with specifications and stories

* chore: update dependencies and remove unused packages

- Removed @base-ui/react and cmdk from dependencies.
- Updated various @radix-ui packages to lower versions for compatibility.
- Adjusted versions for @floating-ui packages.
- Cleaned up package-lock.json by removing unnecessary entries.

* feat(rocket): add Spinner and Textarea components with stories and update AlertDialog and Combobox for new props

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nithin David Thomas 2026-04-09 16:43:49 +05:30 committed by GitHub
parent 4611b51d2c
commit 4e07c24060
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
50 changed files with 3768 additions and 20 deletions

View file

@ -1,12 +1,12 @@
/** @type { import('@storybook/react-webpack5').Preview } */
import "../src/_styles/theme.scss";
import "./preview.scss";
import { withColorScheme, withRouter } from "./decorators"; // Import the decorators
import '../src/_styles/theme.scss';
import './preview.scss';
import { withColorScheme, withRouter } from './decorators'; // Import the decorators
const preview = {
parameters: {
actions: { argTypesRegex: "^on[A-Z].*" },
actions: { argTypesRegex: '^on[A-Z].*' },
controls: {
matchers: {
color: /(background|color)$/i,
@ -16,20 +16,20 @@ const preview = {
},
globalTypes: {
theme: {
description: "Color scheme",
description: 'Color scheme',
toolbar: {
title: "Theme",
icon: "mirror",
title: 'Theme',
icon: 'mirror',
items: [
{ value: "light", title: "Light", icon: "sun" },
{ value: "dark", title: "Dark", icon: "moon" },
{ value: 'light', title: 'Light', icon: 'sun' },
{ value: 'dark', title: 'Dark', icon: 'moon' },
],
dynamicTitle: true,
},
},
},
initialGlobals: {
theme: "light",
theme: 'light',
},
decorators: [withRouter, withColorScheme],
};

View file

@ -13,4 +13,9 @@ body {
.storybook-preview-wrapper,
.sb-show-main {
background-color: var(--background-surface-layer-01) !important;
}
.sb-show-main.sb-main-centered #storybook-root {
width: 100%;
height: stretch;
}

View file

@ -86,7 +86,14 @@ export default [
react: {
version: 'detect',
},
'import-x/resolver': 'webpack',
'import-x/resolver': {
node: {
extensions: ['.js', '.jsx', '.ts', '.tsx'],
},
webpack: {
config: new URL('./webpack.config.js', import.meta.url).pathname,
},
},
},
rules: {

View file

@ -21,13 +21,16 @@
"@mdxeditor/editor": "^3.38.0",
"@microsoft/fetch-event-source": "^2.0.1",
"@radix-ui/colors": "^0.1.8",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slider": "^1.1.2",
@ -155,6 +158,7 @@
"rfdc": "^1.3.1",
"rxjs": "^7.8.0",
"semver": "^7.3.8",
"sonner": "^2.0.7",
"string-hash": "^1.1.3",
"superstruct": "^1.0.3",
"tailwind-merge": "^2.6.0",
@ -178,6 +182,7 @@
"@babel/preset-env": "^7.20.2",
"@babel/preset-react": "^7.18.6",
"@babel/preset-typescript": "^7.28.5",
"@eslint/js": "^9.26.0",
"@pmmmwh/react-refresh-webpack-plugin": "^0.6.0",
"@storybook/addon-docs": "^9.1.5",
"@storybook/addon-links": "^9.1.5",
@ -200,7 +205,6 @@
"css-loader": "^6.7.3",
"css-minimizer-webpack-plugin": "^7.0.2",
"esbuild": "0.25.9",
"@eslint/js": "^9.26.0",
"eslint": "^9.26.0",
"eslint-config-prettier": "^8.10.2",
"eslint-import-resolver-webpack": "^0.13.10",

View file

@ -0,0 +1,212 @@
import React, { forwardRef } from 'react';
import PropTypes from 'prop-types';
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
import { cva } from 'class-variance-authority';
import { cn } from '@/lib/utils';
import { AlertDialogTrigger, AlertDialogPortal } from '@/components/ui/Rocket/shadcn/alert-dialog';
// AlertDialog (root pass-through)
const AlertDialog = AlertDialogPrimitive.Root;
// AlertDialogOverlay
const AlertDialogOverlay = forwardRef(function AlertDialogOverlay({ className, ...props }, ref) {
return (
<AlertDialogPrimitive.Overlay
ref={ref}
data-slot="alert-dialog-overlay"
className={cn(
'tw-fixed tw-inset-0 tw-z-50 tw-bg-black/20',
'data-[state=open]:tw-animate-in data-[state=closed]:tw-animate-out data-[state=closed]:tw-fade-out-0 data-[state=open]:tw-fade-in-0',
className
)}
{...props}
/>
);
});
AlertDialogOverlay.displayName = 'AlertDialogOverlay';
// AlertDialogContent
const alertDialogContentVariants = cva(
[
'tw-bg-background-surface-layer-01',
'tw-shadow-elevation-400',
'tw-rounded-lg',
'tw-border-solid tw-border tw-border-border-weak',
'tw-flex tw-flex-col tw-gap-6',
'tw-p-6',
],
{
variants: {
size: {
default: 'tw-max-w-[460px]',
small: 'tw-max-w-[320px]',
},
},
defaultVariants: { size: 'default' },
}
);
const AlertDialogContent = forwardRef(function AlertDialogContent(
{ className, children, size, preventClose = true, ...props },
ref
) {
const handleInteractOutside = (e) => {
if (preventClose) e.preventDefault();
props.onInteractOutside?.(e);
};
const handleEscapeKeyDown = (e) => {
if (preventClose) e.preventDefault();
props.onEscapeKeyDown?.(e);
};
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
data-slot="alert-dialog-content"
data-size={size || 'default'}
onInteractOutside={handleInteractOutside}
onEscapeKeyDown={handleEscapeKeyDown}
className={cn(
'tw-group/alert-dialog-content tw-fixed tw-inset-0 tw-z-50 tw-m-auto tw-h-fit tw-w-full tw-max-w-[calc(100%-2rem)] tw-outline-none',
'data-[state=open]:tw-animate-in data-[state=closed]:tw-animate-out data-[state=closed]:tw-fade-out-0 data-[state=open]:tw-fade-in-0 data-[state=closed]:tw-zoom-out-95 data-[state=open]:tw-zoom-in-95',
alertDialogContentVariants({ size }),
className
)}
{...props}
>
{children}
</AlertDialogPrimitive.Content>
</AlertDialogPortal>
);
});
AlertDialogContent.displayName = 'AlertDialogContent';
AlertDialogContent.propTypes = {
size: PropTypes.oneOf(['default', 'small']),
preventClose: PropTypes.bool,
className: PropTypes.string,
children: PropTypes.node,
};
// AlertDialogMedia
// Icon or image slot. Placed inside AlertDialogHeader.
// Default size: uses media beside title on default, stacked on small.
function AlertDialogMedia({ className, children, ...props }) {
return (
<div
data-slot="alert-dialog-media"
className={cn('tw-flex tw-items-center tw-justify-center tw-size-10 tw-shrink-0', className)}
{...props}
>
{children}
</div>
);
}
AlertDialogMedia.displayName = 'AlertDialogMedia';
AlertDialogMedia.propTypes = {
className: PropTypes.string,
children: PropTypes.node,
};
// AlertDialogHeader
// Groups media + title + description.
// Layout adapts based on size (via group-data) and media presence (via has-[]).
function AlertDialogHeader({ className, children, ...props }) {
return (
<div
data-slot="alert-dialog-header"
className={cn(
'tw-flex tw-flex-col tw-gap-0.5',
// 8px total between media and title: 6px margin + 2px gap
'[&>[data-slot=alert-dialog-media]]:tw-mb-1.5',
// When small: center text
'group-data-[size=small]/alert-dialog-content:tw-items-center group-data-[size=small]/alert-dialog-content:tw-text-center',
className
)}
{...props}
>
{children}
</div>
);
}
AlertDialogHeader.displayName = 'AlertDialogHeader';
// AlertDialogTitle
const AlertDialogTitle = forwardRef(function AlertDialogTitle({ className, ...props }, ref) {
return (
<AlertDialogPrimitive.Title
ref={ref}
data-slot="alert-dialog-title"
className={cn('tw-font-title-x-large tw-text-text-default tw-mb-0', className)}
{...props}
/>
);
});
AlertDialogTitle.displayName = 'AlertDialogTitle';
// AlertDialogDescription
const AlertDialogDescription = forwardRef(function AlertDialogDescription({ className, ...props }, ref) {
return (
<AlertDialogPrimitive.Description
ref={ref}
data-slot="alert-dialog-description"
className={cn('tw-font-body-default tw-text-text-default tw-m-0', className)}
{...props}
/>
);
});
AlertDialogDescription.displayName = 'AlertDialogDescription';
// AlertDialogFooter
function AlertDialogFooter({ className, ...props }) {
return (
<div
data-slot="alert-dialog-footer"
className={cn('tw-flex tw-items-center tw-justify-between tw-gap-2', className)}
{...props}
/>
);
}
AlertDialogFooter.displayName = 'AlertDialogFooter';
// AlertDialogAction
const AlertDialogAction = forwardRef(function AlertDialogAction({ className, ...props }, ref) {
return <AlertDialogPrimitive.Action ref={ref} data-slot="alert-dialog-action" className={cn(className)} {...props} />;
});
AlertDialogAction.displayName = 'AlertDialogAction';
// AlertDialogCancel
const AlertDialogCancel = forwardRef(function AlertDialogCancel({ className, ...props }, ref) {
return <AlertDialogPrimitive.Cancel ref={ref} data-slot="alert-dialog-cancel" className={cn(className)} {...props} />;
});
AlertDialogCancel.displayName = 'AlertDialogCancel';
// Exports
export {
AlertDialog,
AlertDialogTrigger,
AlertDialogContent,
alertDialogContentVariants,
AlertDialogOverlay,
AlertDialogMedia,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogAction,
AlertDialogCancel,
};

View file

@ -0,0 +1,105 @@
# AlertDialog — Rocket Design Spec
<!-- figma: https://www.figma.com/design/XQdM9x8x9YHcj1lUK8abUG/Rocket-components?node-id=8996-1761 -->
<!-- synced: 2026-04-01 -->
## Overview
AlertDialog is a modal confirmation dialog that **cannot be dismissed** by pressing Escape or clicking outside. The user must explicitly choose an action (confirm or cancel). Used for destructive or irreversible operations.
Structurally simpler than Dialog — no header/footer borders, no body scroll. Uses Radix AlertDialog primitives which enforce the non-dismissible behavior natively.
## Props (AlertDialogContent)
| Prop | Type | Values | Default |
|---|---|---|---|
| size | string | default \| small | default |
| className | string | — | — |
## Sizes
| Value | Max-width | Header alignment | Footer layout |
|---|---|---|---|
| default | 460px | left-aligned | horizontal, justify-between |
| small | 360px | centered | stacked, reverse order |
## Sub-components
| Component | Wraps | Styling |
|---|---|---|
| `AlertDialog` | Radix AlertDialog.Root (pass-through) | none |
| `AlertDialogTrigger` | Re-export from shadcn | none |
| `AlertDialogPortal` | Re-export from shadcn | none |
| `AlertDialogOverlay` | Radix Overlay (direct) | tw-bg-black/50, animations |
| `AlertDialogContent` | Radix Content (direct) | card with padding, shadow, rounded, size CVA |
| `AlertDialogMedia` | plain div | 40px icon/image slot |
| `AlertDialogHeader` | plain div | groups media + title + description |
| `AlertDialogTitle` | Radix Title (direct) | tw-font-title-x-large + color |
| `AlertDialogDescription` | Radix Description (direct) | tw-font-body-default + color |
| `AlertDialogFooter` | plain div | justify-between layout, no border |
| `AlertDialogAction` | Radix Action (direct) | accepts Rocket Button as child via asChild |
| `AlertDialogCancel` | Radix Cancel (direct) | accepts Rocket Button as child via asChild |
## Token Mapping
| Element | State | Figma token | ToolJet class |
|---|---|---|---|
| content bg | default | bg-surface-layer-01 | tw-bg-background-surface-layer-01 |
| content shadow | default | Elevations/400 | tw-shadow-elevation-400 |
| content radius | default | 8px | tw-rounded-lg |
| content padding | default | 24px | tw-p-6 |
| content gap | default | 24px (between body and footer) | tw-gap-6 |
| overlay bg | default | black/50 | tw-bg-black/50 |
| media size | default | 40px | tw-size-10 |
| body gap | default | 8px (between media/title/desc) | tw-gap-2 |
| title text | default | text/default, 16px Medium 24px | tw-font-title-x-large tw-text-text-default |
| description text | default | text/default, 12px Regular 18px | tw-font-body-default tw-text-text-default |
| footer layout | default | justify-between | tw-flex tw-justify-between tw-items-center |
## Slots
- media (optional, `ReactNode`) — 40px icon or image at top, via `AlertDialogMedia`
- title (required, `string`) — heading text
- description (optional, `string`) — supporting text
- cancel (required) — Rocket Button (typically `variant="outline"`)
- action (required) — Rocket Button (typically `variant="primary"` or `variant="primary" danger`)
- secondary action (optional) — additional Rocket Button grouped with action on the right
## Usage Pattern
```jsx
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="primary" danger>Delete</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogMedia><WarningIcon /></AlertDialogMedia>
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
<AlertDialogDescription>This action cannot be undone.</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel asChild>
<Button variant="outline">Cancel</Button>
</AlertDialogCancel>
<AlertDialogAction asChild>
<Button variant="primary" danger>Delete</Button>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
```
## CVA Shape
Shape C — sizes only. `alertDialogContentVariants` has `size` CVA for max-width. Rest use static `cn()` with `group-data-[size=...]` responsive classes.
## Notes
- **Non-dismissible by design**: Radix AlertDialog natively prevents Escape and overlay click dismiss. No `preventClose` prop needed (unlike Dialog).
- No header/footer borders — simpler layout than Dialog.
- No close button (X) — users must use Cancel or Action.
- `small` size: header text centers, footer stacks buttons vertically (reversed so action is on top).
- Footer uses Rocket Button components directly via `asChild` on Action/Cancel.
- The right side of the footer (default size) can hold multiple action buttons (secondary + primary) grouped with `tw-gap-2`.
- Typography uses `tw-font-*` plugin utilities — never manual font combos.
- `data-size` attribute on Content enables `group-data-[size=small]` selectors on child components.

View file

@ -0,0 +1,184 @@
import React from 'react';
import { TriangleAlert } from 'lucide-react';
import {
AlertDialog,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogMedia,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogAction,
AlertDialogCancel,
} from './AlertDialog';
import { Button } from '@/components/ui/Rocket/Button/Button';
export default {
title: 'Rocket/AlertDialog',
tags: ['autodocs'],
parameters: { layout: 'centered' },
};
// Default
export const Default = {
render: () => (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="primary">Open Alert</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogMedia>
<TriangleAlert className="tw-size-10 tw-text-icon-brand" />
</AlertDialogMedia>
<AlertDialogTitle>Lorem ipsum dolor sit amet.</AlertDialogTitle>
<AlertDialogDescription>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer elementum mattis arcu, non vulputate est
ornare vitae.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel asChild>
<Button variant="outline">Cancel</Button>
</AlertDialogCancel>
<div className="tw-flex tw-gap-2">
<Button variant="secondary">Secondary action</Button>
<AlertDialogAction asChild>
<Button variant="primary">Primary action</Button>
</AlertDialogAction>
</div>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
),
};
// Danger
export const Danger = {
render: () => (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="primary" danger>
Delete Item
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogMedia>
<TriangleAlert className="tw-size-10 tw-text-icon-danger" />
</AlertDialogMedia>
<AlertDialogTitle>Are you sure you want to delete?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete the item and all associated data.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel asChild>
<Button variant="outline">Cancel</Button>
</AlertDialogCancel>
<div className="tw-flex tw-gap-2">
<Button variant="secondary" danger>
Secondary action
</Button>
<AlertDialogAction asChild>
<Button variant="primary" danger>
Delete
</Button>
</AlertDialogAction>
</div>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
),
};
// Simple (Cancel + Action only)
export const Simple = {
render: () => (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline">Discard Changes</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogMedia>
<TriangleAlert className="tw-size-10 tw-text-icon-brand" />
</AlertDialogMedia>
<AlertDialogTitle>Discard unsaved changes?</AlertDialogTitle>
<AlertDialogDescription>
You have unsaved changes that will be lost if you leave this page.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel asChild>
<Button variant="outline">Cancel</Button>
</AlertDialogCancel>
<AlertDialogAction asChild>
<Button variant="primary">Discard</Button>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
),
};
// Small
export const Small = {
render: () => (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline">Small Alert</Button>
</AlertDialogTrigger>
<AlertDialogContent size="small">
<AlertDialogHeader>
<AlertDialogMedia>
<TriangleAlert className="tw-size-10 tw-text-icon-brand" />
</AlertDialogMedia>
<AlertDialogTitle>Delete this item?</AlertDialogTitle>
<AlertDialogDescription>This action cannot be undone.</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel asChild>
<Button variant="outline">Cancel</Button>
</AlertDialogCancel>
<AlertDialogAction asChild>
<Button variant="primary" danger>
Delete
</Button>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
),
};
// Without Media
export const WithoutMedia = {
render: () => (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline">Confirm Action</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Confirm this action?</AlertDialogTitle>
<AlertDialogDescription>Please confirm you want to proceed with this operation.</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel asChild>
<Button variant="outline">Cancel</Button>
</AlertDialogCancel>
<AlertDialogAction asChild>
<Button variant="primary">Confirm</Button>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
),
};

View file

@ -0,0 +1,90 @@
import React, { forwardRef } from 'react';
import PropTypes from 'prop-types';
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
import { cva } from 'class-variance-authority';
import { CheckIcon, MinusIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
// Checkbox
const checkboxVariants = cva(
[
// Layout
'tw-group tw-peer tw-relative tw-flex tw-shrink-0 tw-items-center tw-justify-center',
// Reset (preflight off)
'tw-appearance-none tw-outline-none',
// Default border + bg
'tw-border-solid tw-border tw-border-border-default',
'tw-bg-background-surface-layer-01',
'tw-transition-colors',
// Checked + indeterminate
'data-[state=checked]:tw-bg-button-primary data-[state=checked]:tw-border-button-primary',
'data-[state=indeterminate]:tw-bg-button-primary data-[state=indeterminate]:tw-border-button-primary',
// Icon color when checked
'tw-text-text-on-solid',
// Disabled
'disabled:tw-cursor-not-allowed',
'disabled:tw-bg-controls-base-inactive disabled:tw-border-transparent',
'disabled:data-[state=checked]:tw-bg-controls-base-inactive disabled:data-[state=checked]:tw-border-transparent',
'disabled:data-[state=indeterminate]:tw-bg-controls-base-inactive disabled:data-[state=indeterminate]:tw-border-transparent',
'disabled:tw-text-icon-default',
// Focus ring
'focus-visible:tw-ring-2 focus-visible:tw-ring-interactive-focus-outline focus-visible:tw-ring-offset-2',
],
{
variants: {
size: {
large: 'tw-size-5 tw-rounded-[7px]',
default: 'tw-size-4 tw-rounded-[5px]',
},
},
defaultVariants: { size: 'default' },
}
);
const checkIconSizeClasses = {
large: 'tw-size-3',
default: 'tw-size-2.5',
};
const Checkbox = forwardRef(function Checkbox({ className, size = 'default', ...props }, ref) {
return (
<CheckboxPrimitive.Root
ref={ref}
data-slot="checkbox"
className={cn(checkboxVariants({ size }), className)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="tw-grid tw-place-content-center"
forceMount
>
{/* Both icons rendered; CSS shows the right one based on data-state */}
<CheckIcon
className={cn(
checkIconSizeClasses[size],
'tw-stroke-[3]',
'group-data-[state=indeterminate]:tw-hidden group-data-[state=unchecked]:tw-hidden'
)}
/>
<MinusIcon
className={cn(
checkIconSizeClasses[size],
'tw-stroke-[3]',
'group-data-[state=checked]:tw-hidden group-data-[state=unchecked]:tw-hidden'
)}
/>
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);
});
Checkbox.displayName = 'Checkbox';
Checkbox.propTypes = {
size: PropTypes.oneOf(['large', 'default']),
checked: PropTypes.oneOfType([PropTypes.bool, PropTypes.oneOf(['indeterminate'])]),
disabled: PropTypes.bool,
className: PropTypes.string,
};
export { Checkbox, checkboxVariants };

View file

@ -0,0 +1,56 @@
# Checkbox — Rocket Design Spec
<!-- figma: https://www.figma.com/design/XQdM9x8x9YHcj1lUK8abUG/Rocket-components?node-id=93-52292 -->
<!-- synced: 2026-04-07 -->
## Overview
Binary input control. Wraps Radix Checkbox via shadcn. Supports `checked`, `indeterminate`, and `disabled` states. Sized via context-free `size` prop.
Label is **not** baked in — consumers compose `<label>` + `<Checkbox>` themselves.
## Props
| Prop | Type | Values | Default |
|---|---|---|---|
| size | string | large \| default | default |
| checked | boolean \| 'indeterminate' | — | false |
| disabled | boolean | — | false |
| className | string | — | — |
Plus standard Radix Checkbox props (`onCheckedChange`, `name`, `value`, `required`, `defaultChecked`).
## Sizes
| Value | Box | Border radius | Check icon |
|---|---|---|---|
| default | 16px (`tw-size-4`) | 5px (`tw-rounded-[5px]`) | 10px |
| large | 20px (`tw-size-5`) | 7px (`tw-rounded-[7px]`) | 12px |
## Token Mapping
| Element | State | Figma token | ToolJet class |
|---|---|---|---|
| bg | unchecked | `--bg-surface-layer-01` | `tw-bg-background-surface-layer-01` |
| border | unchecked | `border/default` | `tw-border-solid tw-border tw-border-border-default` |
| bg | checked / indeterminate | `button/primary` | `data-[state=checked]:tw-bg-button-primary data-[state=indeterminate]:tw-bg-button-primary` |
| border | checked / indeterminate | `button/primary` | `data-[state=checked]:tw-border-button-primary data-[state=indeterminate]:tw-border-button-primary` |
| icon (check / minus) | checked / indeterminate | `icon/on-solid` | `tw-text-text-on-solid` |
| bg | disabled | `controls/base-inactive` | `disabled:tw-bg-controls-base-inactive disabled:tw-border-transparent` |
| icon | disabled | (muted) | `disabled:tw-text-icon-default` |
| focus ring | focused | `interactive/focusActive` | `focus-visible:tw-ring-2 focus-visible:tw-ring-interactive-focus-outline focus-visible:tw-ring-offset-1` |
| cursor | disabled | — | `disabled:tw-cursor-not-allowed` |
| opacity | disabled | — | `disabled:tw-opacity-50` (only on icon — bg uses its own disabled token) |
## CVA Shape
Shape C — sizes only (no variants).
## Notes
- Wraps Radix `Checkbox.Root` + `Checkbox.Indicator` via shadcn.
- **Indeterminate** state set via `checked="indeterminate"` (Radix native). Renders a `MinusIcon` instead of `CheckIcon`. Determined inside the `Indicator` via `data-state`.
- Focus ring uses `tw-ring-offset-1` so the ring sits just outside the box border (matches Figma's 2px outer ring).
- Border radius is intentionally per-size — `5px` for default and `7px` for large match Figma exactly (not standard `tw-rounded-md`).
- Disabled state: bg becomes `controls-base-inactive` (subtle gray), border removed (transparent), icon muted. Both checked and unchecked disabled states use the same bg.
- Consumer composes the label: `<label className="tw-flex tw-items-center tw-gap-2"><Checkbox /> <span>Label</span></label>`.
- Typography (for the label text) is the consumer's responsibility — `tw-font-body-default` is the typical choice.

View file

@ -0,0 +1,88 @@
import React from 'react';
import { Checkbox } from './Checkbox';
export default {
title: 'Rocket/Checkbox',
tags: ['autodocs'],
parameters: { layout: 'centered' },
};
// Default
export const Default = {
render: () => <Checkbox />,
};
export const Checked = {
render: () => <Checkbox defaultChecked />,
};
export const Indeterminate = {
render: () => <Checkbox checked="indeterminate" />,
};
export const Disabled = {
render: () => (
<div className="tw-flex tw-gap-3">
<Checkbox disabled />
<Checkbox disabled defaultChecked />
<Checkbox disabled checked="indeterminate" />
</div>
),
};
// Sizes
export const Sizes = {
render: () => (
<div className="tw-flex tw-items-center tw-gap-4">
<Checkbox size="default" defaultChecked />
<Checkbox size="large" defaultChecked />
</div>
),
};
// With Label
export const WithLabel = {
render: () => (
<label className="tw-flex tw-items-center tw-gap-2 tw-cursor-pointer tw-font-body-default tw-text-text-default">
<Checkbox />
<span>Accept terms and conditions</span>
</label>
),
};
// All States
export const AllStates = {
render: () => (
<div className="tw-flex tw-flex-col tw-gap-4">
<div className="tw-flex tw-items-center tw-gap-3">
<span className="tw-w-32 tw-font-body-default tw-text-text-default">Unchecked</span>
<Checkbox />
<Checkbox size="large" />
</div>
<div className="tw-flex tw-items-center tw-gap-3">
<span className="tw-w-32 tw-font-body-default tw-text-text-default">Checked</span>
<Checkbox defaultChecked />
<Checkbox size="large" defaultChecked />
</div>
<div className="tw-flex tw-items-center tw-gap-3">
<span className="tw-w-32 tw-font-body-default tw-text-text-default">Indeterminate</span>
<Checkbox checked="indeterminate" />
<Checkbox size="large" checked="indeterminate" />
</div>
<div className="tw-flex tw-items-center tw-gap-3">
<span className="tw-w-32 tw-font-body-default tw-text-text-default">Disabled</span>
<Checkbox disabled />
<Checkbox size="large" disabled />
</div>
<div className="tw-flex tw-items-center tw-gap-3">
<span className="tw-w-32 tw-font-body-default tw-text-text-default">Disabled checked</span>
<Checkbox disabled defaultChecked />
<Checkbox size="large" disabled defaultChecked />
</div>
</div>
),
};

View file

@ -0,0 +1,159 @@
import React, { createContext, forwardRef, useContext } from 'react';
import PropTypes from 'prop-types';
import * as CollapsiblePrimitive from '@radix-ui/react-collapsible';
import { cva } from 'class-variance-authority';
import { ChevronDown } from 'lucide-react';
import { cn } from '@/lib/utils';
// Variant context
const CollapsibleVariantContext = createContext('bordered');
// Collapsible (root)
const collapsibleVariants = cva('', {
variants: {
variant: {
bordered: 'tw-border-solid tw-border tw-border-border-weak tw-rounded-lg tw-bg-background-surface-layer-01',
ghost: '',
},
},
defaultVariants: { variant: 'bordered' },
});
const Collapsible = forwardRef(function Collapsible({ className, variant = 'bordered', ...props }, ref) {
return (
<CollapsibleVariantContext.Provider value={variant}>
<CollapsiblePrimitive.Root
ref={ref}
data-slot="collapsible"
data-variant={variant}
className={cn(collapsibleVariants({ variant }), className)}
{...props}
/>
</CollapsibleVariantContext.Provider>
);
});
Collapsible.displayName = 'Collapsible';
Collapsible.propTypes = {
variant: PropTypes.oneOf(['bordered', 'ghost']),
defaultOpen: PropTypes.bool,
open: PropTypes.bool,
onOpenChange: PropTypes.func,
className: PropTypes.string,
};
// CollapsibleTrigger
const collapsibleTriggerVariants = cva(
[
// Reset (preflight is off)
'tw-appearance-none tw-border-0 tw-bg-transparent tw-outline-none',
'tw-group tw-flex tw-w-full tw-items-center tw-justify-between tw-cursor-pointer',
'tw-font-title-default tw-text-text-default',
'hover:tw-bg-interactive-hover tw-transition-colors',
],
{
variants: {
variant: {
bordered:
'tw-px-4 tw-py-3 tw-rounded-t-md tw-border-solid tw-border-0 data-[state=open]:tw-border-b data-[state=open]:tw-border-border-weak data-[state=closed]:tw-rounded-b-md data-[state=closed]:tw-border-transparent',
ghost: 'tw-py-2 tw-px-2 tw-rounded-md',
},
},
defaultVariants: { variant: 'bordered' },
}
);
const CollapsibleTrigger = forwardRef(function CollapsibleTrigger({ className, children, ...props }, ref) {
const variant = useContext(CollapsibleVariantContext);
return (
<CollapsiblePrimitive.Trigger
ref={ref}
data-slot="collapsible-trigger"
className={cn(collapsibleTriggerVariants({ variant }), className)}
{...props}
>
{children}
<CollapsibleIcon />
</CollapsiblePrimitive.Trigger>
);
});
CollapsibleTrigger.displayName = 'CollapsibleTrigger';
CollapsibleTrigger.propTypes = {
className: PropTypes.string,
children: PropTypes.node,
};
// CollapsibleIcon
// Auto-rotating chevron. Reads data-state from the nearest Radix Collapsible root.
function CollapsibleIcon({ className, ...props }) {
return (
<ChevronDown
aria-hidden
className={cn(
'tw-size-4 tw-text-icon-default tw-shrink-0',
'tw-transition-transform tw-duration-200',
// Radix sets data-state on the trigger parent selector
'group-data-[state=open]:tw-rotate-180',
className
)}
{...props}
/>
);
}
CollapsibleIcon.displayName = 'CollapsibleIcon';
// CollapsibleContent
const collapsibleContentVariants = cva(
[
'tw-grid tw-transition-[grid-template-rows,padding] tw-duration-200 tw-ease-in-out',
'data-[state=closed]:tw-grid-rows-[0fr] data-[state=closed]:!tw-p-0',
'data-[state=open]:tw-grid-rows-[1fr]',
],
{
variants: {
variant: {
bordered: 'tw-px-4 tw-py-3',
ghost: 'tw-py-2',
},
},
defaultVariants: { variant: 'bordered' },
}
);
const CollapsibleContent = forwardRef(function CollapsibleContent({ className, children, ...props }, ref) {
const variant = useContext(CollapsibleVariantContext);
return (
<CollapsiblePrimitive.Content
ref={ref}
forceMount
data-slot="collapsible-content"
className={cn(collapsibleContentVariants({ variant }), className)}
{...props}
>
<div className="tw-overflow-hidden tw-min-h-0">{children}</div>
</CollapsiblePrimitive.Content>
);
});
CollapsibleContent.displayName = 'CollapsibleContent';
CollapsibleContent.propTypes = {
className: PropTypes.string,
children: PropTypes.node,
};
// Exports
export {
Collapsible,
collapsibleVariants,
CollapsibleTrigger,
collapsibleTriggerVariants,
CollapsibleIcon,
CollapsibleContent,
collapsibleContentVariants,
};

View file

@ -0,0 +1,71 @@
# Collapsible — Rocket Design Spec
<!-- figma: https://www.figma.com/design/lOW96V8fTBx9J6yolOLhdC/01-Dashboards---Applications?node-id=4216-38603 -->
<!-- synced: 2026-04-01 -->
## Overview
Collapsible is a disclosure component — a trigger row that expands/collapses hidden content. Used inside modals, settings panels, and detail views to group secondary information.
Wraps Radix Collapsible primitives via shadcn.
## Props (Collapsible — root)
| Prop | Type | Values | Default |
|---|---|---|---|
| variant | string | bordered \| ghost | bordered |
| defaultOpen | boolean | — | false |
| open | boolean | — | — |
| onOpenChange | function | — | — |
## Sub-components
| Component | Wraps | Styling |
|---|---|---|
| `Collapsible` | Radix Collapsible.Root | variant CVA (bordered vs ghost) |
| `CollapsibleTrigger` | Radix Trigger (re-export) | none — consumer styles the trigger content |
| `CollapsibleContent` | Radix Content | padding, animated height |
## Token Mapping
| Element | Variant | State | ToolJet class |
|---|---|---|---|
| root container | bordered | default | `tw-border-solid tw-border tw-border-border-weak tw-rounded-lg` |
| root container | ghost | default | no border, no bg |
| trigger row | all | default | `tw-flex tw-items-center tw-justify-between tw-w-full tw-cursor-pointer` |
| trigger row | bordered | default | `tw-px-4 tw-py-3` |
| trigger row | ghost | default | `tw-py-2` |
| trigger text | all | default | `tw-font-title-default tw-text-text-default` |
| trigger chevron | all | default | `tw-text-icon-default tw-size-4` |
| trigger chevron | all | open | `tw-rotate-180` (via data-[state=open]) |
| content area | bordered | default | `tw-px-4 tw-pb-3` |
| content area | ghost | default | `tw-py-2` |
| content text | all | default | `tw-font-body-default tw-text-text-default` |
## Slots
- trigger (required, `ReactNode`) — the clickable header content (label, count badge, etc.)
- children (required, `ReactNode`) — the collapsible content area
## CVA Shape
Shape B — variants only (`bordered` | `ghost`). No sizes.
## Usage Pattern
```jsx
<Collapsible variant="bordered">
<CollapsibleTrigger>
<span>Missing user groups (5)</span>
</CollapsibleTrigger>
<CollapsibleContent>
<p>Group 1, Group 2, Group 3, Group 4, Group 5</p>
</CollapsibleContent>
</Collapsible>
```
## Notes
- Radix handles open/close state, keyboard (Enter/Space), and aria-expanded automatically.
- The chevron rotation is handled via `data-[state=open]:tw-rotate-180` on the icon inside the trigger. Since the trigger is consumer-composed, Rocket provides a `CollapsibleIcon` helper that auto-rotates.
- Animated height transition on CollapsibleContent via `tw-overflow-hidden` + CSS `grid-template-rows` animation (same pattern as shadcn accordion).
- Typography uses `tw-font-*` plugin utilities.

View file

@ -0,0 +1,84 @@
import React from 'react';
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from './Collapsible';
export default {
title: 'Rocket/Collapsible',
tags: ['autodocs'],
parameters: { layout: 'centered' },
};
// Bordered (default)
export const Bordered = {
render: () => (
<div className="tw-w-[400px]">
<Collapsible>
<CollapsibleTrigger>
<span>Missing user groups (5)</span>
</CollapsibleTrigger>
<CollapsibleContent>
<p className="tw-font-body-default tw-text-text-default">Group 1, Group 2, Group 3, Group 4, Group 5</p>
</CollapsibleContent>
</Collapsible>
</div>
),
};
// Ghost
export const Ghost = {
render: () => (
<div className="tw-w-[400px]">
<Collapsible variant="ghost">
<CollapsibleTrigger>
<span>Advanced settings</span>
</CollapsibleTrigger>
<CollapsibleContent>
<p className="tw-font-body-default tw-text-text-default">Additional configuration options would go here.</p>
</CollapsibleContent>
</Collapsible>
</div>
),
};
// Default Open
export const DefaultOpen = {
render: () => (
<div className="tw-w-[400px]">
<Collapsible defaultOpen>
<CollapsibleTrigger>
<span>Missing user groups (5)</span>
</CollapsibleTrigger>
<CollapsibleContent>
<p className="tw-font-body-default tw-text-text-default">Group 1, Group 2, Group 3, Group 4, Group 5</p>
</CollapsibleContent>
</Collapsible>
</div>
),
};
// Multiple
export const Multiple = {
render: () => (
<div className="tw-w-[400px] tw-flex tw-flex-col tw-gap-2">
<Collapsible>
<CollapsibleTrigger>
<span>Section 1</span>
</CollapsibleTrigger>
<CollapsibleContent>
<p className="tw-font-body-default tw-text-text-default">Content for section 1.</p>
</CollapsibleContent>
</Collapsible>
<Collapsible>
<CollapsibleTrigger>
<span>Section 2</span>
</CollapsibleTrigger>
<CollapsibleContent>
<p className="tw-font-body-default tw-text-text-default">Content for section 2.</p>
</CollapsibleContent>
</Collapsible>
</div>
),
};

View file

@ -139,14 +139,18 @@ ComboboxInput.propTypes = {
// ComboboxContent
const ComboboxContent = forwardRef(function ComboboxContent({ className, ...props }, ref) {
const ComboboxContent = forwardRef(function ComboboxContent({ anchor, className, ...props }, ref) {
const anchorRef = useContext(ComboboxAnchorContext);
// Use custom anchor prop if provided. Default to the context ref (full-width input wrapper).
// Pass `anchor={false}` to skip the custom anchor and let Base UI anchor to the trigger element
// (useful when using ComboboxTrigger with a custom render button).
const resolvedAnchor = anchor !== undefined ? anchor || undefined : anchorRef;
return (
<ShadcnComboboxContent
ref={ref}
anchor={anchorRef}
anchor={resolvedAnchor}
className={cn(
'tw-ring-interactive-weak tw-bg-background-surface-layer-01 tw-rounded-[10px] tw-shadow-elevation-300 tw-p-2',
'tw-ring-interactive-weak tw-bg-background-surface-layer-01 tw-rounded-lg tw-shadow-elevation-300 tw-p-2',
className
)}
{...props}

View file

@ -1,7 +1,10 @@
import React from 'react';
import { cn } from '@/lib/utils';
import {
Combobox,
ComboboxInput,
ComboboxTrigger,
ComboboxValue,
ComboboxContent,
ComboboxList,
ComboboxItem,
@ -11,6 +14,7 @@ import {
ComboboxSeparator,
} from './Combobox';
import { Field, FieldLabel, FieldDescription, FieldError } from '../Field/Field';
import { ChevronDown } from 'lucide-react';
const frameworks = ['React', 'Vue', 'Angular', 'Svelte', 'Solid', 'Qwik'];
@ -321,6 +325,57 @@ export const EmptyState = {
parameters: { layout: 'padded' },
};
// Custom Trigger
// Button-style trigger (e.g. workspace selector) instead of the default input.
export const CustomTrigger = {
render: () => {
const workspaces = [
{ label: 'ABC cargo main team', value: 'abc-cargo-main-team' },
{ label: 'Design system', value: 'design-system' },
{ label: 'Backend API', value: 'backend-api' },
];
return (
<div className="tw-w-72 tw-p-4">
<Combobox value={workspaces[0]} items={workspaces}>
<ComboboxTrigger
render={
<button
className={cn(
'tw-flex tw-items-center tw-gap-1.5 tw-px-2 tw-py-1.5',
'tw-font-title-default tw-text-text-default tw-max-w-48',
'tw-rounded-md tw-border-0 tw-bg-transparent',
'hover:tw-bg-interactive-default',
'data-[pressed]:tw-bg-interactive-selected',
'data-[popup-open]:tw-bg-interactive-selected'
)}
>
<ComboboxValue>
{(value) => <span className="tw-flex-1 tw-truncate">{value?.label ?? ''}</span>}
</ComboboxValue>
<ChevronDown className="tw-size-4 tw-text-icon-default tw-shrink-0" />
</button>
}
/>
<ComboboxContent anchor={false} align="start" className="tw-flex tw-flex-col tw-gap-2">
<ComboboxInput showTrigger={false} placeholder="Search workspace..." />
<ComboboxList>
{(item) => (
<ComboboxItem key={item.value} value={item}>
{item.label}
</ComboboxItem>
)}
</ComboboxList>
<ComboboxEmpty>No workspaces found.</ComboboxEmpty>
</ComboboxContent>
</Combobox>
</div>
);
},
parameters: { layout: 'padded' },
};
// All States
export const AllStates = {
render: () => {

View file

@ -1,4 +1,4 @@
import React, { forwardRef } from 'react';
import React, { createContext, forwardRef, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import PropTypes from 'prop-types';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { cva } from 'class-variance-authority';
@ -13,6 +13,10 @@ import {
DialogPortal,
} from '@/components/ui/Rocket/shadcn/dialog';
// Overflow context (DialogBody DialogFooter)
const DialogOverflowContext = createContext({ overflowing: false, setOverflowing: () => {} });
// DialogOverlay
const DialogOverlay = forwardRef(function DialogOverlay({ className, ...props }, ref) {
@ -61,6 +65,12 @@ const DialogContent = forwardRef(function DialogContent(
{ className, children, size, showCloseButton = true, preventClose = false, ...props },
ref
) {
const [bodyOverflowing, setBodyOverflowing] = useState(false);
const overflowCtx = useMemo(
() => ({ overflowing: bodyOverflowing, setOverflowing: setBodyOverflowing }),
[bodyOverflowing]
);
const handleInteractOutside = (e) => {
if (preventClose) e.preventDefault();
props.onInteractOutside?.(e);
@ -87,7 +97,7 @@ const DialogContent = forwardRef(function DialogContent(
)}
{...props}
>
{children}
<DialogOverflowContext.Provider value={overflowCtx}>{children}</DialogOverflowContext.Provider>
{showCloseButton && (
<DialogClose asChild>
<Button
@ -140,13 +150,37 @@ const DialogBody = forwardRef(function DialogBody(
{ className, noPadding = false, scrollable = false, children, ...props },
ref
) {
const innerRef = useRef(null);
const { setOverflowing } = useContext(DialogOverflowContext);
const mergedRef = useCallback(
(node) => {
innerRef.current = node;
if (typeof ref === 'function') ref(node);
else if (ref) ref.current = node;
},
[ref]
);
useEffect(() => {
const node = innerRef.current;
if (!node) return;
const check = () => setOverflowing(node.scrollHeight > node.clientHeight);
const observer = new ResizeObserver(check);
observer.observe(node);
check();
return () => observer.disconnect();
}, [setOverflowing]);
return (
<div
ref={ref}
ref={mergedRef}
data-slot="dialog-body"
className={cn(
'tw-flex-1 tw-min-h-0',
!noPadding && 'tw-p-6',
!noPadding && 'tw-px-6 tw-py-4',
scrollable && 'tw-overflow-y-auto tw-max-h-[85vh]',
className
)}
@ -167,13 +201,16 @@ DialogBody.propTypes = {
// DialogFooter
function DialogFooter({ className, ...props }) {
const { overflowing } = useContext(DialogOverflowContext);
return (
<div
data-slot="dialog-footer"
className={cn(
'tw-px-6 tw-py-4',
'tw-flex tw-flex-row tw-items-center tw-justify-between tw-gap-2',
'tw-border-solid tw-border-0 tw-border-t tw-border-border-weak',
'tw-border-solid tw-border-0 tw-border-t',
overflowing ? 'tw-border-border-weak' : 'tw-border-transparent',
className
)}
{...props}

View file

@ -220,6 +220,62 @@ export const Scrollable = {
),
};
// Overflow Border
// Footer border only appears when body content overflows.
export const OverflowBorder = {
render: () => (
<div className="tw-flex tw-gap-3">
<Dialog>
<DialogTrigger asChild>
<Button variant="outline">Short Content</Button>
</DialogTrigger>
<DialogContent size="small">
<DialogHeader>
<DialogTitle>Short Content</DialogTitle>
</DialogHeader>
<DialogBody scrollable>
<p className="tw-text-sm tw-text-text-default">This content fits no footer border.</p>
</DialogBody>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<Button variant="primary">Confirm</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog>
<DialogTrigger asChild>
<Button variant="outline">Long Content</Button>
</DialogTrigger>
<DialogContent size="small">
<DialogHeader>
<DialogTitle>Long Content</DialogTitle>
</DialogHeader>
<DialogBody scrollable>
<div className="tw-flex tw-flex-col tw-gap-4 tw-text-sm tw-text-text-default">
{Array.from({ length: 15 }, (_, i) => (
<p key={i}>
Paragraph {i + 1}: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor
incididunt ut labore et dolore magna aliqua.
</p>
))}
</div>
</DialogBody>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<Button variant="primary">Confirm</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
),
};
// No Padding
export const NoPadding = {

View file

@ -0,0 +1,84 @@
import React, { forwardRef } from 'react';
import PropTypes from 'prop-types';
import * as RadioGroupPrimitive from '@radix-ui/react-radio-group';
import { cva } from 'class-variance-authority';
import { cn } from '@/lib/utils';
// RadioGroup
const RadioGroup = forwardRef(function RadioGroup({ className, ...props }, ref) {
return (
<RadioGroupPrimitive.Root
ref={ref}
data-slot="radio-group"
className={cn('tw-grid tw-gap-2', className)}
{...props}
/>
);
});
RadioGroup.displayName = 'RadioGroup';
RadioGroup.propTypes = {
className: PropTypes.string,
};
// RadioGroupItem
const radioGroupItemVariants = cva(
[
// Layout
'tw-relative tw-flex tw-shrink-0 tw-items-center tw-justify-center tw-rounded-full',
// Reset
'tw-appearance-none tw-outline-none',
// Default border + bg
'tw-border-solid tw-border tw-border-border-default',
'tw-bg-background-surface-layer-01',
'tw-transition-colors',
// Checked
'data-[state=checked]:tw-bg-button-primary data-[state=checked]:tw-border-button-primary',
// Disabled
'disabled:tw-cursor-not-allowed',
'disabled:tw-bg-controls-base-inactive disabled:tw-border-transparent',
'disabled:data-[state=checked]:tw-bg-controls-base-inactive disabled:data-[state=checked]:tw-border-transparent',
// Focus ring
'focus-visible:tw-ring-2 focus-visible:tw-ring-interactive-focus-outline focus-visible:tw-ring-offset-2 focus-visible:tw-ring-offset-background',
],
{
variants: {
size: {
large: 'tw-size-5',
default: 'tw-size-4',
},
},
defaultVariants: { size: 'default' },
}
);
const innerDotSizeClasses = {
large: 'tw-size-2',
default: 'tw-size-1.5',
};
const RadioGroupItem = forwardRef(function RadioGroupItem({ className, size = 'default', ...props }, ref) {
return (
<RadioGroupPrimitive.Item
ref={ref}
data-slot="radio-group-item"
className={cn(radioGroupItemVariants({ size }), className)}
{...props}
>
<RadioGroupPrimitive.Indicator
data-slot="radio-group-indicator"
className="tw-flex tw-items-center tw-justify-center"
>
<span className={cn('tw-rounded-full tw-bg-text-on-solid', innerDotSizeClasses[size])} />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
);
});
RadioGroupItem.displayName = 'RadioGroupItem';
RadioGroupItem.propTypes = {
size: PropTypes.oneOf(['large', 'default']),
className: PropTypes.string,
};
export { RadioGroup, RadioGroupItem, radioGroupItemVariants };

View file

@ -0,0 +1,70 @@
# RadioGroup — Rocket Design Spec
<!-- figma: https://www.figma.com/design/XQdM9x8x9YHcj1lUK8abUG/Rocket-components?node-id=93-52292 -->
<!-- synced: 2026-04-07 -->
## Overview
Single-selection group of radio inputs. Wraps Radix RadioGroup via shadcn. The visual design mirrors Checkbox but uses circular shapes — same sizes, same color tokens, same focus/disabled states.
Two sub-components:
- `RadioGroup` — group container, manages selection state
- `RadioGroupItem` — individual radio circle
Label is **not** baked in — consumers compose `<label>` + `<RadioGroupItem>`.
## Props
### RadioGroup
| Prop | Type | Default |
|---|---|---|
| value | string | — |
| defaultValue | string | — |
| onValueChange | function | — |
| disabled | boolean | false |
| name | string | — |
| className | string | — |
### RadioGroupItem
| Prop | Type | Values | Default |
|---|---|---|---|
| size | string | large \| default | default |
| value | string (required) | — | — |
| disabled | boolean | — | false |
| className | string | — | — |
## Sizes
| Value | Box | Inner dot |
|---|---|---|
| default | 16px (`tw-size-4`) | small filled circle |
| large | 20px (`tw-size-5`) | small filled circle |
## Token Mapping
| Element | State | Figma token | ToolJet class |
|---|---|---|---|
| bg | unchecked | `--bg-surface-layer-01` | `tw-bg-background-surface-layer-01` |
| border | unchecked | `border/default` | `tw-border-solid tw-border tw-border-border-default` |
| bg | checked | `button/primary` | `data-[state=checked]:tw-bg-button-primary` |
| border | checked | `button/primary` | `data-[state=checked]:tw-border-button-primary` |
| inner dot | checked | `icon/on-solid` (white) | `tw-bg-text-on-solid` |
| bg | disabled | `controls/base-inactive` | `disabled:tw-bg-controls-base-inactive disabled:tw-border-transparent` |
| focus ring | focused | `interactive/focusActive` | `focus-visible:tw-ring-2 focus-visible:tw-ring-interactive-focus-outline focus-visible:tw-ring-offset-2` |
| cursor | disabled | — | `disabled:tw-cursor-not-allowed` |
## CVA Shape
Shape C — sizes only, applied to `RadioGroupItem`.
## Notes
- Wraps Radix `RadioGroup.Root` + `RadioGroup.Item` + `RadioGroup.Indicator` via shadcn.
- Both circular: `tw-rounded-full` for default and large.
- Inner dot rendered via `<RadioGroup.Indicator>` containing a smaller filled circle.
- Inner dot size: `tw-size-1.5` (6px) for default, `tw-size-2` (8px) for large.
- `RadioGroup` wrapper provides selection state via Radix context — items must be wrapped in a `RadioGroup`.
- Disabled state: bg becomes `controls-base-inactive` (subtle gray), border removed (transparent).
- Same token tokens as Checkbox — consistent UX across binary controls.
- Consumer composes label: `<label className="tw-flex tw-items-center tw-gap-2"><RadioGroupItem value="x" /> <span>Label</span></label>`.

View file

@ -0,0 +1,118 @@
import React from 'react';
import { RadioGroup, RadioGroupItem } from './RadioGroup';
export default {
title: 'Rocket/RadioGroup',
tags: ['autodocs'],
parameters: { layout: 'centered' },
};
// Default
export const Default = {
render: () => (
<RadioGroup defaultValue="option-1">
<RadioGroupItem value="option-1" />
<RadioGroupItem value="option-2" />
<RadioGroupItem value="option-3" />
</RadioGroup>
),
};
// With Labels
export const WithLabels = {
render: () => (
<RadioGroup defaultValue="comfortable">
<label className="tw-flex tw-items-center tw-gap-2 tw-cursor-pointer tw-font-body-default tw-text-text-default">
<RadioGroupItem value="default" />
<span>Default density</span>
</label>
<label className="tw-flex tw-items-center tw-gap-2 tw-cursor-pointer tw-font-body-default tw-text-text-default">
<RadioGroupItem value="comfortable" />
<span>Comfortable density</span>
</label>
<label className="tw-flex tw-items-center tw-gap-2 tw-cursor-pointer tw-font-body-default tw-text-text-default">
<RadioGroupItem value="compact" />
<span>Compact density</span>
</label>
</RadioGroup>
),
};
// Sizes
export const Sizes = {
render: () => (
<div className="tw-flex tw-items-center tw-gap-4">
<RadioGroup defaultValue="a">
<RadioGroupItem value="a" size="default" />
</RadioGroup>
<RadioGroup defaultValue="b">
<RadioGroupItem value="b" size="large" />
</RadioGroup>
</div>
),
};
// Disabled
export const Disabled = {
render: () => (
<RadioGroup defaultValue="option-2" disabled>
<label className="tw-flex tw-items-center tw-gap-2 tw-font-body-default tw-text-text-default">
<RadioGroupItem value="option-1" />
<span>Option 1</span>
</label>
<label className="tw-flex tw-items-center tw-gap-2 tw-font-body-default tw-text-text-default">
<RadioGroupItem value="option-2" />
<span>Option 2 (selected)</span>
</label>
</RadioGroup>
),
};
// All States
export const AllStates = {
render: () => (
<div className="tw-flex tw-flex-col tw-gap-4">
<div className="tw-flex tw-items-center tw-gap-3">
<span className="tw-w-32 tw-font-body-default tw-text-text-default">Unchecked</span>
<RadioGroup>
<div className="tw-flex tw-items-center tw-gap-3">
<RadioGroupItem value="a" />
<RadioGroupItem value="a" size="large" />
</div>
</RadioGroup>
</div>
<div className="tw-flex tw-items-center tw-gap-3">
<span className="tw-w-32 tw-font-body-default tw-text-text-default">Checked</span>
<RadioGroup defaultValue="a">
<div className="tw-flex tw-items-center tw-gap-3">
<RadioGroupItem value="a" />
<RadioGroupItem value="a" size="large" />
</div>
</RadioGroup>
</div>
<div className="tw-flex tw-items-center tw-gap-3">
<span className="tw-w-32 tw-font-body-default tw-text-text-default">Disabled</span>
<RadioGroup disabled>
<div className="tw-flex tw-items-center tw-gap-3">
<RadioGroupItem value="a" />
<RadioGroupItem value="a" size="large" />
</div>
</RadioGroup>
</div>
<div className="tw-flex tw-items-center tw-gap-3">
<span className="tw-w-32 tw-font-body-default tw-text-text-default">Disabled checked</span>
<RadioGroup defaultValue="a" disabled>
<div className="tw-flex tw-items-center tw-gap-3">
<RadioGroupItem value="a" />
<RadioGroupItem value="a" size="large" />
</div>
</RadioGroup>
</div>
</div>
),
};

View file

@ -0,0 +1,254 @@
import React, { createContext, forwardRef, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import PropTypes from 'prop-types';
import * as SheetPrimitive from '@radix-ui/react-dialog';
import { cva } from 'class-variance-authority';
import { X } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/Rocket/Button/Button';
import { SheetTrigger, SheetClose, SheetPortal } from '@/components/ui/Rocket/shadcn/sheet';
// Overflow context (SheetBody SheetFooter)
const SheetOverflowContext = createContext({ overflowing: false, setOverflowing: () => {} });
// Sheet (root pass-through)
const Sheet = SheetPrimitive.Root;
// SheetOverlay
const SheetOverlay = forwardRef(function SheetOverlay({ className, ...props }, ref) {
return (
<SheetPrimitive.Overlay
ref={ref}
data-slot="sheet-overlay"
className={cn(
'tw-fixed tw-inset-0 tw-z-50 tw-bg-black/50',
'data-[state=open]:tw-animate-in data-[state=closed]:tw-animate-out data-[state=closed]:tw-fade-out-0 data-[state=open]:tw-fade-in-0',
className
)}
{...props}
/>
);
});
SheetOverlay.displayName = 'SheetOverlay';
// SheetContent
const sheetContentVariants = cva(
[
'tw-fixed tw-inset-y-0 tw-right-0 tw-z-50 tw-h-full',
'tw-bg-background-surface-layer-01',
'tw-shadow-elevation-400',
'tw-border-solid tw-border-l tw-border-border-weak',
'tw-flex tw-flex-col',
'tw-outline-none',
// Animations
'data-[state=open]:tw-animate-in data-[state=closed]:tw-animate-out',
'data-[state=open]:tw-slide-in-from-right data-[state=closed]:tw-slide-out-to-right',
'tw-duration-200',
],
{
variants: {
size: {
large: 'tw-w-[720px]',
default: 'tw-w-[560px]',
small: 'tw-w-[400px]',
},
},
defaultVariants: { size: 'default' },
}
);
const SheetContent = forwardRef(function SheetContent(
{ className, children, size, showCloseButton = true, preventClose = false, ...props },
ref
) {
const [bodyOverflowing, setBodyOverflowing] = useState(false);
const overflowCtx = useMemo(
() => ({ overflowing: bodyOverflowing, setOverflowing: setBodyOverflowing }),
[bodyOverflowing]
);
const handleInteractOutside = (e) => {
if (preventClose) e.preventDefault();
props.onInteractOutside?.(e);
};
const handleEscapeKeyDown = (e) => {
if (preventClose) e.preventDefault();
props.onEscapeKeyDown?.(e);
};
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
data-slot="sheet-content"
onInteractOutside={handleInteractOutside}
onEscapeKeyDown={handleEscapeKeyDown}
className={cn(sheetContentVariants({ size }), className)}
{...props}
>
<SheetOverflowContext.Provider value={overflowCtx}>{children}</SheetOverflowContext.Provider>
{showCloseButton && (
<SheetClose asChild>
<Button
variant="ghost"
iconOnly
size="small"
className="tw-absolute tw-right-3 tw-top-4"
aria-label="Close"
>
<X className="tw-size-4" />
</Button>
</SheetClose>
)}
</SheetPrimitive.Content>
</SheetPortal>
);
});
SheetContent.displayName = 'SheetContent';
SheetContent.propTypes = {
size: PropTypes.oneOf(['large', 'default', 'small']),
showCloseButton: PropTypes.bool,
preventClose: PropTypes.bool,
className: PropTypes.string,
children: PropTypes.node,
};
// SheetHeader
function SheetHeader({ className, ...props }) {
return (
<div
data-slot="sheet-header"
className={cn(
'tw-flex tw-flex-row tw-items-center',
'tw-h-14 tw-px-6 tw-py-0',
'tw-gap-0 tw-shrink-0',
'tw-border-solid tw-border-0 tw-border-b tw-border-border-weak',
className
)}
{...props}
/>
);
}
SheetHeader.displayName = 'SheetHeader';
// SheetBody
const SheetBody = forwardRef(function SheetBody({ className, noPadding = false, children, ...props }, ref) {
const innerRef = useRef(null);
const { setOverflowing } = useContext(SheetOverflowContext);
const mergedRef = useCallback(
(node) => {
innerRef.current = node;
if (typeof ref === 'function') ref(node);
else if (ref) ref.current = node;
},
[ref]
);
useEffect(() => {
const node = innerRef.current;
if (!node) return;
const check = () => setOverflowing(node.scrollHeight > node.clientHeight);
const observer = new ResizeObserver(check);
observer.observe(node);
node.addEventListener('scroll', check);
check();
return () => {
observer.disconnect();
node.removeEventListener('scroll', check);
};
}, [setOverflowing]);
return (
<div
ref={mergedRef}
data-slot="sheet-body"
className={cn('tw-flex-1 tw-min-h-0 tw-overflow-y-auto', !noPadding && 'tw-p-6', className)}
{...props}
>
{children}
</div>
);
});
SheetBody.displayName = 'SheetBody';
SheetBody.propTypes = {
noPadding: PropTypes.bool,
className: PropTypes.string,
children: PropTypes.node,
};
// SheetFooter
function SheetFooter({ className, ...props }) {
const { overflowing } = useContext(SheetOverflowContext);
return (
<div
data-slot="sheet-footer"
className={cn(
'tw-px-6 tw-py-4 tw-shrink-0',
'tw-flex tw-flex-row tw-items-center tw-justify-between tw-gap-2',
'tw-border-solid tw-border-0 tw-border-t',
overflowing ? 'tw-border-border-weak' : 'tw-border-transparent',
className
)}
{...props}
/>
);
}
SheetFooter.displayName = 'SheetFooter';
// SheetTitle
const SheetTitle = forwardRef(function SheetTitle({ className, ...props }, ref) {
return (
<SheetPrimitive.Title
ref={ref}
data-slot="sheet-title"
className={cn('tw-font-title-large tw-text-text-default tw-mb-0', className)}
{...props}
/>
);
});
SheetTitle.displayName = 'SheetTitle';
// SheetDescription
const SheetDescription = forwardRef(function SheetDescription({ className, ...props }, ref) {
return (
<SheetPrimitive.Description
ref={ref}
data-slot="sheet-description"
className={cn('tw-font-body-small tw-text-text-placeholder', className)}
{...props}
/>
);
});
SheetDescription.displayName = 'SheetDescription';
// Exports
export {
Sheet,
SheetTrigger,
SheetClose,
SheetPortal,
SheetOverlay,
SheetContent,
sheetContentVariants,
SheetHeader,
SheetBody,
SheetFooter,
SheetTitle,
SheetDescription,
};

View file

@ -0,0 +1,74 @@
# Sheet — Rocket Design Spec
<!-- synced: 2026-04-07 -->
## Overview
Sheet is a slide-in panel from the right edge of the viewport. Used for multi-step forms or detail views that need more space than a Dialog can provide (e.g. adding a new datasource through multiple steps).
Wraps Radix Dialog primitives via shadcn (sheet is a Dialog with side animations). Mirrors Dialog's structure (Header / Body / Footer) including the conditional footer overflow border.
## Props (SheetContent)
| Prop | Type | Values | Default |
|---|---|---|---|
| size | string | large \| default \| small | default |
| showCloseButton | boolean | — | true |
| preventClose | boolean | — | false |
## Sizes
| Value | Width | Tailwind |
|---|---|---|
| small | 400px | tw-w-[400px] |
| default | 560px | tw-w-[560px] |
| large | 720px | tw-w-[720px] |
## Sub-components
| Component | Wraps | Styling |
|---|---|---|
| `Sheet` | Radix Dialog.Root (pass-through) | none |
| `SheetTrigger` | Re-export from shadcn | none |
| `SheetClose` | Re-export from shadcn | none |
| `SheetPortal` | Re-export from shadcn | none |
| `SheetOverlay` | Radix Overlay (direct) | tw-bg-black/50, animations |
| `SheetContent` | Radix Content (direct) | size CVA, slide-in from right, tokens |
| `SheetHeader` | plain div | border-bottom, fixed 56px height, horizontal padding |
| `SheetBody` | plain div (custom) | scrollable, ResizeObserver for overflow detection |
| `SheetFooter` | plain div | border-top (conditional on body overflow), padding |
| `SheetTitle` | Radix Title (direct) | tw-font-title-large + color |
| `SheetDescription` | Radix Description (direct) | tw-font-body-small + color |
## Token Mapping
| Element | State | ToolJet class |
|---|---|---|
| content bg | default | tw-bg-background-surface-layer-01 |
| content shadow | default | tw-shadow-elevation-400 |
| content border-left | default | tw-border-solid tw-border-l tw-border-border-weak |
| overlay bg | default | tw-bg-black/50 |
| header border | default | tw-border-solid tw-border-b tw-border-border-weak |
| header height | default | tw-h-14 |
| header padding | default | tw-px-6 tw-py-0 |
| title text | default | tw-font-title-large tw-text-text-default |
| description text | default | tw-font-body-small tw-text-text-placeholder |
| body padding | default | tw-p-6 |
| footer border (overflowing) | default | tw-border-solid tw-border-t tw-border-border-weak |
| footer border (no overflow) | default | tw-border-transparent |
| footer padding | default | tw-px-6 tw-py-4 |
| footer layout | default | tw-flex tw-justify-between tw-items-center |
| close button position | default | absolute top-right of content |
## CVA Shape
Shape C — sizes only. `sheetContentVariants` has `size` CVA for max-width. Rest use static `cn()`.
## Notes
- Right-side only for now (not exposing `side` prop). Sheet always slides in from the right edge.
- Slide-in animation via `data-[state=open]:tw-slide-in-from-right` and `data-[state=closed]:tw-slide-out-to-right`.
- **Overflow border** on footer mirrors Dialog: ResizeObserver on SheetBody detects when content overflows, sets context, footer reads context for `border-t` color.
- `preventClose` blocks overlay click via `onInteractOutside` + Escape via `onEscapeKeyDown` (same as Dialog).
- Close button is positioned absolute top-right of content (same as Dialog).
- Full viewport height — sheet stretches from top to bottom of the screen.
- Typography uses `tw-font-*` plugin utilities.

View file

@ -0,0 +1,190 @@
import React from 'react';
import {
Sheet,
SheetTrigger,
SheetContent,
SheetHeader,
SheetBody,
SheetFooter,
SheetTitle,
SheetDescription,
SheetClose,
} from './Sheet';
import { Button } from '@/components/ui/Rocket/Button/Button';
export default {
title: 'Rocket/Sheet',
tags: ['autodocs'],
parameters: { layout: 'centered' },
};
// Default
export const Default = {
render: () => (
<Sheet>
<SheetTrigger asChild>
<Button variant="primary">Open Sheet</Button>
</SheetTrigger>
<SheetContent>
<SheetHeader>
<SheetTitle>Add new datasource</SheetTitle>
</SheetHeader>
<SheetBody>
<p className="tw-text-sm tw-text-text-default">Sheet body content goes here.</p>
</SheetBody>
<SheetFooter>
<SheetClose asChild>
<Button variant="outline">Cancel</Button>
</SheetClose>
<Button variant="primary">Continue</Button>
</SheetFooter>
</SheetContent>
</Sheet>
),
};
// Sizes
export const Sizes = {
render: () => (
<div className="tw-flex tw-gap-3">
{['small', 'default', 'large'].map((size) => (
<Sheet key={size}>
<SheetTrigger asChild>
<Button variant="outline">{size}</Button>
</SheetTrigger>
<SheetContent size={size}>
<SheetHeader>
<SheetTitle>Size: {size}</SheetTitle>
</SheetHeader>
<SheetBody>
<p className="tw-text-sm tw-text-text-default">
This sheet uses the <code>{size}</code> size variant.
</p>
</SheetBody>
<SheetFooter>
<SheetClose asChild>
<Button variant="outline">Close</Button>
</SheetClose>
</SheetFooter>
</SheetContent>
</Sheet>
))}
</div>
),
};
// With Description
export const WithDescription = {
render: () => (
<Sheet>
<SheetTrigger asChild>
<Button variant="primary">Add Datasource</Button>
</SheetTrigger>
<SheetContent>
<SheetHeader>
<div className="tw-flex tw-flex-col tw-gap-1">
<SheetTitle>Connect a new datasource</SheetTitle>
<SheetDescription>Choose a datasource type to get started.</SheetDescription>
</div>
</SheetHeader>
<SheetBody>
<p className="tw-text-sm tw-text-text-default">
Sheet body content for selecting and configuring a datasource.
</p>
</SheetBody>
<SheetFooter>
<SheetClose asChild>
<Button variant="outline">Cancel</Button>
</SheetClose>
<Button variant="primary">Next</Button>
</SheetFooter>
</SheetContent>
</Sheet>
),
};
// Overflow Border
// Footer border only appears when body content overflows.
export const OverflowBorder = {
render: () => (
<div className="tw-flex tw-gap-3">
<Sheet>
<SheetTrigger asChild>
<Button variant="outline">Short Content</Button>
</SheetTrigger>
<SheetContent>
<SheetHeader>
<SheetTitle>Short Content</SheetTitle>
</SheetHeader>
<SheetBody>
<p className="tw-text-sm tw-text-text-default">This content fits no footer border.</p>
</SheetBody>
<SheetFooter>
<SheetClose asChild>
<Button variant="outline">Cancel</Button>
</SheetClose>
<Button variant="primary">Confirm</Button>
</SheetFooter>
</SheetContent>
</Sheet>
<Sheet>
<SheetTrigger asChild>
<Button variant="outline">Long Content</Button>
</SheetTrigger>
<SheetContent>
<SheetHeader>
<SheetTitle>Long Content</SheetTitle>
</SheetHeader>
<SheetBody>
<div className="tw-flex tw-flex-col tw-gap-4 tw-text-sm tw-text-text-default">
{Array.from({ length: 30 }, (_, i) => (
<p key={i}>
Paragraph {i + 1}: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor
incididunt ut labore et dolore magna aliqua.
</p>
))}
</div>
</SheetBody>
<SheetFooter>
<SheetClose asChild>
<Button variant="outline">Cancel</Button>
</SheetClose>
<Button variant="primary">Confirm</Button>
</SheetFooter>
</SheetContent>
</Sheet>
</div>
),
};
// Prevent Close
export const PreventClose = {
render: () => (
<Sheet>
<SheetTrigger asChild>
<Button variant="outline">Prevent Close</Button>
</SheetTrigger>
<SheetContent preventClose>
<SheetHeader>
<SheetTitle>Multi-step form</SheetTitle>
</SheetHeader>
<SheetBody>
<p className="tw-text-sm tw-text-text-default">
Clicking outside or pressing Escape won&apos;t close this sheet. Use the footer button.
</p>
</SheetBody>
<SheetFooter>
<SheetClose asChild>
<Button variant="primary">Done</Button>
</SheetClose>
</SheetFooter>
</SheetContent>
</Sheet>
),
};

View file

@ -0,0 +1,16 @@
import React from 'react';
import PropTypes from 'prop-types';
import { cn } from '@/lib/utils';
import { Skeleton as ShadcnSkeleton } from '@/components/ui/Rocket/shadcn/skeleton';
const skeletonClasses = 'tw-animate-pulse tw-rounded-md tw-bg-interactive-hover';
function Skeleton({ className, ...props }) {
return <ShadcnSkeleton className={cn(skeletonClasses, className)} {...props} />;
}
Skeleton.displayName = 'Skeleton';
Skeleton.propTypes = {
className: PropTypes.string,
};
export { Skeleton, skeletonClasses };

View file

@ -0,0 +1,34 @@
# Skeleton — Rocket Design Spec
<!-- synced: 2026-04-07 -->
## Overview
Skeleton is a loading placeholder primitive — a pulsing rectangle. Used in loading states for content (text, lists, tables, cards) before real data arrives.
Wraps shadcn skeleton with ToolJet token override.
## Props
| Prop | Type | Default |
|---|---|---|
| className | string | — |
Sized via Tailwind utilities (e.g. `tw-h-4 tw-w-32`).
## Token Mapping
| Element | ToolJet class |
|---|---|
| bg | `tw-bg-interactive-hover` |
| animation | `tw-animate-pulse` |
| radius | `tw-rounded-md` |
## CVA Shape
Shape D — no CVA. Single static class set.
## Notes
- Brought from PR #14498 (`Rocket/skeleton.jsx`).
- Background uses `tw-bg-interactive-hover` (subtle gray) instead of shadcn's `tw-bg-muted` semantic token.
- Consumer sets dimensions via className.

View file

@ -0,0 +1,34 @@
import React from 'react';
import { Skeleton } from './Skeleton';
export default {
title: 'Rocket/Skeleton',
tags: ['autodocs'],
parameters: { layout: 'padded' },
};
export const Default = {
render: () => <Skeleton className="tw-h-4 tw-w-48" />,
};
export const Card = {
render: () => (
<div className="tw-flex tw-flex-col tw-gap-3 tw-w-[320px]">
<Skeleton className="tw-h-32 tw-w-full" />
<Skeleton className="tw-h-4 tw-w-3/4" />
<Skeleton className="tw-h-4 tw-w-1/2" />
</div>
),
};
export const ListRow = {
render: () => (
<div className="tw-flex tw-items-center tw-gap-3 tw-w-[400px]">
<Skeleton className="tw-size-10 tw-rounded-full" />
<div className="tw-flex tw-flex-col tw-gap-2 tw-flex-1">
<Skeleton className="tw-h-4 tw-w-3/4" />
<Skeleton className="tw-h-3 tw-w-1/2" />
</div>
</div>
),
};

View file

@ -0,0 +1,82 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Toaster as SonnerToaster, toast } from 'sonner';
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon, X } from 'lucide-react';
import { cn } from '@/lib/utils';
// Toaster
// Place once in the app root. All toast() calls will render here.
const toastIcons = {
success: <CircleCheckIcon className="tw-size-5" />,
info: <InfoIcon className="tw-size-5" />,
warning: <TriangleAlertIcon className="tw-size-5" />,
error: <OctagonXIcon className="tw-size-5" />,
loading: <Loader2Icon className="tw-size-5 tw-animate-spin" />,
close: <X className="tw-size-4" />,
};
const toastClassNames = {
toast: cn(
// Layout
'tw-flex tw-items-center tw-gap-2 tw-px-3 tw-py-2.5 tw-pr-10 tw-w-[var(--width)]',
// Visual
'tw-bg-background-surface-layer-01',
'tw-border-solid tw-border tw-border-border-weak',
'tw-shadow-elevation-300',
'tw-rounded-md'
),
title: 'tw-font-title-large tw-text-text-default',
description: 'tw-font-body-default tw-text-text-default',
content: 'tw-flex tw-flex-col tw-gap-0.5',
icon: 'tw-flex tw-items-center tw-justify-center tw-shrink-0',
closeButton: cn(
'tw-absolute tw-right-2 tw-flex tw-items-center tw-justify-center tw-shrink-0 tw-ml-auto',
'tw-size-6 tw-rounded-md tw-border-0 tw-bg-transparent tw-cursor-pointer',
'tw-text-icon-default hover:tw-bg-interactive-hover hover:tw-text-icon-strong',
'tw-transition-colors'
),
actionButton: cn(
'tw-font-title-default tw-ml-auto tw-shrink-0',
'tw-rounded-md tw-px-2 tw-h-6 tw-border-0 tw-cursor-pointer',
'tw-bg-transparent tw-text-text-brand hover:tw-bg-interactive-hover',
'tw-transition-colors'
),
};
function Toaster({
position = 'top-center',
closeButton = true,
richColors = true,
duration = 4000,
className,
...props
}) {
return (
<SonnerToaster
position={position}
closeButton={closeButton}
richColors={richColors}
duration={duration}
icons={toastIcons}
className={cn('tw-toaster tw-group', className)}
toastOptions={{
unstyled: true,
classNames: toastClassNames,
}}
{...props}
/>
);
}
Toaster.displayName = 'Toaster';
Toaster.propTypes = {
position: PropTypes.string,
closeButton: PropTypes.bool,
richColors: PropTypes.bool,
duration: PropTypes.number,
className: PropTypes.string,
};
// Exports
export { Toaster, toast };

View file

@ -0,0 +1,84 @@
# Sonner — Rocket Design Spec
<!-- figma: https://www.figma.com/design/SZKm4ecuzgBwFq0v4sfrO5/Sanitation---Miscellaneous?node-id=2868-334374 -->
<!-- synced: 2026-04-02 -->
## Overview
Sonner is the toast notification system for Rocket. It wraps the `sonner` library with ToolJet tokens. Consists of two parts:
1. `<Toaster />` — provider component, placed once in the app root
2. `toast()` — imperative API to trigger toasts from anywhere
## Toaster Props
| Prop | Type | Values | Default |
|---|---|---|---|
| position | string | top-center \| top-right \| bottom-right \| etc. | top-center |
| closeButton | boolean | — | true |
| richColors | boolean | — | true |
| duration | number | ms | 4000 |
## Toast Types
| Type | Icon | Color intent |
|---|---|---|
| default | none | neutral (surface bg) |
| success | CircleCheckIcon | green/success |
| error | OctagonXIcon | red/danger |
| warning | TriangleAlertIcon | orange/warning |
| info | InfoIcon | blue/brand |
| loading | Loader2Icon (spinning) | neutral |
## Token Mapping (from Figma)
| Element | Figma token | ToolJet class |
|---|---|---|
| toast bg | Base/Light/white 00 | `tw-bg-background-surface-layer-01` |
| toast border | slate/light/05 (#E6E8EB) | `tw-border-solid tw-border tw-border-border-weak` |
| toast shadow | Shadow/06 (0 32 64 -12) | `tw-shadow-[0px_32px_64px_-12px_rgba(16,24,40,0.14)]` |
| toast radius | 6px | `tw-rounded-md` |
| toast padding | 16px | `tw-p-4` |
| toast text | 14px Medium / 20px (slate/light/12) | `tw-font-title-large tw-text-text-default` |
| icon gap | 8px | `tw-gap-2` |
| close icon | slate/light/09 (#889096) | `tw-text-icon-default` |
| close icon size | 16px | `tw-size-4` |
| type icon size | 20px | `tw-size-5` |
## Usage Pattern
```jsx
// In app root (once)
<Toaster />
// Anywhere in the app
import { toast } from 'sonner';
toast('Default notification');
toast.success('Item saved');
toast.error('Workspace already exists');
toast.warning('Proceed with caution');
toast.info('New version available');
toast.loading('Uploading...');
// With description
toast.success('Saved', { description: 'Your changes have been saved.' });
// With action
toast('Item deleted', {
action: { label: 'Undo', onClick: () => undoDelete() },
});
```
## CVA Shape
Shape D — no CVA. Sonner handles its own rendering. Rocket HOC overrides styles via `toastOptions.classNames` and inline style variables.
## Notes
- `sonner` library handles all toast rendering, stacking, swipe-to-dismiss, and auto-dismiss.
- No `next-themes` — ToolJet uses `.dark-theme` class. Theme not passed to sonner.
- Icons from `lucide-react` — type icons are 20px (`tw-size-5`), close icon is 16px (`tw-size-4`).
- `closeButton={true}` by default — all toasts get a close button.
- Position defaults to `top-center`.
- `richColors={true}` enables sonner's built-in semantic coloring for typed toasts.
- Shadow is heavier than standard elevation tokens — uses custom shadow from Figma.
- Typography uses `tw-font-title-large` (14px Medium / 20px) — not the default body font.

View file

@ -0,0 +1,136 @@
import React from 'react';
import { Toaster, toast } from './Sonner';
import { Button } from '@/components/ui/Rocket/Button/Button';
export default {
title: 'Rocket/Sonner',
tags: ['autodocs'],
parameters: { layout: 'centered' },
decorators: [
(Story) => (
<>
<Toaster />
<Story />
</>
),
],
};
// Default
export const Default = {
render: () => (
<Button variant="outline" onClick={() => toast('This is a default toast')}>
Default Toast
</Button>
),
};
// Success
export const Success = {
render: () => (
<Button
variant="outline"
onClick={() => toast.success('Item saved', { description: 'Your changes have been saved successfully.' })}
>
Success Toast
</Button>
),
};
// Error
export const Error = {
render: () => (
<Button
variant="outline"
onClick={() => toast.error('Something went wrong', { description: 'Please try again later.' })}
>
Error Toast
</Button>
),
};
// Warning
export const Warning = {
render: () => (
<Button variant="outline" onClick={() => toast.warning('Proceed with caution')}>
Warning Toast
</Button>
),
};
// Info
export const Info = {
render: () => (
<Button variant="outline" onClick={() => toast.info('New version available')}>
Info Toast
</Button>
),
};
// Loading
export const Loading = {
render: () => (
<Button
variant="outline"
onClick={() => {
const id = toast.loading('Uploading...');
setTimeout(() => toast.success('Upload complete', { id }), 2000);
}}
>
Loading Toast
</Button>
),
};
// With Action
export const WithAction = {
render: () => (
<Button
variant="outline"
onClick={() =>
toast('Item deleted', {
action: {
label: 'Undo',
onClick: () => toast.success('Restored'),
},
})
}
>
Toast with Action
</Button>
),
};
// All Types
export const AllTypes = {
render: () => (
<div className="tw-flex tw-flex-wrap tw-gap-2">
<Button variant="outline" onClick={() => toast('Default notification')}>
Default
</Button>
<Button variant="outline" onClick={() => toast.success('Success!')}>
Success
</Button>
<Button variant="outline" onClick={() => toast.error('Error!')}>
Error
</Button>
<Button variant="outline" onClick={() => toast.warning('Warning!')}>
Warning
</Button>
<Button variant="outline" onClick={() => toast.info('Info!')}>
Info
</Button>
<Button variant="outline" onClick={() => toast.loading('Loading...')}>
Loading
</Button>
</div>
),
};

View file

@ -0,0 +1,38 @@
import React, { forwardRef } from 'react';
import PropTypes from 'prop-types';
import { cva } from 'class-variance-authority';
import { cn } from '@/lib/utils';
import { Loader2 } from 'lucide-react';
const spinnerVariants = cva(['tw-animate-spin tw-text-icon-default tw-shrink-0'], {
variants: {
size: {
large: 'tw-size-6',
default: 'tw-size-4',
small: 'tw-size-3',
},
},
defaultVariants: { size: 'default' },
});
const Spinner = forwardRef(function Spinner({ className, size, label = 'Loading', ...props }, ref) {
return (
<Loader2
ref={ref}
role="status"
aria-label={label}
className={cn(spinnerVariants({ size }), className)}
{...props}
/>
);
});
Spinner.displayName = 'Spinner';
Spinner.propTypes = {
size: PropTypes.oneOf(['large', 'default', 'small']),
label: PropTypes.string,
className: PropTypes.string,
};
export { Spinner, spinnerVariants };

View file

@ -0,0 +1,89 @@
import React from 'react';
import { Spinner } from './Spinner';
import { Button } from '../Button/Button';
export default {
title: 'Rocket/Spinner',
component: Spinner,
tags: ['autodocs'],
parameters: { layout: 'centered' },
argTypes: {
size: {
control: 'select',
options: ['large', 'default', 'small'],
},
},
};
// Default
export const Default = {
args: {},
};
// Sizes
export const Sizes = {
render: () => (
<div className="tw-flex tw-items-center tw-gap-4 tw-p-4">
<div className="tw-flex tw-flex-col tw-items-center tw-gap-1">
<Spinner size="large" />
<span className="tw-text-xs tw-text-text-placeholder">Large</span>
</div>
<div className="tw-flex tw-flex-col tw-items-center tw-gap-1">
<Spinner size="default" />
<span className="tw-text-xs tw-text-text-placeholder">Default</span>
</div>
<div className="tw-flex tw-flex-col tw-items-center tw-gap-1">
<Spinner size="small" />
<span className="tw-text-xs tw-text-text-placeholder">Small</span>
</div>
</div>
),
parameters: { layout: 'padded' },
};
// Custom Color
export const CustomColor = {
render: () => (
<div className="tw-flex tw-items-center tw-gap-4 tw-p-4">
<Spinner className="tw-text-icon-default" />
<Spinner className="tw-text-text-brand" />
<Spinner className="tw-text-icon-danger" />
<Spinner className="tw-text-white" />
</div>
),
parameters: { layout: 'padded' },
};
// Inline with Text
export const InlineWithText = {
render: () => (
<div className="tw-flex tw-flex-col tw-gap-3 tw-p-4">
<div className="tw-flex tw-items-center tw-gap-2 tw-text-text-default tw-text-sm">
<Spinner size="small" />
<span>Loading data...</span>
</div>
<div className="tw-flex tw-items-center tw-gap-2 tw-text-text-placeholder tw-text-sm">
<Spinner size="small" className="tw-text-text-placeholder" />
<span>Saving changes...</span>
</div>
</div>
),
parameters: { layout: 'padded' },
};
// In Button
export const InButton = {
render: () => (
<div className="tw-flex tw-items-center tw-gap-3 tw-p-4">
<Button disabled>
<Spinner size="small" className="tw-text-current" />
Saving...
</Button>
<Button variant="outline" disabled>
<Spinner size="small" className="tw-text-current" />
Loading...
</Button>
</div>
),
parameters: { layout: 'padded' },
};

View file

@ -0,0 +1,170 @@
import React, { createContext, forwardRef, useContext } from 'react';
import PropTypes from 'prop-types';
import { cn } from '@/lib/utils';
import {
Table as ShadcnTable,
TableHeader as ShadcnTableHeader,
TableBody as ShadcnTableBody,
TableFooter as ShadcnTableFooter,
TableRow as ShadcnTableRow,
TableHead as ShadcnTableHead,
TableCell as ShadcnTableCell,
TableCaption as ShadcnTableCaption,
} from '@/components/ui/Rocket/shadcn/table';
// Density context
const TableDensityContext = createContext('default');
// Table (root)
const Table = forwardRef(function Table({ className, density = 'default', ...props }, ref) {
return (
<TableDensityContext.Provider value={density}>
<ShadcnTable
ref={ref}
data-density={density}
className={cn(
'tw-w-full tw-caption-bottom',
'tw-border-separate tw-border-spacing-0',
'tw-font-body-default tw-text-text-default',
className
)}
{...props}
/>
</TableDensityContext.Provider>
);
});
Table.displayName = 'Table';
Table.propTypes = {
density: PropTypes.oneOf(['default', 'compact']),
className: PropTypes.string,
};
// TableHeader
const TableHeader = forwardRef(function TableHeader({ className, ...props }, ref) {
return (
<ShadcnTableHeader
ref={ref}
className={cn(
'[&_th]:tw-border-solid [&_th]:tw-border-0 [&_th]:tw-border-b [&_th]:tw-border-border-weak',
className
)}
{...props}
/>
);
});
TableHeader.displayName = 'TableHeader';
// TableBody
const TableBody = forwardRef(function TableBody({ className, ...props }, ref) {
return <ShadcnTableBody ref={ref} className={cn(className)} {...props} />;
});
TableBody.displayName = 'TableBody';
// TableFooter
const TableFooter = forwardRef(function TableFooter({ className, ...props }, ref) {
return (
<ShadcnTableFooter
ref={ref}
className={cn(
'tw-border-solid tw-border-0 tw-border-t tw-border-border-weak',
'tw-bg-background-surface-layer-02 tw-font-title-default',
className
)}
{...props}
/>
);
});
TableFooter.displayName = 'TableFooter';
// TableRow
const TableRow = forwardRef(function TableRow({ className, ...props }, ref) {
return (
<ShadcnTableRow
ref={ref}
className={cn(
'tw-transition-colors',
'hover:tw-bg-interactive-hover',
'data-[state=selected]:tw-bg-interactive-selected',
className
)}
{...props}
/>
);
});
TableRow.displayName = 'TableRow';
// TableHead
const tableHeadDensityClasses = {
default: 'tw-h-10 tw-px-3.5 tw-py-0',
compact: 'tw-h-8 tw-px-3 tw-py-0',
};
const TableHead = forwardRef(function TableHead({ className, ...props }, ref) {
const density = useContext(TableDensityContext);
return (
<ShadcnTableHead
ref={ref}
className={cn(
tableHeadDensityClasses[density],
'tw-text-left tw-align-middle',
'tw-font-title-default tw-text-text-default',
className
)}
{...props}
/>
);
});
TableHead.displayName = 'TableHead';
// TableCell
const tableCellDensityClasses = {
default: 'tw-h-[52px] tw-p-3.5',
compact: 'tw-h-9 tw-px-3 tw-py-2',
};
const TableCell = forwardRef(function TableCell({ className, ...props }, ref) {
const density = useContext(TableDensityContext);
return (
<ShadcnTableCell
ref={ref}
className={cn(
tableCellDensityClasses[density],
'tw-align-middle tw-text-text-default',
// Rounded pill effect: first/last cell corners get rounded so the
// hover/selected row bg shows as a pill highlight.
'first:tw-rounded-l-[10px] last:tw-rounded-r-[10px]',
className
)}
{...props}
/>
);
});
TableCell.displayName = 'TableCell';
// TableCaption
const TableCaption = forwardRef(function TableCaption({ className, ...props }, ref) {
return (
<ShadcnTableCaption
ref={ref}
className={cn('tw-mt-4 tw-font-body-small tw-text-text-placeholder', className)}
{...props}
/>
);
});
TableCaption.displayName = 'TableCaption';
// Exports
export { Table, TableHeader, TableBody, TableFooter, TableRow, TableHead, TableCell, TableCaption };

View file

@ -0,0 +1,86 @@
# Table — Rocket Design Spec
<!-- synced: 2026-04-07 -->
## Overview
Table is the low-level Rocket primitive for tabular data. Wraps shadcn's `<table>` primitive with ToolJet token overrides. Used as the structural base for higher-level compositions like `DataTable` (TanStack-driven) and feature tables (apps list, datasources list, workflows list, etc.).
The HOC layer **only handles structure + visual tokens**. State (sorting, filtering, pagination, selection) is the responsibility of the consumer or the `DataTable` block.
Brought forward from PR #14498 with improved organization and ToolJet token usage.
## Props
### Table (root)
| Prop | Type | Values | Default |
|---|---|---|---|
| density | string | default \| compact | default |
| className | string | — | — |
### TableRow
| Prop | Type | Values | Default |
|---|---|---|---|
| data-state | string | "selected" \| undefined | — |
| className | string | — | — |
## Density (Sizes)
| Value | Header height | Header padding | Cell height | Cell padding | Font |
|---|---|---|---|---|---|
| default | 40px | 0 / 14px | 52px | 14px | `tw-text-base` (12px) |
| compact | 32px | 0 / 12px | 36px | 8px / 12px | `tw-text-base` (12px) |
Density is set via context on `Table` and read by `TableHead` / `TableCell`.
## Sub-components
| Component | Element | Token overrides |
|---|---|---|
| `Table` | `<table>` inside scrollable `<div>` | bg, w-full, font tokens |
| `TableHeader` | `<thead>` | border-bottom on rows |
| `TableBody` | `<tbody>` | last row no border |
| `TableFooter` | `<tfoot>` | border-top, surface bg, medium font |
| `TableRow` | `<tr>` | border-bottom, hover bg, selected bg |
| `TableHead` | `<th>` | header height (density), title font, text-default, left-align |
| `TableCell` | `<td>` | cell padding (density), body font, text-default |
| `TableCaption` | `<caption>` | mt-4, body small, text-placeholder |
## Token Mapping
| Element | State | ToolJet class |
|---|---|---|
| Table container | default | `tw-relative tw-w-full tw-overflow-auto` |
| Table | default | `tw-w-full tw-caption-bottom tw-border-separate tw-border-spacing-0 tw-font-body-default tw-text-text-default` |
| TableHeader | default | `tw-border-solid tw-border-0 tw-border-b tw-border-border-weak` |
| TableRow | default | `tw-transition-colors` |
| TableRow | hover | `hover:tw-bg-interactive-hover` |
| TableRow | selected | `data-[state=selected]:tw-bg-interactive-selected` |
| TableHead | default | `tw-h-10 tw-px-3.5 tw-py-0 tw-text-left tw-align-middle tw-font-title-default tw-text-text-default` |
| TableHead | compact | `tw-h-8 tw-px-3 tw-py-0` |
| TableCell | default | `tw-h-[52px] tw-p-3.5 tw-align-middle tw-text-text-default first:tw-rounded-l-[10px] last:tw-rounded-r-[10px]` |
| TableCell | compact | `tw-h-9 tw-px-3 tw-py-2 first:tw-rounded-l-[10px] last:tw-rounded-r-[10px]` |
| TableFooter | default | `tw-border-solid tw-border-0 tw-border-t tw-border-border-weak tw-bg-background-surface-layer-02 tw-font-title-default` |
| TableCaption | default | `tw-mt-4 tw-font-body-small tw-text-text-placeholder` |
## CVA Shape
Shape C — sizes only (`density`). Plus a `TableDensityContext` so child components (`TableHead`, `TableCell`) read density from the root `Table`.
## Slots
Standard HTML table slots — consumer composes via sub-components.
## Notes
- Wraps the shadcn `table` primitive (installed at `Rocket/shadcn/table.jsx`).
- Replaces all shadcn semantic classes (`tw-bg-muted`, `tw-text-foreground`) with ToolJet tokens via className override.
- `density` prop controls row/cell heights via context — font stays `12px Medium` (`tw-font-title-default`) on header and `12px Regular` (`tw-font-body-default`) on body across both densities.
- **Borderless rows** — rows do NOT have bottom borders. Visual separation comes from the hover/selected background which uses `rounded-[10px]` corners on the first/last cells, creating a "pill" highlight.
- The `<table>` itself uses `tw-border-separate tw-border-spacing-0` so the rounded cell corners render correctly (the default `border-collapse` collapses cell borders and breaks the rounded corners).
- Header has a single `border-bottom` (`tw-border-border-weak`) on `TableHeader` — separates header from body.
- Hover and selected states use `tw-bg-interactive-hover` and `tw-bg-interactive-selected`.
- `TableFooter` uses surface-layer-02 bg + medium font weight (slightly emphasized).
- Higher-level features (sorting indicators, sticky header, loading skeleton, empty state, pagination) live in the `DataTable` block (layer 2), not here.
- Typography uses `tw-font-*` plugin utilities — never manual font combos.

View file

@ -0,0 +1,157 @@
import React from 'react';
import { Table, TableHeader, TableBody, TableFooter, TableRow, TableHead, TableCell, TableCaption } from './Table';
export default {
title: 'Rocket/Table',
tags: ['autodocs'],
parameters: { layout: 'padded' },
};
const sampleRows = [
{ name: 'My first app', editedBy: 'Alice', editedAt: 'Edited 2m ago', type: 'App' },
{ name: 'Customer dashboard', editedBy: 'Bob', editedAt: 'Edited 1h ago', type: 'App' },
{ name: 'Sales pipeline', editedBy: 'Carol', editedAt: 'Edited yesterday', type: 'Workflow' },
{ name: 'HR onboarding', editedBy: 'Dan', editedAt: 'Edited 3 days ago', type: 'Workflow' },
];
// Default
export const Default = {
render: () => (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Type</TableHead>
<TableHead>Edited by</TableHead>
<TableHead>Edited at</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sampleRows.map((row) => (
<TableRow key={row.name}>
<TableCell>{row.name}</TableCell>
<TableCell>{row.type}</TableCell>
<TableCell>{row.editedBy}</TableCell>
<TableCell>{row.editedAt}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
),
};
// Compact density
export const Compact = {
render: () => (
<Table density="compact">
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Type</TableHead>
<TableHead>Edited by</TableHead>
<TableHead>Edited at</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sampleRows.map((row) => (
<TableRow key={row.name}>
<TableCell>{row.name}</TableCell>
<TableCell>{row.type}</TableCell>
<TableCell>{row.editedBy}</TableCell>
<TableCell>{row.editedAt}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
),
};
// Selected row
export const SelectedRow = {
render: () => (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Type</TableHead>
<TableHead>Edited by</TableHead>
<TableHead>Edited at</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sampleRows.map((row, i) => (
<TableRow key={row.name} data-state={i === 1 ? 'selected' : undefined}>
<TableCell>{row.name}</TableCell>
<TableCell>{row.type}</TableCell>
<TableCell>{row.editedBy}</TableCell>
<TableCell>{row.editedAt}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
),
};
// With Footer
export const WithFooter = {
render: () => (
<Table>
<TableHeader>
<TableRow>
<TableHead>Item</TableHead>
<TableHead>Quantity</TableHead>
<TableHead>Price</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell>Apples</TableCell>
<TableCell>3</TableCell>
<TableCell>$3.00</TableCell>
</TableRow>
<TableRow>
<TableCell>Oranges</TableCell>
<TableCell>2</TableCell>
<TableCell>$4.00</TableCell>
</TableRow>
</TableBody>
<TableFooter>
<TableRow>
<TableCell>Total</TableCell>
<TableCell>5</TableCell>
<TableCell>$7.00</TableCell>
</TableRow>
</TableFooter>
</Table>
),
};
// With Caption
export const WithCaption = {
render: () => (
<Table>
<TableCaption>A list of recent apps from your workspace.</TableCaption>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Edited by</TableHead>
<TableHead>Edited at</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sampleRows.slice(0, 3).map((row) => (
<TableRow key={row.name}>
<TableCell>{row.name}</TableCell>
<TableCell>{row.editedBy}</TableCell>
<TableCell>{row.editedAt}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
),
};

View file

@ -0,0 +1,47 @@
import React, { forwardRef } from 'react';
import PropTypes from 'prop-types';
import { cva } from 'class-variance-authority';
import { cn } from '@/lib/utils';
import { Textarea as ShadcnTextarea } from '@/components/ui/Rocket/shadcn/textarea';
const textareaVariants = cva(
[
// Resets not covered by shadcn base (preflight is off)
'tw-appearance-none tw-border-solid tw-outline-none',
// Base tokens
'tw-bg-background-surface-layer-01 tw-border-border-default tw-text-text-default tw-shadow-elevation-none',
'tw-text-base',
'placeholder:tw-text-text-placeholder',
// Override shadcn focus ring with ToolJet token
'focus-visible:tw-ring-2 focus-visible:tw-ring-interactive-focus-outline !focus-visible:tw-outline-none',
// Hover
'hover:tw-border-border-strong',
// Error (via aria-invalid)
'aria-[invalid=true]:tw-border-border-danger-strong aria-[invalid=true]:tw-bg-background-error-weak',
// Disabled
'disabled:tw-bg-background-surface-layer-02 disabled:tw-text-text-disabled disabled:tw-border-border-disabled',
],
{
variants: {
size: {
large: 'tw-px-3 tw-py-2.5 tw-text-lg',
default: 'tw-px-3 tw-py-2 tw-text-base',
small: 'tw-px-3 tw-py-1.5 tw-text-base',
},
},
defaultVariants: { size: 'default' },
}
);
const Textarea = forwardRef(function Textarea({ className, size, ...props }, ref) {
return <ShadcnTextarea ref={ref} className={cn(textareaVariants({ size }), className)} {...props} />;
});
Textarea.displayName = 'Textarea';
Textarea.propTypes = {
size: PropTypes.oneOf(['large', 'default', 'small']),
className: PropTypes.string,
};
export { Textarea, textareaVariants };

View file

@ -0,0 +1,79 @@
import React from 'react';
import { Textarea } from './Textarea';
import { Field, FieldLabel, FieldDescription, FieldError } from '../Field/Field';
export default {
title: 'Rocket/Textarea',
component: Textarea,
tags: ['autodocs'],
parameters: { layout: 'centered' },
argTypes: {
size: {
control: 'select',
options: ['large', 'default', 'small'],
},
disabled: { control: 'boolean' },
},
};
// Default
export const Default = {
args: { placeholder: 'Enter text...' },
};
// Sizes
export const Sizes = {
render: () => (
<div className="tw-flex tw-flex-col tw-gap-3 tw-w-72 tw-p-4">
<Textarea size="large" placeholder="Large" />
<Textarea size="default" placeholder="Default" />
<Textarea size="small" placeholder="Small" />
</div>
),
parameters: { layout: 'padded' },
};
// States
export const States = {
render: () => (
<div className="tw-flex tw-flex-col tw-gap-3 tw-w-72 tw-p-4">
<Textarea placeholder="Default" />
<Textarea defaultValue="With content - this textarea has some longer text to show how it wraps across multiple lines." />
<Textarea placeholder="With error" aria-invalid="true" />
<Textarea placeholder="Disabled" disabled />
</div>
),
parameters: { layout: 'padded' },
};
// With Field
export const WithField = {
render: () => (
<div className="tw-w-80 tw-flex tw-flex-col tw-gap-6 tw-p-4">
<Field>
<FieldLabel>Description</FieldLabel>
<Textarea placeholder="Enter a description..." />
<FieldDescription>Brief summary of the item.</FieldDescription>
</Field>
<Field data-invalid="true">
<FieldLabel>Notes</FieldLabel>
<Textarea placeholder="Add notes..." aria-invalid="true" />
<FieldError>This field is required.</FieldError>
</Field>
</div>
),
parameters: { layout: 'padded' },
};
// Resizable
export const Resizable = {
render: () => (
<div className="tw-flex tw-flex-col tw-gap-3 tw-w-72 tw-p-4">
<Textarea placeholder="Vertical resize (default)" />
<Textarea placeholder="No resize" className="tw-resize-none" />
<Textarea placeholder="Both directions" className="tw-resize" />
</div>
),
parameters: { layout: 'padded' },
};

View file

@ -148,3 +148,56 @@ export {
tabsContentClasses,
} from './Tabs/Tabs';
export { Switch, switchClasses } from './Switch/Switch';
export {
AlertDialog,
AlertDialogTrigger,
AlertDialogContent,
alertDialogContentVariants,
AlertDialogOverlay,
AlertDialogMedia,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogAction,
AlertDialogCancel,
} from './AlertDialog/AlertDialog';
export {
Collapsible,
collapsibleVariants,
CollapsibleTrigger,
collapsibleTriggerVariants,
CollapsibleIcon,
CollapsibleContent,
collapsibleContentVariants,
} from './Collapsible/Collapsible';
export { Toaster, toast } from './Sonner/Sonner';
export {
Sheet,
SheetTrigger,
SheetClose,
SheetPortal,
SheetOverlay,
SheetContent,
sheetContentVariants,
SheetHeader,
SheetBody,
SheetFooter,
SheetTitle,
SheetDescription,
} from './Sheet/Sheet';
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableRow,
TableHead,
TableCell,
TableCaption,
} from './Table/Table';
export { Skeleton, skeletonClasses } from './Skeleton/Skeleton';
export { Checkbox, checkboxVariants } from './Checkbox/Checkbox';
export { RadioGroup, RadioGroupItem, radioGroupItemVariants } from './RadioGroup/RadioGroup';
export { Textarea, textareaVariants } from './Textarea/Textarea';
export { Spinner, spinnerVariants } from './Spinner/Spinner';

View file

@ -0,0 +1,143 @@
import * as React from 'react';
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/Rocket/shadcn/button';
function AlertDialog({ ...props }) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
}
function AlertDialogTrigger({ ...props }) {
return <AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />;
}
function AlertDialogPortal({ ...props }) {
return <AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />;
}
function AlertDialogOverlay({ className, ...props }) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
'tw-fixed tw-inset-0 tw-z-50 tw-bg-black/10 tw-duration-100 supports-backdrop-filter:tw-backdrop-blur-xs data-[open]:tw-animate-in data-[open]:tw-fade-in-0 data-[closed]:tw-animate-out data-[closed]:tw-fade-out-0',
className
)}
{...props}
/>
);
}
function AlertDialogContent({ className, size = 'default', ...props }) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
data-size={size}
className={cn(
'tw-group/alert-dialog-content tw-fixed tw-top-1/2 tw-left-1/2 tw-z-50 tw-grid tw-w-full tw--translate-x-1/2 tw--translate-y-1/2 tw-gap-4 tw-rounded-xl tw-bg-popover tw-p-4 tw-text-popover-foreground tw-ring-1 tw-ring-foreground/10 tw-duration-100 tw-outline-none data-[size=default]:tw-max-w-xs data-[size=sm]:tw-max-w-xs data-[size=default]:sm:tw-max-w-sm data-[open]:tw-animate-in data-[open]:tw-fade-in-0 data-[open]:tw-zoom-in-95 data-[closed]:tw-animate-out data-[closed]:tw-fade-out-0 data-[closed]:tw-zoom-out-95',
className
)}
{...props}
/>
</AlertDialogPortal>
);
}
function AlertDialogHeader({ className, ...props }) {
return (
<div
data-slot="alert-dialog-header"
className={cn(
'tw-grid tw-grid-rows-[auto_1fr] tw-place-items-center tw-gap-1.5 tw-text-center has-[[data-slot=alert-dialog-media]]:tw-grid-rows-[auto_auto_1fr] has-[[data-slot=alert-dialog-media]]:tw-gap-x-4 sm:group-data-[size=default]/alert-dialog-content:tw-place-items-start sm:group-data-[size=default]/alert-dialog-content:tw-text-left sm:group-data-[size=default]/alert-dialog-content:has-[[data-slot=alert-dialog-media]]:tw-grid-rows-[auto_1fr]',
className
)}
{...props}
/>
);
}
function AlertDialogFooter({ className, ...props }) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
'tw--mx-4 tw--mb-4 tw-flex tw-flex-col-reverse tw-gap-2 tw-rounded-b-xl tw-border-t tw-bg-muted/50 tw-p-4 group-data-[size=sm]/alert-dialog-content:tw-grid group-data-[size=sm]/alert-dialog-content:tw-grid-cols-2 sm:tw-flex-row sm:tw-justify-end',
className
)}
{...props}
/>
);
}
function AlertDialogMedia({ className, ...props }) {
return (
<div
data-slot="alert-dialog-media"
className={cn(
'tw-mb-2 tw-inline-flex tw-size-10 tw-items-center tw-justify-center tw-rounded-md tw-bg-muted sm:group-data-[size=default]/alert-dialog-content:tw-row-span-2 *:[svg:not([class*=size-])]:tw-size-6',
className
)}
{...props}
/>
);
}
function AlertDialogTitle({ className, ...props }) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn(
'tw-text-base tw-font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-[[data-slot=alert-dialog-media]]/alert-dialog-content:tw-col-start-2',
className
)}
{...props}
/>
);
}
function AlertDialogDescription({ className, ...props }) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn(
'tw-text-sm tw-text-balance tw-text-muted-foreground md:tw-text-pretty *:[a]:tw-underline *:[a]:tw-underline-offset-3 *:[a]:hover:tw-text-foreground',
className
)}
{...props}
/>
);
}
function AlertDialogAction({ className, variant = 'default', size = 'default', ...props }) {
return (
<Button variant={variant} size={size} asChild>
<AlertDialogPrimitive.Action data-slot="alert-dialog-action" className={cn(className)} {...props} />
</Button>
);
}
function AlertDialogCancel({ className, variant = 'outline', size = 'default', ...props }) {
return (
<Button variant={variant} size={size} asChild>
<AlertDialogPrimitive.Cancel data-slot="alert-dialog-cancel" className={cn(className)} {...props} />
</Button>
);
}
export {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger,
};

View file

@ -0,0 +1,27 @@
import * as React from 'react';
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
import { cn } from '@/lib/utils';
import { CheckIcon } from 'lucide-react';
function Checkbox({ className, ...props }) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
'tw-peer tw-relative tw-flex tw-size-4 tw-shrink-0 tw-items-center tw-justify-center tw-rounded-[4px] tw-border tw-border-input tw-transition-colors tw-outline-none focus-visible:tw-border-ring focus-visible:tw-ring-2 focus-visible:tw-ring-ring/50 disabled:tw-cursor-not-allowed disabled:tw-opacity-50 aria-invalid:tw-border-destructive aria-invalid:tw-ring-2 aria-invalid:tw-ring-destructive/20 data-[state=checked]:tw-border-primary data-[state=checked]:tw-bg-primary data-[state=checked]:tw-text-primary-foreground data-[state=indeterminate]:tw-border-primary data-[state=indeterminate]:tw-bg-primary data-[state=indeterminate]:tw-text-primary-foreground',
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="tw-grid tw-place-content-center tw-text-current tw-transition-none [&>svg]:tw-size-3.5"
>
<CheckIcon />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);
}
export { Checkbox };

View file

@ -0,0 +1,16 @@
import * as React from 'react';
import * as CollapsiblePrimitive from '@radix-ui/react-collapsible';
function Collapsible({ ...props }) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
}
function CollapsibleTrigger({ ...props }) {
return <CollapsiblePrimitive.Trigger data-slot="collapsible-trigger" {...props} />;
}
function CollapsibleContent({ ...props }) {
return <CollapsiblePrimitive.Content data-slot="collapsible-content" {...props} />;
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent };

View file

@ -0,0 +1,36 @@
import * as React from 'react';
import * as RadioGroupPrimitive from '@radix-ui/react-radio-group';
import { cn } from '@/lib/utils';
function RadioGroup({ className, ...props }) {
return (
<RadioGroupPrimitive.Root
data-slot="radio-group"
className={cn('tw-grid tw-w-full tw-gap-2', className)}
{...props}
/>
);
}
function RadioGroupItem({ className, ...props }) {
return (
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
'tw-peer tw-relative tw-flex tw-aspect-square tw-size-4 tw-shrink-0 tw-rounded-full tw-border tw-border-input tw-outline-none focus-visible:tw-border-ring focus-visible:tw-ring-2 focus-visible:tw-ring-ring/50 disabled:tw-cursor-not-allowed disabled:tw-opacity-50 data-[state=checked]:tw-border-primary data-[state=checked]:tw-bg-primary data-[state=checked]:tw-text-primary-foreground',
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator
data-slot="radio-group-indicator"
className="tw-flex tw-size-4 tw-items-center tw-justify-center"
>
<span className="tw-absolute tw-top-1/2 tw-left-1/2 tw-size-2 tw--translate-x-1/2 tw--translate-y-1/2 tw-rounded-full tw-bg-primary-foreground" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
);
}
export { RadioGroup, RadioGroupItem };

View file

@ -0,0 +1,109 @@
import * as React from 'react';
import * as SheetPrimitive from '@radix-ui/react-dialog';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/Rocket/shadcn/button';
import { XIcon } from 'lucide-react';
function Sheet({ ...props }) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
}
function SheetTrigger({ ...props }) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
}
function SheetClose({ ...props }) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
}
function SheetPortal({ ...props }) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
}
function SheetOverlay({ className, ...props }) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
'tw-fixed tw-inset-0 tw-z-50 tw-bg-black/10 tw-duration-100 supports-backdrop-filter:tw-backdrop-blur-sm data-[state=open]:tw-animate-in data-[state=open]:tw-fade-in-0 data-[state=closed]:tw-animate-out data-[state=closed]:tw-fade-out-0',
className
)}
{...props}
/>
);
}
function SheetContent({ className, children, side = 'right', showCloseButton = true, ...props }) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
data-side={side}
className={cn(
'tw-fixed tw-z-50 tw-flex tw-flex-col tw-gap-4 tw-bg-popover tw-bg-clip-padding tw-text-sm tw-text-popover-foreground tw-shadow-lg tw-transition tw-duration-200 tw-ease-in-out data-[side=bottom]:tw-inset-x-0 data-[side=bottom]:tw-bottom-0 data-[side=bottom]:tw-h-auto data-[side=bottom]:tw-border-t data-[side=left]:tw-inset-y-0 data-[side=left]:tw-left-0 data-[side=left]:tw-h-full data-[side=left]:tw-w-3/4 data-[side=left]:tw-border-r data-[side=right]:tw-inset-y-0 data-[side=right]:tw-right-0 data-[side=right]:tw-h-full data-[side=right]:tw-w-3/4 data-[side=right]:tw-border-l data-[side=top]:tw-inset-x-0 data-[side=top]:tw-top-0 data-[side=top]:tw-h-auto data-[side=top]:tw-border-b data-[side=left]:sm:tw-max-w-sm data-[side=right]:sm:tw-max-w-sm data-[state=open]:tw-animate-in data-[state=open]:tw-fade-in-0 data-[side=bottom]:data-[state=open]:tw-slide-in-from-bottom-10 data-[side=left]:data-[state=open]:tw-slide-in-from-left-10 data-[side=right]:data-[state=open]:tw-slide-in-from-right-10 data-[side=top]:data-[state=open]:tw-slide-in-from-top-10 data-[state=closed]:tw-animate-out data-[state=closed]:tw-fade-out-0 data-[side=bottom]:data-[state=closed]:tw-slide-out-to-bottom-10 data-[side=left]:data-[state=closed]:tw-slide-out-to-left-10 data-[side=right]:data-[state=closed]:tw-slide-out-to-right-10 data-[side=top]:data-[state=closed]:tw-slide-out-to-top-10',
className
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close data-slot="sheet-close" asChild>
<Button variant="ghost" className="tw-absolute tw-top-3 tw-right-3" size="icon-sm">
<XIcon />
<span className="tw-sr-only">Close</span>
</Button>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Content>
</SheetPortal>
);
}
function SheetHeader({ className, ...props }) {
return <div data-slot="sheet-header" className={cn('tw-flex tw-flex-col tw-gap-0.5 tw-p-4', className)} {...props} />;
}
function SheetFooter({ className, ...props }) {
return (
<div
data-slot="sheet-footer"
className={cn('tw-mt-auto tw-flex tw-flex-col tw-gap-2 tw-p-4', className)}
{...props}
/>
);
}
function SheetTitle({ className, ...props }) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn('tw-text-base tw-font-medium tw-text-foreground', className)}
{...props}
/>
);
}
function SheetDescription({ className, ...props }) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn('tw-text-sm tw-text-muted-foreground', className)}
{...props}
/>
);
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetPortal,
SheetOverlay,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
};

View file

@ -0,0 +1,10 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
function Skeleton({ className, ...props }) {
return (
<div data-slot="skeleton" className={cn('tw-animate-pulse tw-rounded-md tw-bg-muted', className)} {...props} />
);
}
export { Skeleton };

View file

@ -0,0 +1,32 @@
import * as React from 'react';
import { Toaster as Sonner } from 'sonner';
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from 'lucide-react';
const Toaster = ({ ...props }) => {
return (
<Sonner
className="tw-toaster tw-group"
icons={{
success: <CircleCheckIcon className="tw-size-4" />,
info: <InfoIcon className="tw-size-4" />,
warning: <TriangleAlertIcon className="tw-size-4" />,
error: <OctagonXIcon className="tw-size-4" />,
loading: <Loader2Icon className="tw-size-4 tw-animate-spin" />,
}}
style={{
'--normal-bg': 'var(--popover)',
'--normal-text': 'var(--popover-foreground)',
'--normal-border': 'var(--border)',
'--border-radius': 'var(--radius)',
}}
toastOptions={{
classNames: {
toast: 'cn-toast',
},
}}
{...props}
/>
);
};
export { Toaster };

View file

@ -0,0 +1,77 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
function Table({ className, ...props }) {
return (
<div data-slot="table-container" className="tw-relative tw-w-full tw-overflow-x-auto">
<table data-slot="table" className={cn('tw-w-full tw-caption-bottom tw-text-sm', className)} {...props} />
</div>
);
}
function TableHeader({ className, ...props }) {
return <thead data-slot="table-header" className={cn('[&_tr]:tw-border-b', className)} {...props} />;
}
function TableBody({ className, ...props }) {
return <tbody data-slot="table-body" className={cn('[&_tr:last-child]:tw-border-0', className)} {...props} />;
}
function TableFooter({ className, ...props }) {
return (
<tfoot
data-slot="table-footer"
className={cn('tw-border-t tw-bg-muted/50 tw-font-medium [&>tr]:last:tw-border-b-0', className)}
{...props}
/>
);
}
function TableRow({ className, ...props }) {
return (
<tr
data-slot="table-row"
className={cn(
'tw-border-b tw-transition-colors hover:tw-bg-muted/50 has-[[aria-expanded]]:tw-bg-muted/50 data-[state=selected]:tw-bg-muted',
className
)}
{...props}
/>
);
}
function TableHead({ className, ...props }) {
return (
<th
data-slot="table-head"
className={cn(
'tw-h-10 tw-px-2 tw-text-left tw-align-middle tw-font-medium tw-whitespace-nowrap tw-text-foreground [&:has([role=checkbox])]:tw-pr-0',
className
)}
{...props}
/>
);
}
function TableCell({ className, ...props }) {
return (
<td
data-slot="table-cell"
className={cn('tw-p-2 tw-align-middle tw-whitespace-nowrap [&:has([role=checkbox])]:tw-pr-0', className)}
{...props}
/>
);
}
function TableCaption({ className, ...props }) {
return (
<caption
data-slot="table-caption"
className={cn('tw-mt-4 tw-text-sm tw-text-muted-foreground', className)}
{...props}
/>
);
}
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption };

View file

@ -0,0 +1,78 @@
import * as React from 'react';
import PropTypes from 'prop-types';
import { flexRender } from '@tanstack/react-table';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/Rocket/Table/Table';
import { TableSkeleton } from '@/components/ui/blocks/TableSkeleton';
/**
* DataTable TanStack-driven table block.
*
* Pass a TanStack `table` instance (from `useReactTable`). This block renders
* the header, body (or skeleton during loading), and a "no results" empty state.
*
* Higher-level features (sorting indicators, pagination controls, toolbar) are
* the responsibility of the consuming feature, not this block.
*/
function DataTableInternal({ table, isLoading = false, skeleton, density = 'default', emptyMessage = 'No results.' }) {
const columnCount = table?.getAllColumns()?.length || 4;
return (
<div className="tw-w-full">
<Table density={density}>
<TableHeader className="tw-sticky tw-top-0 tw-z-10">
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead
key={header.id}
colSpan={header.colSpan}
style={header.getSize() ? { width: `${header.getSize()}px` } : undefined}
>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
{isLoading ? (
skeleton || <TableSkeleton rowCount={5} columnCount={columnCount} />
) : (
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id} data-state={row.getIsSelected() ? 'selected' : undefined}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columnCount}
className="tw-h-24 tw-text-center tw-text-text-placeholder tw-font-body-default"
>
{emptyMessage}
</TableCell>
</TableRow>
)}
</TableBody>
)}
</Table>
</div>
);
}
DataTableInternal.displayName = 'DataTable';
DataTableInternal.propTypes = {
// TanStack `table` instance required, but PropTypes can't validate object shape easily
// eslint-disable-next-line react/forbid-prop-types
table: PropTypes.object.isRequired,
isLoading: PropTypes.bool,
skeleton: PropTypes.node,
density: PropTypes.oneOf(['default', 'compact']),
emptyMessage: PropTypes.string,
};
export const DataTable = React.memo(DataTableInternal);

View file

@ -0,0 +1,69 @@
import React from 'react';
import { useReactTable, getCoreRowModel, createColumnHelper } from '@tanstack/react-table';
import { DataTable } from './DataTable';
export default {
title: 'Blocks/DataTable',
tags: ['autodocs'],
parameters: { layout: 'padded' },
};
const sampleData = [
{ id: 1, name: 'My first app', type: 'App', editedBy: 'Alice', editedAt: 'Edited 2m ago' },
{ id: 2, name: 'Customer dashboard', type: 'App', editedBy: 'Bob', editedAt: 'Edited 1h ago' },
{ id: 3, name: 'Sales pipeline', type: 'Workflow', editedBy: 'Carol', editedAt: 'Edited yesterday' },
{ id: 4, name: 'HR onboarding', type: 'Workflow', editedBy: 'Dan', editedAt: 'Edited 3 days ago' },
{ id: 5, name: 'Inventory tracker', type: 'App', editedBy: 'Eve', editedAt: 'Edited last week' },
];
const columnHelper = createColumnHelper();
const columns = [
columnHelper.accessor('name', { header: 'Name', cell: (info) => info.getValue() }),
columnHelper.accessor('type', { header: 'Type', cell: (info) => info.getValue() }),
columnHelper.accessor('editedBy', { header: 'Edited by', cell: (info) => info.getValue() }),
columnHelper.accessor('editedAt', { header: 'Edited at', cell: (info) => info.getValue() }),
];
function useTable(data) {
return useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
});
}
// Default
export const Default = {
render: () => {
const table = useTable(sampleData);
return <DataTable table={table} />;
},
};
// Compact
export const Compact = {
render: () => {
const table = useTable(sampleData);
return <DataTable table={table} density="compact" />;
},
};
// Loading
export const Loading = {
render: () => {
const table = useTable([]);
return <DataTable table={table} isLoading />;
},
};
// Empty
export const Empty = {
render: () => {
const table = useTable([]);
return <DataTable table={table} emptyMessage="No apps found. Create one to get started." />;
},
};

View file

@ -0,0 +1 @@
export { DataTable } from './DataTable';

View file

@ -0,0 +1,37 @@
import * as React from 'react';
import PropTypes from 'prop-types';
import { TableBody, TableCell, TableRow } from '@/components/ui/Rocket/Table/Table';
import { Skeleton } from '@/components/ui/Rocket/Skeleton/Skeleton';
/**
* Generic table skeleton renders pulsing placeholder rows.
* Designed to be used inside a `<Table>` while data is loading,
* replacing the real `<TableBody>`.
*/
function TableSkeleton({ rowCount = 5, columnCount = 4 }) {
const rows = React.useMemo(
() => Array.from({ length: rowCount }, (_, i) => ({ id: `table-skeleton-${i}` })),
[rowCount]
);
return (
<TableBody>
{rows.map((row) => (
<TableRow key={row.id}>
{Array.from({ length: columnCount }, (_, colIndex) => (
<TableCell key={colIndex}>
<Skeleton className="tw-h-4 tw-w-full" />
</TableCell>
))}
</TableRow>
))}
</TableBody>
);
}
TableSkeleton.displayName = 'TableSkeleton';
TableSkeleton.propTypes = {
rowCount: PropTypes.number,
columnCount: PropTypes.number,
};
export { TableSkeleton };

View file

@ -0,0 +1 @@
export { TableSkeleton } from './TableSkeleton';