feat(rocket): add Popover component (#16009)
Some checks are pending
CI / build (push) Waiting to run
CI / lint-for-plugins (push) Blocked by required conditions
CI / lint-for-frontend (push) Blocked by required conditions
CI / lint-for-server (push) Blocked by required conditions
CI / unit-test (push) Blocked by required conditions
CI / e2e-test (push) Blocked by required conditions
Deploy Storybook to Netlify / deploy-storybook (push) Waiting to run

Adds shadcn Popover primitive and Rocket HOC wrapping it with
ToolJet design tokens, plus Storybook stories and spec.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nithin David Thomas 2026-04-27 19:52:31 +05:30 committed by GitHub
parent a6f98b8e1b
commit e932d704cb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 364 additions and 0 deletions

View file

@ -0,0 +1,104 @@
import React, { forwardRef } from 'react';
import PropTypes from 'prop-types';
import * as PopoverPrimitive from '@radix-ui/react-popover';
import { cn } from '@/lib/utils';
import { PopoverTrigger } from '@/components/ui/Rocket/shadcn/popover';
// PopoverContent
const PopoverContent = forwardRef(function PopoverContent(
{ className, align = 'center', side = 'bottom', sideOffset = 4, children, ...props },
ref
) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
data-slot="popover-content"
align={align}
side={side}
sideOffset={sideOffset}
className={cn(
'tw-z-50 tw-w-72 tw-flex tw-flex-col tw-gap-2.5',
'tw-bg-background-surface-layer-01',
'tw-shadow-elevation-400',
'tw-rounded-lg',
'tw-border-solid tw-border tw-border-border-weak',
'tw-p-3',
'tw-outline-none',
'tw-origin-[var(--radix-popover-content-transform-origin)]',
'data-[state=open]:tw-animate-in data-[state=closed]:tw-animate-out',
'data-[state=open]:tw-fade-in-0 data-[state=closed]:tw-fade-out-0',
'data-[state=open]:tw-zoom-in-95 data-[state=closed]:tw-zoom-out-95',
'data-[side=bottom]:tw-slide-in-from-top-2 data-[side=top]:tw-slide-in-from-bottom-2',
'data-[side=left]:tw-slide-in-from-right-2 data-[side=right]:tw-slide-in-from-left-2',
className
)}
{...props}
>
{children}
</PopoverPrimitive.Content>
</PopoverPrimitive.Portal>
);
});
PopoverContent.displayName = 'PopoverContent';
PopoverContent.propTypes = {
align: PropTypes.oneOf(['start', 'center', 'end']),
side: PropTypes.oneOf(['top', 'right', 'bottom', 'left']),
sideOffset: PropTypes.number,
className: PropTypes.string,
children: PropTypes.node,
};
// PopoverHeader
function PopoverHeader({ className, ...props }) {
return <div data-slot="popover-header" className={cn('tw-flex tw-flex-col tw-gap-0.5', className)} {...props} />;
}
PopoverHeader.displayName = 'PopoverHeader';
PopoverHeader.propTypes = {
className: PropTypes.string,
};
// PopoverTitle
const PopoverTitle = forwardRef(function PopoverTitle({ className, ...props }, ref) {
return (
<div
ref={ref}
data-slot="popover-title"
className={cn('tw-font-title-large tw-text-text-default', className)}
{...props}
/>
);
});
PopoverTitle.displayName = 'PopoverTitle';
PopoverTitle.propTypes = {
className: PropTypes.string,
};
// PopoverDescription
const PopoverDescription = forwardRef(function PopoverDescription({ className, ...props }, ref) {
return (
<p
ref={ref}
data-slot="popover-description"
className={cn('tw-font-body-small tw-text-text-placeholder tw-m-0', className)}
{...props}
/>
);
});
PopoverDescription.displayName = 'PopoverDescription';
PopoverDescription.propTypes = {
className: PropTypes.string,
};
// Popover (root pass-through)
const Popover = PopoverPrimitive.Root;
// Exports
export { Popover, PopoverContent, PopoverHeader, PopoverTitle, PopoverDescription, PopoverTrigger };

View file

