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

Add contentProps to UI Select to pass through PopoverPrimitive.Content positioning props (align/side/sideOffset).
Defaults preserved (align='start', sideOffset=4). New Storybook example and play test demonstrate overriding align to 'end' for right-aligned triggers.

14 changes: 7 additions & 7 deletions apps/docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@
"name": "@lambdacurry/forms-docs",
"version": "0.2.0",
"scripts": {
"dev": "storybook dev -p 6006",
"build": "storybook build",
"build-storybook": "storybook build",
"storybook": "storybook dev -p 6006",
"serve": "http-server ./storybook-static -p 6006 -s",
"test": "start-server-and-test serve http://127.0.0.1:6006 'test-storybook --url http://127.0.0.1:6006'",
"test:local": "test-storybook",
"dev": "../../node_modules/.bin/storybook dev -p 6006",
"build": "../../node_modules/.bin/storybook build",
"build-storybook": "../../node_modules/.bin/storybook build",
"storybook": "../../node_modules/.bin/storybook dev -p 6006",
"serve": "../../node_modules/.bin/http-server ./storybook-static -p 6006 -s",
"test": "../../node_modules/.bin/start-server-and-test serve http://127.0.0.1:6006 '../../node_modules/.bin/test-storybook --url http://127.0.0.1:6006'",
"test:local": "../../node_modules/.bin/test-storybook",
Comment on lines +5 to +11
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

🧩 Analysis chain

Verify Storybook 9 runner/test package compatibility

You’re on storybook@^9.0.6 but runner libs may need matching major ranges. Please confirm recommended versions.


🏁 Script executed:

#!/bin/bash
# Show local versions for alignment
jq -r '.dependencies.storybook, .devDependencies["@storybook/test-runner"], .devDependencies["@storybook/test"]' apps/docs/package.json

# (Optional) check other workspaces for pinned Storybook packages
fd package.json -a apps packages | xargs -I{} jq -r '"FILE:{}\n" + (try .dependencies.storybook // "none")'

Length of output: 22


🌐 Web query:

What versions of @storybook/test-runner and @storybook/test are recommended for Storybook 9.0.x?

💡 Result:

Short answer: don't use @storybook/test-runner or @storybook/test with Storybook 9.x. Storybook 9 consolidates @storybook/test into storybook/test and replaces the old test-runner with the new addon-vitest test experience — migrate to addon-vitest (storybook/test for APIs) per the Storybook 9 migration docs. [1][2]

Sources:

  • Storybook 9 migration guide (packages consolidated; test renamed to addon-vitest). [1]
  • Test runner docs / compatibility table (shows compatibility up to Storybook 8 and recommends migrating to addon-vitest). [2]

Migrate Storybook test packages for Storybook 9

apps/docs/package.json currently pins storybook ^9.0.6 but still lists @storybook/test @8.6.14 and @storybook/test-runner @0.22.1 — Storybook 9 consolidates/renames the test packages and replaces the old test-runner. Remove those devDependencies and migrate tests to the Storybook 9 testing setup (install addon-vitest and use the new storybook/test APIs per the Storybook 9 migration guide).

🤖 Prompt for AI Agents
In apps/docs/package.json around lines 5 to 11, the project still lists legacy
@storybook/test and @storybook/test-runner packages for Storybook 8 while
package.json pins Storybook ^9.0.6; remove the old devDependencies
(@storybook/test and @storybook/test-runner) and replace them with the Storybook
9 testing setup by installing @storybook/addon-vitest (or addon-vitest
equivalent from the migration guide) and updating test scripts to use the new
storybook/test APIs (adjust test: and test:local scripts to invoke the Storybook
9 test runner via addon-vitest or vitest as documented), then run npm/yarn
install and verify tests run under the new Storybook 9 testing API.

"type-check": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
Expand Down
86 changes: 86 additions & 0 deletions apps/docs/src/ui/select-alignment.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, userEvent, within } from '@storybook/test';
import { useState } from 'react';
import { Select, type SelectOption } from '@lambdacurry/forms/ui/select';

const meta: Meta<typeof Select> = {
title: 'UI/Select/Alignment',
component: Select,
parameters: { layout: 'centered' },
tags: ['autodocs'],
} satisfies Meta<typeof Select>;

export default meta;

type Story = StoryObj<typeof meta>;

const OPTIONS: SelectOption[] = [
{ label: 'Alabama', value: 'AL' },
{ label: 'Alaska', value: 'AK' },
{ label: 'Arizona', value: 'AZ' },
{ label: 'Arkansas', value: 'AR' },
{ label: 'California', value: 'CA' },
];

