Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions .claude/skills/add-new-component/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,85 @@ export const Component = Object.assign(ComponentRoot, {
});
```

### Base UI Primitive Wrapping (when applicable)

When the component wraps a Base UI primitive from `@base-ui/react`, follow these additional conventions:

**Naming:** The Base UI `Popup` (or `Panel`) sub-component is always exported as `Content` in Apsara. The `Content` wrapper internally composes `Portal`, `Positioner`, and `Popup` (or `Panel`) so consumers only deal with a single sub-component.

**Content Props Interface:** Merge Positioner props with Popup/Panel props so positioning config (`side`, `align`, `sideOffset`, etc.) is passed directly on `<Component.Content>`. Separate them internally via rest spread:

```tsx
export interface ComponentContentProps
extends Omit<
ComponentPrimitive.Positioner.Props,
'render' | 'className' | 'style'
>,
ComponentPrimitive.Popup.Props {
/** @default false */
showArrow?: boolean;
}
```

**Content Component Template:**

```tsx
const ComponentContent = forwardRef<
ElementRef<typeof ComponentPrimitive.Popup>,
ComponentContentProps
>(
(
{
className,
children,
showArrow = false,
style,
render,
...positionerProps
},
ref
) => {
return (
<ComponentPrimitive.Portal>
<ComponentPrimitive.Positioner
sideOffset={showArrow ? 10 : 4}
collisionPadding={3}
className={styles.positioner}
{...positionerProps}
>
<ComponentPrimitive.Popup
ref={ref}
className={cx(styles.popup, className)}
style={style}
render={render}
>
{children}
{showArrow && (
<ComponentPrimitive.Arrow className={styles.arrow}>
{/* arrow SVG */}
</ComponentPrimitive.Arrow>
)}
</ComponentPrimitive.Popup>
</ComponentPrimitive.Positioner>
</ComponentPrimitive.Portal>
);
}
);
ComponentContent.displayName = 'Component.Content';
Comment on lines +118 to +176
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Locating referenced example components ==="
fd 'popover\.tsx|tooltip-content\.tsx|preview-card\.tsx' || echo "No exact matches found"

echo
echo "=== Broader search for popover/tooltip/preview patterns ==="
fd -e tsx | grep -i -E '(popover|tooltip|preview)' | head -20

echo
echo "=== Check if .claude/skills directory exists ==="
ls -la .claude/skills/add-new-component/ 2>/dev/null | head -20 || echo "Directory not found"

Repository: raystack/apsara

Length of output: 1524


🏁 Script executed:

#!/bin/bash

echo "=== popover.tsx ===" 
cat -n packages/raystack/components/popover/popover.tsx | head -100

echo
echo "=== tooltip-content.tsx ==="
cat -n packages/raystack/components/tooltip/tooltip-content.tsx | head -100

echo
echo "=== preview-card.tsx ==="
cat -n packages/raystack/components/preview-card/preview-card.tsx | head -100

Repository: raystack/apsara

Length of output: 7843


🏁 Script executed:

#!/bin/bash

echo "=== Check test files for usage patterns ==="
grep -n "Popup\|Popup.Props" packages/raystack/components/popover/__tests__/popover.test.tsx | head -30

echo
echo "=== Check tooltip tests ==="
grep -n "Popup\|TooltipContent" packages/raystack/components/tooltip/__tests__/tooltip.test.tsx | head -30

echo
echo "=== Search for any additional Popup-specific props being used ==="
rg "TooltipContent|PopoverContent|PreviewCardContent" -A 8 packages/ | grep -E "(initialFocus|finalFocus|onOpenChange|onEscapeKeyDown|aria-|role=)" | head -20

Repository: raystack/apsara

Length of output: 334


🏁 Script executed:

#!/bin/bash

echo "=== SKILL.md lines 118-176 ==="
sed -n '118,176p' .claude/skills/add-new-component/SKILL.md | cat -n

echo
echo "=== Compare with actual popover.tsx destructuring ==="
echo "popover.tsx destructures:"
sed -n '19,28p' packages/raystack/components/popover/popover.tsx

echo
echo "=== Compare with actual tooltip-content.tsx destructuring ==="
echo "tooltip-content.tsx destructures:"
sed -n '26,34p' packages/raystack/components/tooltip/tooltip-content.tsx

