Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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.
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