export const RightAlignedWithEndContent: Story = {
name: 'Right-aligned trigger with content align="end"',
args: {},
render: () => {
const [value, setValue] = useState<string>('');

return (
<div className="w-[480px]">
<div className="flex justify-end">
<div className="w-[280px]">
<Select
options={OPTIONS}
placeholder="Choose a state"
contentProps={{ align: 'end' }}
value={value}
onChange={setValue}
/>
</div>
</div>
</div>
);
},
play: async ({ canvasElement, step }) => {
const canvas = within(canvasElement);

await step('Open the select', async () => {
// Find the trigger by its role and accessible name (which should be the placeholder)
const trigger = await canvas.findByRole('combobox', { name: 'Choose a state' });
await userEvent.click(trigger);

// Wait for popover content to be rendered
await new Promise((r) => setTimeout(r, 100));
const contentEl = document.body.querySelector('[data-slot="popover-content"]') as HTMLElement | null;
expect(contentEl).toBeTruthy();

// Assert alignment override using data attribute we expose
expect(contentEl).toHaveAttribute('data-align', 'end');
});

await step('Keyboard navigate and select', async () => {
// Focus should be inside the popover; try arrow navigation then enter
await userEvent.keyboard('[ArrowDown]');
await userEvent.keyboard('[Enter]');

Comment on lines +64 to +68
Copy link

Choose a reason for hiding this comment

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

🧹 Nitpick

Use userEvent keyboard tokens with curly braces.

Square-bracket tokens are nonstandard and may be flaky.

-      await userEvent.keyboard('[ArrowDown]');
-      await userEvent.keyboard('[Enter]');
+      await userEvent.keyboard('{ArrowDown}');
+      await userEvent.keyboard('{Enter}');
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
await step('Keyboard navigate and select', async () => {
// Focus should be inside the popover; try arrow navigation then enter
await userEvent.keyboard('[ArrowDown]');
await userEvent.keyboard('[Enter]');
await step('Keyboard navigate and select', async () => {
// Focus should be inside the popover; try arrow navigation then enter
await userEvent.keyboard('{ArrowDown}');
await userEvent.keyboard('{Enter}');
🤖 Prompt for AI Agents
In apps/docs/src/ui/select-alignment.stories.tsx around lines 58 to 62, the test
uses nonstandard square-bracket keyboard tokens (e.g. '[ArrowDown]' and
'[Enter]') which can be flaky; replace them with userEvent.keyboard calls using
curly-brace tokens (e.g. '{ArrowDown}' and '{Enter}') so the keyboard navigation
uses the standard userEvent token syntax.

// Wait for the selection to be processed
await new Promise((r) => setTimeout(r, 100));

// The trigger should now show the selected option (first item: Alabama)
await expect(canvas.findByRole('combobox', { name: 'Alabama' })).resolves.toBeInTheDocument();

// Re-open and press Escape to close
const trigger = await canvas.findByRole('combobox', { name: 'Alabama' });
await userEvent.click(trigger);
await userEvent.keyboard('[Escape]');

// Ensure popover content is removed
await new Promise((r) => setTimeout(r, 200));
const stillOpen = document.body.querySelector('[data-slot="popover-content"]');
expect(stillOpen).toBeNull();
});
},
};
19 changes: 16 additions & 3 deletions packages/components/src/ui/select.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import * as PopoverPrimitive from '@radix-ui/react-popover';
import { Popover } from '@radix-ui/react-popover';
import { Check as DefaultCheckIcon, ChevronDown as DefaultChevronIcon } from 'lucide-react';
import type * as React from 'react';

import { useOverlayTriggerState } from 'react-stately';
import { PopoverTrigger } from './popover';
import { cn } from './utils';
Expand Down Expand Up @@ -31,6 +33,11 @@ export interface SelectUIComponents {
ChevronIcon?: React.ComponentType<React.SVGProps<SVGSVGElement>>;
}

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

export interface SelectProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'value' | 'onChange'> {
options: SelectOption[];
value?: string;
Expand All @@ -41,6 +48,8 @@ export interface SelectProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonE
contentClassName?: string;
itemClassName?: string;
components?: Partial<SelectUIComponents>;
// Content positioning props (forwarded to Radix PopoverPrimitive.Content)
contentProps?: SelectContentProps;
// Search behavior
searchable?: boolean;
searchInputProps?: React.ComponentPropsWithoutRef<typeof CommandInput>;
Expand All @@ -66,6 +75,7 @@ export function Select({
contentClassName,
itemClassName,
components,
contentProps,
searchable = true,
searchInputProps,
creatable = false,
Expand Down Expand Up @@ -126,16 +136,18 @@ export function Select({
aria-expanded={popoverState.isOpen}
aria-controls={listboxId}
{...buttonProps}
aria-label={value ? (selectedOption?.label ?? value) : placeholder}
>
{value !== '' ? (selectedOption?.label ?? value) : placeholder}
{value ? (selectedOption?.label ?? value) : placeholder}
<ChevronIcon className="w-4 h-4 opacity-50" />
</Trigger>
</PopoverTrigger>
<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 All @@ -147,6 +159,7 @@ export function Select({
)}
style={{ width: 'var(--radix-popover-trigger-width)' }}
data-slot="popover-content"
data-align={contentProps?.align ?? 'start'}
>
<Command className="bg-white rounded-md focus:outline-none sm:text-sm w-full">
{searchable && (
Expand Down
Loading
Loading