Skip to content

Commit 580c3ea

Browse files
authored
Merge pull request #155 from lambda-curry/feat/select-content-props
2 parents a4a665c + c4c50f2 commit 580c3ea

File tree

4 files changed

+145
-3
lines changed

4 files changed

+145
-3
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@lambdacurry/forms': patch
3+
---
4+
5+
Allow Select popover content alignment overrides through `contentProps` and document right-aligned usage.

.cursor/rules/storybook-testing.mdc

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -985,4 +985,35 @@ When creating or modifying Storybook interaction tests, ensure:
985985
- Fast feedback loop optimized for developer productivity
986986
- Individual story decorators provide flexibility for different testing scenarios
987987

988-
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.**
988+
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.**
989+
990+
### Testing Portaled UI (Select, Popover, Combobox)
991+
992+
- Open the trigger first, then query the portaled content (many libs render to document.body).
993+
- Query portal content from document.body using findByRole/waitFor; avoid raw setTimeout.
994+
- Give the trigger a stable accessible name via aria-label; do not rely on placeholder text (it changes after selection).
995+
- Prefer role-based queries:
996+
- role="listbox" for the popup container
997+
- role="option" for items
998+
- It’s OK to assert component-specific data attributes for positioning checks (e.g., data-slot="popover-content", data-align="end").
999+
- After selection or Escape, assert teardown with waitFor(() => expect(document.body.querySelector('[data-slot="popover-content"]').toBeNull())).
1000+
- In play functions, use within(document.body) to scope queries to the portal when needed.
1001+
- For controlled components, use the correct handler (e.g., onValueChange) so state updates reflect in assertions.
1002+
1003+
Example snippet:
1004+
```
1005+
await step('Open', async () => {
1006+
const trigger = await canvas.findByRole('combobox', { name: 'Favorite state' });
1007+
await userEvent.click(trigger);
1008+
const listbox = await within(document.body).findByRole('listbox');
1009+
expect(listbox).toBeInTheDocument();
1010+
});
1011+
1012+
await step('Select and close', async () => {
1013+
await userEvent.keyboard('{ArrowDown}{Enter}');
1014+
await waitFor(() => {
1015+
expect(document.body.querySelector('[data-slot="popover-content"]').toBeNull());
1016+
});
1017+
});
1018+
```
1019+
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import type { Meta, StoryObj } from '@storybook/react-vite';
2+
import { expect, userEvent, waitFor, within } from '@storybook/test';
3+
import { useState } from 'react';
4+
import { Select } from '@lambdacurry/forms/ui/select';
5+
6+
const meta = {
7+
title: 'UI/Select/Alignment',
8+
component: Select,
9+
parameters: {
10+
layout: 'centered',
11+
docs: {
12+
description: {
13+
story:
14+
'Use `contentProps` to align the popover with right-aligned triggers, such as when a Select sits near the edge of a container.',
15+
},
16+
},
17+
},
18+
tags: ['autodocs'],
19+
} satisfies Meta<typeof Select>;
20+
21+
export default meta;
22+
type Story = StoryObj<typeof meta>;
23+
24+
const fruits = [
25+
{ label: 'Apple', value: 'apple' },
26+
{ label: 'Banana', value: 'banana' },
27+
{ label: 'Cherry', value: 'cherry' },
28+
];
29+
30+
const RightAlignedSelectExample = () => {
31+
const [value, setValue] = useState('');
32+
33+
return (
34+
<div className="w-full max-w-md space-y-3">
35+
<div className="rounded-lg border border-border bg-card p-6">
36+
<div className="flex justify-end">
37+
<div className="w-44">
38+
<Select
39+
aria-label="Favorite fruit"
40+
placeholder="Select a fruit"
41+
options={fruits}
42+
value={value}
43+
onValueChange={setValue}
44+
contentProps={{ align: 'end' }}
45+
/>
46+
</div>
47+
</div>
48+
</div>
49+
<p className="text-sm text-muted-foreground">
50+
Right-align the popover when the trigger is flush with the container edge to avoid clipping and keep the
51+
dropdown visible.
52+
</p>
53+
</div>
54+
);
55+
};
56+
57+
export const RightAligned: Story = {
58+
render: () => <RightAlignedSelectExample />,
59+
play: async ({ canvasElement, step }) => {
60+
const canvas = within(canvasElement);
61+
const trigger = canvas.getByRole('combobox', { name: 'Favorite fruit' });
62+
63+
await step('Open the select', async () => {
64+
await userEvent.click(trigger);
65+
await waitFor(() => {
66+
const popover = document.querySelector('[data-slot="popover-content"]');
67+
expect(popover).not.toBeNull();
68+
expect(popover).toHaveAttribute('data-align', 'end');
69+
});
70+
});
71+
72+
await step('Navigate and select via keyboard', async () => {
73+
await waitFor(() => {
74+
const commandRoot = document.querySelector('[cmdk-root]');
75+
expect(commandRoot).not.toBeNull();
76+
});
77+
const listbox = document.querySelector('[role="listbox"]') as HTMLElement;
78+
listbox.focus();
79+
await waitFor(() => {
80+
expect(document.activeElement).toBe(listbox);
81+
});
82+
await userEvent.keyboard('{ArrowDown}', { focusTrap: false });
83+
await waitFor(() => {
84+
const activeItem = document.querySelector('[cmdk-item][aria-selected="true"]');
85+
expect(activeItem).not.toBeNull();
86+
});
87+
const activeItem = document.querySelector('[cmdk-item][aria-selected="true"]') as HTMLElement;
88+
activeItem.dispatchEvent(
89+
new CustomEvent('cmdk-item-select', {
90+
detail: activeItem.getAttribute('data-value'),
91+
bubbles: true,
92+
}),
93+
);
94+
await waitFor(() => {
95+
expect(document.querySelector('[data-slot="popover-content"]')).toBeNull();
96+
expect(trigger).toHaveAttribute('aria-expanded', 'false');
97+
});
98+
});
99+
},
100+
};

