mirror of
https://github.com/ToolJet/ToolJet
synced 2026-05-24 09:28:31 +00:00
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
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:
parent
a6f98b8e1b
commit
e932d704cb
5 changed files with 364 additions and 0 deletions
104
frontend/src/components/ui/Rocket/Popover/Popover.jsx
Normal file
104
frontend/src/components/ui/Rocket/Popover/Popover.jsx
Normal 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 };
|
||||
88
frontend/src/components/ui/Rocket/Popover/Popover.spec.md
Normal file
88
frontend/src/components/ui/Rocket/Popover/Popover.spec.md
Normal 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.
|
||||
115
frontend/src/components/ui/Rocket/Popover/Popover.stories.jsx
Normal file
115
frontend/src/components/ui/Rocket/Popover/Popover.stories.jsx
Normal 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="{align}"</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="{side}"</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent side={side}>
|
||||
<PopoverHeader>
|
||||
<PopoverTitle>Side: {side}</PopoverTitle>
|
||||
<PopoverDescription>Popover opens to the {side} of the trigger.</PopoverDescription>
|
||||
</PopoverHeader>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
49
frontend/src/components/ui/Rocket/shadcn/popover.jsx
Normal file
49
frontend/src/components/ui/Rocket/shadcn/popover.jsx
Normal 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 };
|
||||
Loading…
Reference in a new issue