Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/align-select-popover-end.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@lambdacurry/forms': patch
---

Allow Select popover content alignment overrides through `contentProps` and document right-aligned usage.
33 changes: 32 additions & 1 deletion .cursor/rules/storybook-testing.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -985,4 +985,35 @@ When creating or modifying Storybook interaction tests, ensure:
- Fast feedback loop optimized for developer productivity
- Individual story decorators provide flexibility for different testing scenarios

Remember: Every story should test real user workflows and serve as living documentation. Focus on behavior, not implementation details. The testing infrastructure should be reliable, fast, and easy to maintain for local development and Codegen workflows. **Always place decorators on individual stories for maximum flexibility and clarity.**
Remember: Every story should test real user workflows and serve as living documentation. Focus on behavior, not implementation details. The testing infrastructure should be reliable, fast, and easy to maintain for local development and Codegen workflows. **Always place decorators on individual stories for maximum flexibility and clarity.**

### Testing Portaled UI (Select, Popover, Combobox)

- Open the trigger first, then query the portaled content (many libs render to document.body).
- Query portal content from document.body using findByRole/waitFor; avoid raw setTimeout.
- Give the trigger a stable accessible name via aria-label; do not rely on placeholder text (it changes after selection).
- Prefer role-based queries:
- role="listbox" for the popup container
- role="option" for items
- It’s OK to assert component-specific data attributes for positioning checks (e.g., data-slot="popover-content", data-align="end").
- After selection or Escape, assert teardown with waitFor(() => expect(document.body.querySelector('[data-slot="popover-content"]').toBeNull())).
- In play functions, use within(document.body) to scope queries to the portal when needed.
- For controlled components, use the correct handler (e.g., onValueChange) so state updates reflect in assertions.

Example snippet:
```
await step('Open', async () => {
const trigger = await canvas.findByRole('combobox', { name: 'Favorite state' });
await userEvent.click(trigger);
const listbox = await within(document.body).findByRole('listbox');
expect(listbox).toBeInTheDocument();
});

await step('Select and close', async () => {
await userEvent.keyboard('{ArrowDown}{Enter}');
await waitFor(() => {
expect(document.body.querySelector('[data-slot="popover-content"]').toBeNull());
});
});
```

100 changes: 100 additions & 0 deletions apps/docs/src/ui/select-alignment.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, userEvent, waitFor, within } from '@storybook/test';
import { useState } from 'react';
import { Select } from '@lambdacurry/forms/ui/select';

const meta = {
title: 'UI/Select/Alignment',
component: Select,
parameters: {
layout: 'centered',
docs: {
description: {
story:
'Use `contentProps` to align the popover with right-aligned triggers, such as when a Select sits near the edge of a container.',
},
},
},
tags: ['autodocs'],
} satisfies Meta<typeof Select>;

export default meta;
type Story = StoryObj<typeof meta>;

const fruits = [
{ label: 'Apple', value: 'apple' },
{ label: 'Banana', value: 'banana' },
{ label: 'Cherry', value: 'cherry' },
];

const RightAlignedSelectExample = () => {
const [value, setValue] = useState('');

return (
<div className="w-full max-w-md space-y-3">
<div className="rounded-lg border border-border bg-card p-6">
<div className="flex justify-end">
<div className="w-44">
<Select
aria-label="Favorite fruit"
placeholder="Select a fruit"
options={fruits}
value={value}
onValueChange={setValue}
contentProps={{ align: 'end' }}
/>
</div>
</div>
</div>
<p className="text-sm text-muted-foreground">
Right-align the popover when the trigger is flush with the container edge to avoid clipping and keep the
dropdown visible.
</p>
</div>
);
};

export const RightAligned: Story = {
render: () => <RightAlignedSelectExample />,
play: async ({ canvasElement, step }) => {
const canvas = within(canvasElement);
const trigger = canvas.getByRole('combobox', { name: 'Favorite fruit' });

await step('Open the select', async () => {
await userEvent.click(trigger);
await waitFor(() => {
const popover = document.querySelector('[data-slot="popover-content"]');
expect(popover).not.toBeNull();
expect(popover).toHaveAttribute('data-align', 'end');
});
});

await step('Navigate and select via keyboard', async () => {
await waitFor(() => {
const commandRoot = document.querySelector('[cmdk-root]');
expect(commandRoot).not.toBeNull();
});
const listbox = document.querySelector('[role="listbox"]') as HTMLElement;
listbox.focus();
await waitFor(() => {
expect(document.activeElement).toBe(listbox);
});
await userEvent.keyboard('{ArrowDown}', { focusTrap: false });
await waitFor(() => {
const activeItem = document.querySelector('[cmdk-item][aria-selected="true"]');
expect(activeItem).not.toBeNull();
});
const activeItem = document.querySelector('[cmdk-item][aria-selected="true"]') as HTMLElement;
activeItem.dispatchEvent(
new CustomEvent('cmdk-item-select', {
detail: activeItem.getAttribute('data-value'),
bubbles: true,
}),
);
await waitFor(() => {
expect(document.querySelector('[data-slot="popover-content"]')).toBeNull();
expect(trigger).toHaveAttribute('aria-expanded', 'false');
});
});
},
};
10 changes: 8 additions & 2 deletions packages/components/src/ui/select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
useEffect,
useState,
type ButtonHTMLAttributes,
type ComponentProps,
type ComponentType,
type RefAttributes,
useId,
Expand All @@ -31,6 +32,8 @@ export interface SelectUIComponents {
ChevronIcon?: React.ComponentType<React.SVGProps<SVGSVGElement>>;
}

export type SelectContentProps = Pick<ComponentProps<typeof PopoverPrimitive.Content>, 'align' | 'side' | 'sideOffset'>;

export interface SelectProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'value' | 'onChange'> {
options: SelectOption[];
value?: string;
Expand All @@ -40,6 +43,7 @@ export interface SelectProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonE
className?: string;
contentClassName?: string;
itemClassName?: string;
contentProps?: SelectContentProps;
components?: Partial<SelectUIComponents>;
// Search behavior
searchable?: boolean;
Expand All @@ -65,6 +69,7 @@ export function Select({
className,
contentClassName,
itemClassName,
contentProps,
components,
searchable = true,
searchInputProps,
Expand Down Expand Up @@ -134,8 +139,9 @@ export function Select({
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={popoverRef}
align="start"
sideOffset={4}
align={contentProps?.align ?? 'start'}
side={contentProps?.side}
sideOffset={contentProps?.sideOffset ?? 4}
className={cn(
'z-50 rounded-md border bg-popover text-popover-foreground shadow-md outline-none',
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
Expand Down