@ -0,0 +1,88 @@
# Popover — Rocket Design Spec
<!-- figma: n/a (interactive spec — modeled after Dialog tokens) -->
<!-- synced: 2026-04-20 -->
## Overview
Popover is a lightweight, non-modal overlay anchored to a trigger element. Used to surface supplementary information, simple forms, or quick actions without blocking the rest of the UI. Unlike Dialog, there is no backdrop overlay, no focus trap escalation, and no close button — clicking outside dismisses.
Structurally identical to the shadcn primitive's sub-component set. Styling matches Dialog's card tokens (surface, elevation, radius) with tighter padding to suit its smaller footprint.
## Props (PopoverContent)
| Prop | Type | Values | Default |
|---|---|---|---|
| align | string | start \| center \| end | center |
| side | string | top \| right \| bottom \| left | bottom |
| sideOffset | number | — | 4 |
| className | string | — | — |
(All other props forward to Radix `Popover.Content``onOpenAutoFocus`, `onInteractOutside`, etc.)
## Sub-components
| Component | Wraps | Styling |
|---|---|---|
| `Popover` | Radix Popover.Root (pass-through) | none |
| `PopoverTrigger` | Re-export from shadcn | none |
| `PopoverContent` | Radix Content (direct, via Portal) | card tokens, padding, animations |
| `PopoverHeader` | plain div | flex column, tight gap |
| `PopoverTitle` | plain div | tw-font-title-large + color |
| `PopoverDescription` | plain div | tw-font-body-small + color |
## 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 border | default | border/weak | tw-border-solid tw-border tw-border-border-weak |
| content padding | default | 12px | tw-p-3 |
| content width | default | 288px (overridable via className) | tw-w-72 |
| content gap | default | 10px (between header and other content) | tw-gap-2.5 |
| header gap | default | 2px (between title and description) | tw-gap-0.5 |
| title text | default | text/default 14px Medium | tw-font-title-large tw-text-text-default |
| description text | default | text/placeholder 11px Regular | tw-font-body-small tw-text-text-placeholder |
## Slots
- trigger (required) — any element that opens the popover, via `PopoverTrigger` + `asChild`
- content (required) — arbitrary children inside `PopoverContent`
- header (optional) — `PopoverHeader` groups title + description
- title (optional) — `PopoverTitle`, typically inside header
- description (optional) — `PopoverDescription`, typically inside header
## Usage Pattern
```jsx
<Popover>
<PopoverTrigger asChild>
<Button variant="outline">Open</Button>
</PopoverTrigger>
<PopoverContent align="start">
<PopoverHeader>
<PopoverTitle>Dimensions</PopoverTitle>
<PopoverDescription>Set the width and height.</PopoverDescription>
</PopoverHeader>
{/* arbitrary body content */}
</PopoverContent>
</Popover>
```
## CVA Shape
Shape E — compound/multi-part, but **no CVA on any sub-component** (no variants, no sizes). All sub-components use static `cn()` calls. No `popoverVariants` export.
## Notes
- No overlay / backdrop — Radix Popover is non-modal; the page behind stays interactive.
- No close button — dismiss by clicking outside, pressing Escape, or re-clicking the trigger (native Radix behavior).
- No arrow indicator — not requested.
- No `PopoverAnchor` re-export — the trigger IS the anchor (Radix default). If a consumer needs a separate anchor later, add as a pass-through re-export.
- Typography uses plugin token utilities (`tw-font-title-large`, `tw-font-body-small`) — matches Dialog scale, not AlertDialog's larger scale.
- Padding is `tw-p-3` (12px), tighter than Dialog's 24px body padding — popovers are compact.
- Default width `tw-w-72` (288px) from shadcn — overridable via `className` on `PopoverContent`.
- Animations inherited from shadcn: `data-[state=open]:tw-animate-in`, `data-[state=closed]:tw-animate-out`, side-based slide-in.
- `forwardRef` is required on `PopoverContent` — Radix internally uses `React.cloneElement` for positioning refs; missing forwardRef silently breaks alignment.
- Header/Title/Description wrap plain `div`/`p` elements (not Radix primitives) — there is no `Popover.Title`/`Popover.Description` in Radix UI. shadcn's generated file also uses plain divs.

View file