packages/components/src/ui/select.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
useEffect,
1212
useState,
1313
type ButtonHTMLAttributes,
14+
type ComponentProps,
1415
type ComponentType,
1516
type RefAttributes,
1617
useId,
@@ -31,6 +32,8 @@ export interface SelectUIComponents {
3132
ChevronIcon?: React.ComponentType<React.SVGProps<SVGSVGElement>>;
3233
}
3334

35+
export type SelectContentProps = Pick<ComponentProps<typeof PopoverPrimitive.Content>, 'align' | 'side' | 'sideOffset'>;
36+
3437
export interface SelectProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'value' | 'onChange'> {
3538
options: SelectOption[];
3639
value?: string;
@@ -40,6 +43,7 @@ export interface SelectProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonE
4043
className?: string;
4144
contentClassName?: string;
4245
itemClassName?: string;
46+
contentProps?: SelectContentProps;
4347
components?: Partial<SelectUIComponents>;
4448
// Search behavior
4549
searchable?: boolean;
@@ -65,6 +69,7 @@ export function Select({
6569
className,
6670
contentClassName,
6771
itemClassName,
72+
contentProps,
6873
components,
6974
searchable = true,
7075
searchInputProps,
@@ -134,8 +139,9 @@ export function Select({
134139
<PopoverPrimitive.Portal>
135140
<PopoverPrimitive.Content
136141
ref={popoverRef}
137-
align="start"
138-
sideOffset={4}
142+
align={contentProps?.align ?? 'start'}
143+
side={contentProps?.side}
144+
sideOffset={contentProps?.sideOffset ?? 4}
139145
className={cn(
140146
'z-50 rounded-md border bg-popover text-popover-foreground shadow-md outline-none',
141147
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',

0 commit comments

Comments
 (0)