Skip to content

Commit 55716fd

Browse files
committed
feat(select): enhance Select component with search and creatable options
- Added searchable and creatable props to the Select component, allowing for dynamic option creation and search functionality. - Implemented a default search input using CommandInput, improving user experience. - Updated related stories in Storybook to demonstrate new search behavior and creatable options.
1 parent 4f25a08 commit 55716fd

File tree

4 files changed

+274
-18
lines changed

4 files changed

+274
-18
lines changed

apps/docs/src/remix-hook-form/select.stories.tsx

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,21 @@ const handleFormSubmission = async (request: Request) => {
8585
};
8686
};
8787

88+
// Region-only submission handler for stories that only submit the `region` field
89+
const handleRegionSubmission = async (request: Request) => {
90+
const regionSchema = z.object({ region: z.string().min(1) });
91+
const { data, errors } = await getValidatedFormData<{ region: string }>(request, zodResolver(regionSchema));
92+
93+
if (errors) {
94+
return { errors };
95+
}
96+
97+
return {
98+
message: 'Form submitted successfully',
99+
selectedRegion: data.region,
100+
};
101+
};
102+
88103
const meta: Meta<typeof Select> = {
89104
title: 'RemixHookForm/Select',
90105
component: Select,
@@ -348,3 +363,182 @@ export const FormSubmission: Story = {
348363
});
349364
},
350365
};
366+
367+
// Additional examples for search behavior and creatable options
368+
369+
const SearchDisabledExample = () => {
370+
const fetcher = useFetcher<{ message: string }>();
371+
const methods = useRemixForm<{ region: string }>({
372+
resolver: zodResolver(z.object({ region: z.string().min(1) })),
373+
defaultValues: { region: '' },
374+
fetcher,
375+
submitConfig: { action: '/', method: 'post' },
376+
});
377+
return (
378+
<RemixFormProvider {...methods}>
379+
<fetcher.Form onSubmit={methods.handleSubmit}>
380+
<Select
381+
name="region"
382+
label="Custom Region"
383+
description="Search disabled"
384+
options={[...US_STATES.slice(0, 5), ...CANADA_PROVINCES.slice(0, 5)]}
385+
placeholder="Select a custom region"
386+
searchable={false}
387+
/>
388+
</fetcher.Form>
389+
</RemixFormProvider>
390+
);
391+
};
392+
393+
export const SearchDisabled: Story = {
394+
decorators: [
395+
withReactRouterStubDecorator({
396+
routes: [
397+
{
398+
path: '/',
399+
Component: SearchDisabledExample,
400+
action: async ({ request }: ActionFunctionArgs) => handleFormSubmission(request),
401+
},
402+
],
403+
}),
404+
],
405+
play: async ({ canvasElement, step }) => {
406+
const canvas = within(canvasElement);
407+
await step('Open select and ensure no search input', async () => {
408+
const regionSelect = canvas.getByLabelText('Custom Region');
409+
await userEvent.click(regionSelect);
410+
const listbox = await within(document.body).findByRole('listbox');
411+
expect(within(listbox).queryByPlaceholderText('Search...')).not.toBeInTheDocument();
412+
});
413+
},
414+
};
415+
416+
const CustomSearchPlaceholderExample = () => {
417+
const fetcher = useFetcher<{ message: string }>();
418+
const methods = useRemixForm<{ region: string }>({
419+
resolver: zodResolver(z.object({ region: z.string().min(1) })),
420+
defaultValues: { region: '' },
421+
fetcher,
422+
submitConfig: { action: '/', method: 'post' },
423+
});
424+
return (
425+
<RemixFormProvider {...methods}>
426+
<fetcher.Form onSubmit={methods.handleSubmit}>
427+
<Select
428+
name="region"
429+
label="Custom Region"
430+
description="Custom search placeholder"
431+
options={[...US_STATES.slice(0, 5), ...CANADA_PROVINCES.slice(0, 5)]}
432+
placeholder="Select a custom region"
433+
searchInputProps={{ placeholder: 'Type to filter…' }}
434+
/>
435+
</fetcher.Form>
436+
</RemixFormProvider>
437+
);
438+
};
439+
440+
export const CustomSearchPlaceholder: Story = {
441+
decorators: [
442+
withReactRouterStubDecorator({
443+
routes: [
444+
{
445+
path: '/',
446+
Component: CustomSearchPlaceholderExample,
447+
action: async ({ request }: ActionFunctionArgs) => handleFormSubmission(request),
448+
},
449+
],
450+
}),
451+
],
452+
play: async ({ canvasElement, step }) => {
453+
const canvas = within(canvasElement);
454+
await step('Open select and see custom placeholder', async () => {
455+
const regionSelect = canvas.getByLabelText('Custom Region');
456+
await userEvent.click(regionSelect);
457+
// The search input is rendered alongside the listbox in the portal, not inside the listbox itself.
458+
const searchInput = await within(document.body).findByPlaceholderText('Type to filter…');
459+
expect(searchInput).toBeInTheDocument();
460+
});
461+
},
462+
};
463+
464+
const CreatableSelectExample = () => {
465+
const fetcher = useFetcher<{ message: string; selectedRegion?: string }>();
466+
const methods = useRemixForm<{ region: string }>({
467+
resolver: zodResolver(z.object({ region: z.string().min(1) })),
468+
defaultValues: { region: '' },
469+
fetcher,
470+
submitConfig: { action: '/', method: 'post' },
471+
});
472+
return (
473+
<RemixFormProvider {...methods}>
474+
<fetcher.Form onSubmit={methods.handleSubmit} className="space-y-4">
475+
<Select
476+
name="region"
477+
label="Custom Region"
478+
description="Creatable option enabled"
479+
options={[...US_STATES.slice(0, 5), ...CANADA_PROVINCES.slice(0, 5)]}
480+
placeholder="Select a custom region"
481+
creatable
482+
onCreateOption={async (input) => ({ label: input, value: input })}
483+
/>
484+
<Button type="submit">Submit</Button>
485+
{fetcher.data?.selectedRegion && (
486+
<div className="mt-4 p-4 bg-gray-100 rounded-md" data-testid="submitted-region">
487+
<p className="text-sm font-medium">Submitted region: {fetcher.data.selectedRegion}</p>
488+
</div>
489+
)}
490+
</fetcher.Form>
491+
</RemixFormProvider>
492+
);
493+
};
494+
495+
export const CreatableOption: Story = {
496+
decorators: [
497+
withReactRouterStubDecorator({
498+
routes: [
499+
{
500+
path: '/',
501+
Component: CreatableSelectExample,
502+
action: async ({ request }: ActionFunctionArgs) => handleRegionSubmission(request),
503+
},
504+
],
505+
}),
506+
],
507+
play: async ({ canvasElement, step }) => {
508+
const canvas = within(canvasElement);
509+
510+
await step('Create new option when no exact match', async () => {
511+
const regionSelect = canvas.getByLabelText('Custom Region');
512+
await userEvent.click(regionSelect);
513+
const listbox = await within(document.body).findByRole('listbox');
514+
// The search input is outside the listbox container; query from the portal root
515+
const input = within(document.body).getByPlaceholderText('Search...');
516+
await userEvent.click(input);
517+
await userEvent.clear(input);
518+
await userEvent.type(input, 'Atlantis');
519+
520+
const createItem = await within(listbox).findByRole('option', { name: 'Create \"Atlantis\"' });
521+
await userEvent.click(createItem);
522+
523+
await expect(canvas.findByRole('combobox', { name: 'Custom Region' })).resolves.toHaveTextContent('Atlantis');
524+
525+
// Submit and verify server received the created option value
526+
const submitButton = canvas.getByRole('button', { name: 'Submit' });
527+
await userEvent.click(submitButton);
528+
await expect(canvas.findByText('Submitted region: Atlantis')).resolves.toBeInTheDocument();
529+
});
530+
531+
await step('No creatable when exact match exists', async () => {
532+
const regionSelect = canvas.getByLabelText('Custom Region');
533+
await userEvent.click(regionSelect);
534+
const listbox = await within(document.body).findByRole('listbox');
535+
// The search input is outside the listbox container; query from the portal root
536+
const input = within(document.body).getByPlaceholderText('Search...');
537+
await userEvent.click(input);
538+
await userEvent.clear(input);
539+
await userEvent.type(input, 'California');
540+
541+
expect(within(listbox).queryByRole('option', { name: 'Create \"California\"' })).not.toBeInTheDocument();
542+
});
543+
},
544+
};