@ -0,0 +1,115 @@
import React from 'react';
import { Popover, PopoverContent, PopoverHeader, PopoverTitle, PopoverDescription, PopoverTrigger } from './Popover';
import { Button } from '../Button/Button';
export default {
title: 'Rocket/Popover',
component: Popover,
tags: ['autodocs'],
parameters: { layout: 'centered' },
};
// Default
export const Default = {
render: () => (
<Popover>
<PopoverTrigger asChild>
<Button variant="outline">Open Popover</Button>
</PopoverTrigger>
<PopoverContent>
<PopoverHeader>
<PopoverTitle>Dimensions</PopoverTitle>
<PopoverDescription>Set the width and height for the layer.</PopoverDescription>
</PopoverHeader>
</PopoverContent>
</Popover>
),
};
// Title only
export const TitleOnly = {
render: () => (
<Popover>
<PopoverTrigger asChild>
<Button variant="outline">Title only</Button>
</PopoverTrigger>
<PopoverContent>
<PopoverHeader>
<PopoverTitle>Quick settings</PopoverTitle>
</PopoverHeader>
</PopoverContent>
</Popover>
),
};
// With body content
export const WithBodyContent = {
render: () => (
<Popover>
<PopoverTrigger asChild>
<Button variant="outline">Open</Button>
</PopoverTrigger>
<PopoverContent>
<PopoverHeader>
<PopoverTitle>Share this page</PopoverTitle>
<PopoverDescription>Anyone with the link can view.</PopoverDescription>
</PopoverHeader>
<div className="tw-flex tw-gap-2">
<Button variant="outline" size="small" className="tw-flex-1">
Copy link
</Button>
<Button variant="primary" size="small" className="tw-flex-1">
Share
</Button>
</div>
</PopoverContent>
</Popover>
),
};
// Alignment
export const Alignment = {
render: () => (
<div className="tw-flex tw-gap-3">
{['start', 'center', 'end'].map((align) => (
<Popover key={align}>
<PopoverTrigger asChild>
<Button variant="outline">align=&quot;{align}&quot;</Button>
</PopoverTrigger>
<PopoverContent align={align}>
<PopoverHeader>
<PopoverTitle>Aligned: {align}</PopoverTitle>
<PopoverDescription>Content is anchored to the {align} edge of the trigger.</PopoverDescription>
</PopoverHeader>
</PopoverContent>
</Popover>
))}
</div>
),
};
// Sides
export const Sides = {
render: () => (
<div className="tw-grid tw-grid-cols-2 tw-gap-6 tw-p-24">
{['top', 'right', 'bottom', 'left'].map((side) => (
<Popover key={side}>
<PopoverTrigger asChild>
<Button variant="outline">side=&quot;{side}&quot;</Button>
</PopoverTrigger>
<PopoverContent side={side}>
<PopoverHeader>
<PopoverTitle>Side: {side}</PopoverTitle>
<PopoverDescription>Popover opens to the {side} of the trigger.</PopoverDescription>
</PopoverHeader>
</PopoverContent>
</Popover>
))}
</div>
),
};

View file

@ -201,3 +201,11 @@ 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';
export {
Popover,
PopoverContent,
PopoverHeader,
PopoverTitle,
PopoverDescription,
PopoverTrigger,
} from './Popover/Popover';

View file

@ -0,0 +1,49 @@
import * as React from 'react';
import * as PopoverPrimitive from '@radix-ui/react-popover';
import { cn } from '@/lib/utils';
function Popover({ ...props }) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
}
function PopoverTrigger({ ...props }) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
}
function PopoverContent({ className, align = 'center', sideOffset = 4, ...props }) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
'tw-z-50 tw-flex tw-w-72 tw-origin-[var(--radix-popover-content-transform-origin)] tw-flex-col tw-gap-2.5 tw-rounded-lg tw-bg-popover tw-p-2.5 tw-text-sm tw-text-popover-foreground tw-shadow-md tw-ring-1 tw-ring-foreground/10 tw-outline-none tw-duration-100 data-[side=bottom]:tw-slide-in-from-top-2 data-[side=left]:tw-slide-in-from-right-2 data-[side=right]:tw-slide-in-from-left-2 data-[side=top]:tw-slide-in-from-bottom-2 data-[state=open]:tw-animate-in data-[state=open]:tw-fade-in-0 data-[state=open]:tw-zoom-in-95 data-[state=closed]:tw-animate-out data-[state=closed]:tw-fade-out-0 data-[state=closed]:tw-zoom-out-95',
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
);
}
function PopoverAnchor({ ...props }) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
}
function PopoverHeader({ className, ...props }) {
return (
<div data-slot="popover-header" className={cn('tw-flex tw-flex-col tw-gap-0.5 tw-text-sm', className)} {...props} />
);
}
function PopoverTitle({ className, ...props }) {
return <div data-slot="popover-title" className={cn('tw-font-medium', className)} {...props} />;
}
function PopoverDescription({ className, ...props }) {
return <p data-slot="popover-description" className={cn('tw-text-muted-foreground', className)} {...props} />;
}
export { Popover, PopoverAnchor, PopoverContent, PopoverDescription, PopoverHeader, PopoverTitle, PopoverTrigger };