Skip to content

Commit 8886fe6

Browse files
committed
Enhance: add contentProps to Select component for customizable Popover alignment and offset
Introduced a new prop, contentProps, to the Select component, allowing users to customize the alignment and side offset of the Popover. This change improves flexibility in the component's usage, enabling better control over its presentation in various contexts.
1 parent a4a665c commit 8886fe6

File tree

3 files changed

+113
-2
lines changed

3 files changed

+113
-2
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.
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)