biome.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
"noUndeclaredDependencies": "off",
5454
"useImportExtensions": "off",
5555
"noUnusedVariables": "off",
56+
"noUnusedFunctionParameters": "off",
5657
"useUniqueElementIds": "off"
5758
}
5859
}

packages/components/src/ui/data-table-filter/components/filter-value.tsx

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ export function FilterValueDisplay<TData, TType extends ColumnDataType>({
136136
export function FilterValueOptionDisplay<TData>({
137137
filter,
138138
column,
139-
actions: _actions,
139+
actions,
140140
locale: _locale = 'en',
141141
}: FilterValueDisplayProps<TData, 'option'>) {
142142
const options = useMemo(() => column.getOptions(), [column]);
@@ -182,7 +182,7 @@ export function FilterValueOptionDisplay<TData>({
182182
export function FilterValueMultiOptionDisplay<TData>({
183183
filter,
184184
column,
185-
actions: _actions,
185+
actions,
186186
locale: _locale = 'en',
187187
}: FilterValueDisplayProps<TData, 'multiOption'>) {
188188
const options = useMemo(() => column.getOptions(), [column]);
@@ -246,8 +246,8 @@ function formatDateRange(start: Date | string | number, end: Date | string | num
246246

247247
export function FilterValueDateDisplay<TData>({
248248
filter,
249-
column: _column,
250-
actions: _actions,
249+
column,
250+
actions,
251251
locale: _locale = 'en',
252252
}: FilterValueDisplayProps<TData, 'date'>) {
253253
if (!filter) return null;
@@ -273,9 +273,9 @@ export function FilterValueDateDisplay<TData>({
273273

274274
export function FilterValueTextDisplay<TData>({
275275
filter,
276-
column: _column,
277-
actions: _actions,
278-
locale: _locale = 'en',
276+
column,
277+
actions,
278+
locale = 'en',
279279
}: FilterValueDisplayProps<TData, 'text'>) {
280280
if (!filter) return null;
281281
if (filter.values.length === 0 || filter.values[0].trim() === '') return <Ellipsis className="size-4" />;
@@ -287,9 +287,9 @@ export function FilterValueTextDisplay<TData>({
287287

288288
export function FilterValueNumberDisplay<TData>({
289289
filter,
290-
column: _column,
291-
actions: _actions,
292-
locale: _locale = 'en',
290+
column,
291+
actions,
292+
locale = 'en',
293293
}: FilterValueDisplayProps<TData, 'number'>) {
294294
if (!filter || !filter.values || filter.values.length === 0) return null;
295295

@@ -654,8 +654,10 @@ export function FilterValueNumberController<TData>({
654654
// filter && values.length === 2
655655
filter && numberFilterOperators[filter.operator].target === 'multiple';
656656

657-
const setFilterOperatorDebounced = useDebounceCallback(actions.setFilterOperator, 500);
658-
const setFilterValueDebounced = useDebounceCallback(actions.setFilterValue, 500);
657+
// biome-ignore lint/suspicious/noExplicitAny: any for flexibility
658+
const setFilterOperatorDebounced = useDebounceCallback(actions.setFilterOperator as any, 500);
659+
// biome-ignore lint/suspicious/noExplicitAny: any for flexibility
660+
const setFilterValueDebounced = useDebounceCallback(actions.setFilterValue as any, 500);
659661

660662
const changeNumber = (value: number[]) => {
661663
setValues(value);

0 commit comments

Comments
 (0)