Repository: raystack/apsara

Length of output: 2513


Routing bug in Content component template: merged Popup props are incorrectly spread into Positioner.

Lines 118–176 merge Positioner and Popup props at the interface level but fail to route them correctly. The template destructures only className, style, render explicitly and spreads all remaining props via ...positionerProps into Positioner. This means any additional Popup-specific props (like initialFocus, finalFocus, or others) either get dropped or incorrectly routed to Positioner.

Real implementations (popover.tsx, tooltip-content.tsx, preview-card.tsx) follow this pattern. popover.tsx works around it by explicitly destructuring Popup-specific props, but tooltip-content.tsx and preview-card.tsx silently drop unhandled Popup props. The guidance should separate prop concerns:

-export interface ComponentContentProps
-  extends Omit<
-      ComponentPrimitive.Positioner.Props,
-      'render' | 'className' | 'style'
-    >,
-    ComponentPrimitive.Popup.Props {
+type PositionerProps = Omit<
+  ComponentPrimitive.Positioner.Props,
+  'render' | 'className' | 'style'
+>;
+
+export interface ComponentContentProps extends ComponentPrimitive.Popup.Props {
+  positioner?: PositionerProps;
   /** `@default` false */
   showArrow?: boolean;
 }
@@
-      ...positionerProps
+      positioner,
+      ...popupProps
@@
         <ComponentPrimitive.Positioner
           sideOffset={showArrow ? 10 : 4}
           collisionPadding={3}
           className={styles.positioner}
-          {...positionerProps}
+          {...positioner}
         >
           <ComponentPrimitive.Popup
             ref={ref}
             className={cx(styles.popup, className)}
             style={style}
             render={render}
+            {...popupProps}
           >
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.claude/skills/add-new-component/SKILL.md around lines 118 - 176, The
Component.Content component merges Positioner and Popup props via
ComponentContentProps but currently spreads all remaining props into
ComponentPrimitive.Positioner inside ComponentContent, causing Popup-only props
(e.g., initialFocus, finalFocus, onOpenAutoFocus) to be dropped or misrouted;
fix by explicitly destructuring Popup-specific props (at least the known Popup
props like render, initialFocus, finalFocus, onOpenAutoFocus, onCloseAutoFocus,
etc.) from the function args and pass those to ComponentPrimitive.Popup, while
spreading the remaining props into ComponentPrimitive.Positioner so Positioner
receives only its props and Popup receives its own; update ComponentContent
signature and the destructure in ComponentContent to reflect this split.

```

Key rules:
- `ref` forwards to the `Popup`/`Panel` element (the visible content container)
- `className` and `style` apply to `Popup`/`Panel`, NOT the Positioner
- Positioner gets its own CSS class from the module (e.g., `styles.positioner`)
- If the Base UI primitive uses `Panel` instead of `Popup`, substitute accordingly but still export as `Content`
- Arrow is optional, controlled by `showArrow` prop (default `false`). When `showArrow` is true, increase `sideOffset` to account for arrow size

**Existing examples:**
- `Popover.Content` wraps `Portal > Positioner > Popup` — see `components/popover/popover.tsx`
- `Tooltip.Content` wraps `Portal > Positioner > Popup` with arrow support — see `components/tooltip/tooltip-content.tsx`
- `PreviewCard.Content` wraps `Portal > Positioner > Popup` with arrow support — see `components/preview-card/preview-card.tsx`

## Step 2: Create the Index File

Simple re-export:
Expand Down
1 change: 1 addition & 0 deletions apps/www/src/components/playground/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export * from './link-examples';
export * from './list-examples';
export * from './menu-examples';
export * from './popover-examples';
export * from './preview-card-examples';
export * from './radio-examples';
export * from './search-examples';
export * from './select-examples';
Expand Down
51 changes: 51 additions & 0 deletions apps/www/src/components/playground/preview-card-examples.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
'use client';

import { Flex, PreviewCard, Text } from '@raystack/apsara';
import PlaygroundLayout from './playground-layout';

const placeholderStyle = {
width: 200,
height: 120,
borderRadius: 'var(--rs-radius-2)',
backgroundColor: 'var(--rs-color-background-base-secondary)'
};

export function PreviewCardExamples() {
return (
<PlaygroundLayout title='PreviewCard'>
<Flex gap='medium' wrap='wrap'>
<PreviewCard>
<PreviewCard.Trigger href='#'>
Hover to preview (bottom)
</PreviewCard.Trigger>
<PreviewCard.Content side='bottom'>
<Flex direction='column' gap='small'>
<div style={placeholderStyle} />
<Text size='2'>Content appears below the trigger</Text>
</Flex>
</PreviewCard.Content>
</PreviewCard>
<PreviewCard>
<PreviewCard.Trigger href='#'>
Hover to preview (top)
</PreviewCard.Trigger>
<PreviewCard.Content side='top'>
<Flex direction='column' gap='small'>
<div style={placeholderStyle} />
<Text size='2'>Content appears above the trigger</Text>
</Flex>
</PreviewCard.Content>
</PreviewCard>
<PreviewCard>
<PreviewCard.Trigger href='#'>Hover with arrow</PreviewCard.Trigger>
<PreviewCard.Content showArrow>
<Flex direction='column' gap='small'>
<div style={placeholderStyle} />
<Text size='2'>Preview content with an arrow indicator</Text>
</Flex>
</PreviewCard.Content>
</PreviewCard>
</Flex>
</PlaygroundLayout>
);
}
170 changes: 170 additions & 0 deletions apps/www/src/content/docs/components/preview-card/demo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
'use client';

import { getPropsString } from '@/lib/utils';

const PLACEHOLDER = `<div style={{ width: 200, height: 120, borderRadius: "var(--rs-radius-2)", backgroundColor: "var(--rs-color-background-accent-primary)" }} />`;

export const getCode = (props: any) => {
const { children, ...rest } = props;
return `
<PreviewCard>
<PreviewCard.Trigger href="#">Hover to preview</PreviewCard.Trigger>
<PreviewCard.Content${getPropsString(rest)}>
<Flex direction="column" gap="small">
${PLACEHOLDER}
<Text size="2">${children}</Text>
</Flex>
</PreviewCard.Content>
</PreviewCard>`;
};

export const playground = {
type: 'playground',
controls: {
align: {
type: 'select',
options: ['start', 'center', 'end'],
defaultValue: 'center'
},
side: {
type: 'select',
options: ['top', 'right', 'bottom', 'left'],
defaultValue: 'bottom'
},
sideOffset: { type: 'number', min: 0, defaultValue: 4 },
showArrow: { type: 'checkbox', defaultValue: false },
children: {
type: 'text',
initialValue: 'This is the preview card content.'
}
},
getCode
};

export const arrowDemo = {
type: 'code',
code: `
<PreviewCard>
<PreviewCard.Trigger href="#">Hover to preview</PreviewCard.Trigger>
<PreviewCard.Content showArrow>
<Flex direction="column" gap="small">
${PLACEHOLDER}
<Text size="2">Preview content with an arrow indicator</Text>
</Flex>
</PreviewCard.Content>
</PreviewCard>`
};

export const contentTransitionsDemo = {
type: 'code',
code: `function ContentTransitionsExample() {
const handle = PreviewCard.createHandle();
const pages = {
"page-a": {
color: "var(--rs-color-background-accent-primary)",
title: "Getting Started",
description: "Learn the basics of the design system.",
},
"page-b": {
color: "var(--rs-color-background-success-primary)",
title: "Components",
description: "Explore the full component library with interactive examples and detailed API documentation.",
},
"page-c": {
color: "var(--rs-color-background-attention-primary)",
title: "Theming",
description: "Customize colors, typography, and spacing to match your brand. Includes dark mode support and CSS variable overrides.",
},
};

return (
<Flex gap="medium">
<PreviewCard.Trigger handle={handle} payload="page-a" href="#">
Page A
</PreviewCard.Trigger>
<PreviewCard.Trigger handle={handle} payload="page-b" href="#">
Page B
</PreviewCard.Trigger>
<PreviewCard.Trigger handle={handle} payload="page-c" href="#">
Page C
</PreviewCard.Trigger>

<PreviewCard handle={handle}>
{({payload}) => {
const page = pages[payload];
if (!page) return null;
return (
<PreviewCard.Content style={{ maxWidth: 240 }}>
<PreviewCard.Viewport>
<Flex direction="column" gap="small">
<div style={{ width: "100%", height: 120, borderRadius: "var(--rs-radius-2)", backgroundColor: page.color, padding: "var(--rs-space-4)" }} />
<Text size="2" weight="medium">{page.title}</Text>
<Text size="1">{page.description}</Text>
</Flex>
</PreviewCard.Viewport>
</PreviewCard.Content>
);
}}
</PreviewCard>
</Flex>
);
}`
};

export const positionDemo = {
type: 'code',
tabs: [
{
name: 'Top',
code: `
<PreviewCard>
<PreviewCard.Trigger href="#">Hover me</PreviewCard.Trigger>
<PreviewCard.Content side="top">
<Flex direction="column" gap="small">
${PLACEHOLDER}
<Text size="2">Content appears above the trigger</Text>
</Flex>
</PreviewCard.Content>
</PreviewCard>`
},
{
name: 'Right',
code: `
<PreviewCard>
<PreviewCard.Trigger href="#">Hover me</PreviewCard.Trigger>
<PreviewCard.Content side="right">
<Flex direction="column" gap="small">
${PLACEHOLDER}
<Text size="2">Content appears to the right</Text>
</Flex>
</PreviewCard.Content>
</PreviewCard>`
},
{
name: 'Bottom',
code: `
<PreviewCard>
<PreviewCard.Trigger href="#">Hover me</PreviewCard.Trigger>
<PreviewCard.Content side="bottom">
<Flex direction="column" gap="small">
${PLACEHOLDER}
<Text size="2">Content appears below the trigger</Text>
</Flex>
</PreviewCard.Content>
</PreviewCard>`
},
{
name: 'Left',
code: `
<PreviewCard>
<PreviewCard.Trigger href="#">Hover me</PreviewCard.Trigger>
<PreviewCard.Content side="left">
<Flex direction="column" gap="small">
${PLACEHOLDER}
<Text size="2">Content appears to the left</Text>
</Flex>
</PreviewCard.Content>
</PreviewCard>`
}
]
};
78 changes: 78 additions & 0 deletions apps/www/src/content/docs/components/preview-card/index.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
---
title: PreviewCard
description: A popup that appears when a link is hovered, showing a preview for sighted users.
source: packages/raystack/components/preview-card
tag: new
---

import { playground, arrowDemo, positionDemo, contentTransitionsDemo } from "./demo.ts";

<Demo data={playground} />

## Anatomy

Import and assemble the component:

```tsx
import { PreviewCard } from "@raystack/apsara";

<PreviewCard>
<PreviewCard.Trigger href="/some-url">Hover me</PreviewCard.Trigger>
<PreviewCard.Content>
Preview content here
</PreviewCard.Content>
</PreviewCard>
```

## API Reference

### Root

Groups all parts of the preview card.

<auto-type-table path="./props.ts" name="PreviewCardRootProps" />

### Trigger

A link element that opens the preview card on hover. Renders an `<a>` tag.

<auto-type-table path="./props.ts" name="PreviewCardTriggerProps" />

### Content

Renders the preview card content panel, wrapping Portal, Positioner, and Popup internally.

<auto-type-table path="./props.ts" name="PreviewCardContentProps" />

### Viewport

Optional wrapper for animating content transitions when multiple triggers share one card via `createHandle`. Provides direction-aware slide animations.

<auto-type-table path="./props.ts" name="PreviewCardViewportProps" />

## Examples

### With Arrow

Display an arrow pointing to the trigger element.

<Demo data={arrowDemo} />

### Content Transitions

When multiple triggers share one card via `createHandle`, wrap content in `Viewport` to animate transitions between them. Content slides in from the direction of the newly hovered trigger.

<Demo data={contentTransitionsDemo} />

### Positioning

Control the position of the preview card relative to its trigger.

<Demo data={positionDemo} />

## Accessibility

- Opens on hover with configurable delay (default 600ms)
- Closes when the pointer leaves the trigger or content area
- Content is accessible to screen readers when open
- Trigger renders as a semantic `<a>` element
Loading