@@ -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+
88103const 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+ } ;
0 commit comments