@@ -6,6 +6,7 @@ import { z } from 'zod';
66import { FormError , TextField } from '@lambdacurry/forms' ;
77import { Button } from '@lambdacurry/forms/ui/button' ;
88import { FormMessage } from '@lambdacurry/forms/remix-hook-form/form' ;
9+ import { expect , userEvent , within } from '@storybook/test' ;
910import { withReactRouterStubDecorator } from '../lib/storybook/react-router-stub' ;
1011
1112// Form schema for testing
@@ -355,6 +356,7 @@ const handlePlacementTest = async (request: Request) => {
355356const meta : Meta < typeof FormError > = {
356357 title : 'RemixHookForm/FormError' ,
357358 component : FormError ,
359+ decorators : [ withReactRouterStubDecorator ] ,
358360 parameters : {
359361 layout : 'centered' ,
360362 docs : {
@@ -437,9 +439,75 @@ The FormError component automatically displays when \`errors._form\` exists in t
437439 } ,
438440 } ,
439441 play : async ( { canvasElement, step } ) => {
440- // This would be implemented with actual testing logic
441- await step ( 'Form renders with FormError component' , async ( ) => {
442- // Test implementation would go here
442+ const canvas = within ( canvasElement ) ;
443+
444+ await step ( 'Verify initial state' , async ( ) => {
445+ // Verify form elements are present
446+ const emailInput = canvas . getByLabelText ( / e m a i l a d d r e s s / i) ;
447+ const passwordInput = canvas . getByLabelText ( / p a s s w o r d / i) ;
448+ const submitButton = canvas . getByRole ( 'button' , { name : / s i g n i n / i } ) ;
449+
450+ expect ( emailInput ) . toBeInTheDocument ( ) ;
451+ expect ( passwordInput ) . toBeInTheDocument ( ) ;
452+ expect ( submitButton ) . toBeInTheDocument ( ) ;
453+
454+ // Verify no form-level error is shown initially
455+ expect ( canvas . queryByText ( / i n v a l i d e m a i l o r p a s s w o r d / i) ) . not . toBeInTheDocument ( ) ;
456+ expect ( canvas . queryByText ( / s e r v e r e r r o r / i) ) . not . toBeInTheDocument ( ) ;
457+ } ) ;
458+
459+ await step ( 'Test field-level validation errors' , async ( ) => {
460+ // Submit form without filling fields
461+ const submitButton = canvas . getByRole ( 'button' , { name : / s i g n i n / i } ) ;
462+ await userEvent . click ( submitButton ) ;
463+
464+ // Verify field-level validation errors appear
465+ await expect ( canvas . findByText ( / p l e a s e e n t e r a v a l i d e m a i l a d d r e s s / i) ) . resolves . toBeInTheDocument ( ) ;
466+ await expect ( canvas . findByText ( / p a s s w o r d m u s t b e a t l e a s t 6 c h a r a c t e r s / i) ) . resolves . toBeInTheDocument ( ) ;
467+
468+ // Verify no form-level error is shown for validation errors
469+ expect ( canvas . queryByText ( / i n v a l i d e m a i l o r p a s s w o r d / i) ) . not . toBeInTheDocument ( ) ;
470+ } ) ;
471+
472+ await step ( 'Test form-level error with invalid credentials' , async ( ) => {
473+ // Fill in invalid credentials
474+ const emailInput = canvas . getByLabelText ( / e m a i l a d d r e s s / i) ;
475+ const passwordInput = canvas . getByLabelText ( / p a s s w o r d / i) ;
476+
477+ await userEvent . clear ( emailInput ) ;
478+ await userEvent . clear ( passwordInput ) ;
479+ await userEvent . type ( emailInput , 'wrong@email.com' ) ;
480+ await userEvent . type ( passwordInput , 'wrongpass' ) ;
481+
482+ // Submit form
483+ const submitButton = canvas . getByRole ( 'button' , { name : / s i g n i n / i } ) ;
484+ await userEvent . click ( submitButton ) ;
485+
486+ // Verify form-level error appears
487+ await expect ( canvas . findByText ( / i n v a l i d e m a i l o r p a s s w o r d / i) ) . resolves . toBeInTheDocument ( ) ;
488+
489+ // Verify field-level errors are cleared
490+ expect ( canvas . queryByText ( / p l e a s e e n t e r a v a l i d e m a i l a d d r e s s / i) ) . not . toBeInTheDocument ( ) ;
491+ expect ( canvas . queryByText ( / p a s s w o r d m u s t b e a t l e a s t 6 c h a r a c t e r s / i) ) . not . toBeInTheDocument ( ) ;
492+ } ) ;
493+
494+ await step ( 'Test successful form submission' , async ( ) => {
495+ // Fill in correct credentials
496+ const emailInput = canvas . getByLabelText ( / e m a i l a d d r e s s / i) ;
497+ const passwordInput = canvas . getByLabelText ( / p a s s w o r d / i) ;
498+
499+ await userEvent . clear ( emailInput ) ;
500+ await userEvent . clear ( passwordInput ) ;
501+ await userEvent . type ( emailInput , 'user@example.com' ) ;
502+ await userEvent . type ( passwordInput , 'password123' ) ;
503+
504+ // Submit form
505+ const submitButton = canvas . getByRole ( 'button' , { name : / s i g n i n / i } ) ;
506+ await userEvent . click ( submitButton ) ;
507+
508+ // Verify success message appears and form error is cleared
509+ await expect ( canvas . findByText ( / l o g i n s u c c e s s f u l / i) ) . resolves . toBeInTheDocument ( ) ;
510+ expect ( canvas . queryByText ( / i n v a l i d e m a i l o r p a s s w o r d / i) ) . not . toBeInTheDocument ( ) ;
443511 } ) ;
444512 } ,
445513} ;
@@ -472,6 +540,93 @@ This pattern is useful when you want to show form-level context alongside specif
472540 } ,
473541 } ,
474542 } ,
543+ play : async ( { canvasElement, step } ) => {
544+ const canvas = within ( canvasElement ) ;
545+
546+ await step ( 'Verify initial state with multiple FormError placements' , async ( ) => {
547+ // Verify form elements are present
548+ const emailInput = canvas . getByLabelText ( / e m a i l a d d r e s s / i) ;
549+ const passwordInput = canvas . getByLabelText ( / p a s s w o r d / i) ;
550+ const submitButton = canvas . getByRole ( 'button' , { name : / c r e a t e a c c o u n t / i } ) ;
551+
552+ expect ( emailInput ) . toBeInTheDocument ( ) ;
553+ expect ( passwordInput ) . toBeInTheDocument ( ) ;
554+ expect ( submitButton ) . toBeInTheDocument ( ) ;
555+
556+ // Verify no form-level errors are shown initially (multiple FormError components)
557+ expect ( canvas . queryByText ( / r e g i s t r a t i o n f a i l e d / i) ) . not . toBeInTheDocument ( ) ;
558+ expect ( canvas . queryByText ( / s e r v e r e r r o r o c c u r r e d / i) ) . not . toBeInTheDocument ( ) ;
559+ } ) ;
560+
561+ await step ( 'Test mixed errors - field and form level together' , async ( ) => {
562+ // Fill in email that already exists
563+ const emailInput = canvas . getByLabelText ( / e m a i l a d d r e s s / i) ;
564+ const passwordInput = canvas . getByLabelText ( / p a s s w o r d / i) ;
565+
566+ await userEvent . type ( emailInput , 'taken@example.com' ) ;
567+ await userEvent . type ( passwordInput , 'validpass123' ) ;
568+
569+ // Submit form
570+ const submitButton = canvas . getByRole ( 'button' , { name : / c r e a t e a c c o u n t / i } ) ;
571+ await userEvent . click ( submitButton ) ;
572+
573+ // Verify both field-level and form-level errors appear
574+ await expect ( canvas . findByText ( / t h i s e m a i l a d d r e s s i s a l r e a d y r e g i s t e r e d / i) ) . resolves . toBeInTheDocument ( ) ;
575+ await expect ( canvas . findByText ( / r e g i s t r a t i o n f a i l e d / i) ) . resolves . toBeInTheDocument ( ) ;
576+
577+ // Verify form-level error appears in multiple locations (top and bottom)
578+ const formErrors = canvas . getAllByText ( / r e g i s t r a t i o n f a i l e d / i) ;
579+ expect ( formErrors ) . toHaveLength ( 2 ) ; // Should appear in both FormError components
580+ } ) ;
581+
582+ await step ( 'Test server error only (form-level error)' , async ( ) => {
583+ // Clear and fill with server error trigger
584+ const emailInput = canvas . getByLabelText ( / e m a i l a d d r e s s / i) ;
585+ const passwordInput = canvas . getByLabelText ( / p a s s w o r d / i) ;
586+
587+ await userEvent . clear ( emailInput ) ;
588+ await userEvent . clear ( passwordInput ) ;
589+ await userEvent . type ( emailInput , 'test@example.com' ) ;
590+ await userEvent . type ( passwordInput , 'servererror' ) ;
591+
592+ // Submit form
593+ const submitButton = canvas . getByRole ( 'button' , { name : / c r e a t e a c c o u n t / i } ) ;
594+ await userEvent . click ( submitButton ) ;
595+
596+ // Verify only form-level error appears
597+ await expect ( canvas . findByText ( / s e r v e r e r r o r o c c u r r e d / i) ) . resolves . toBeInTheDocument ( ) ;
598+
599+ // Verify field-level error is cleared
600+ expect ( canvas . queryByText ( / t h i s e m a i l a d d r e s s i s a l r e a d y r e g i s t e r e d / i) ) . not . toBeInTheDocument ( ) ;
601+
602+ // Verify form-level error appears in multiple locations
603+ const formErrors = canvas . getAllByText ( / s e r v e r e r r o r o c c u r r e d / i) ;
604+ expect ( formErrors ) . toHaveLength ( 2 ) ; // Should appear in both FormError components
605+ } ) ;
606+
607+ await step ( 'Test successful submission clears all errors' , async ( ) => {
608+ // Fill in valid data
609+ const emailInput = canvas . getByLabelText ( / e m a i l a d d r e s s / i) ;
610+ const passwordInput = canvas . getByLabelText ( / p a s s w o r d / i) ;
611+
612+ await userEvent . clear ( emailInput ) ;
613+ await userEvent . clear ( passwordInput ) ;
614+ await userEvent . type ( emailInput , 'valid@example.com' ) ;
615+ await userEvent . type ( passwordInput , 'validpass123' ) ;
616+
617+ // Submit form
618+ const submitButton = canvas . getByRole ( 'button' , { name : / c r e a t e a c c o u n t / i } ) ;
619+ await userEvent . click ( submitButton ) ;
620+
621+ // Verify success message appears
622+ await expect ( canvas . findByText ( / a c c o u n t c r e a t e d s u c c e s s f u l l y / i) ) . resolves . toBeInTheDocument ( ) ;
623+
624+ // Verify all errors are cleared
625+ expect ( canvas . queryByText ( / r e g i s t r a t i o n f a i l e d / i) ) . not . toBeInTheDocument ( ) ;
626+ expect ( canvas . queryByText ( / s e r v e r e r r o r o c c u r r e d / i) ) . not . toBeInTheDocument ( ) ;
627+ expect ( canvas . queryByText ( / t h i s e m a i l a d d r e s s i s a l r e a d y r e g i s t e r e d / i) ) . not . toBeInTheDocument ( ) ;
628+ } ) ;
629+ } ,
475630} ;
476631
477632export const CustomStyling : Story = {
@@ -507,6 +662,68 @@ This example shows an alert-style error message with an icon and custom styling.
507662 } ,
508663 } ,
509664 } ,
665+ play : async ( { canvasElement, step } ) => {
666+ const canvas = within ( canvasElement ) ;
667+
668+ await step ( 'Verify initial state with custom styling' , async ( ) => {
669+ // Verify form elements are present
670+ const emailInput = canvas . getByLabelText ( / e m a i l a d d r e s s / i) ;
671+ const passwordInput = canvas . getByLabelText ( / p a s s w o r d / i) ;
672+ const submitButton = canvas . getByRole ( 'button' , { name : / s i g n i n / i } ) ;
673+
674+ expect ( emailInput ) . toBeInTheDocument ( ) ;
675+ expect ( passwordInput ) . toBeInTheDocument ( ) ;
676+ expect ( submitButton ) . toBeInTheDocument ( ) ;
677+
678+ // Verify no custom styled error is shown initially
679+ expect ( canvas . queryByText ( / a u t h e n t i c a t i o n s e r v i c e i s t e m p o r a r i l y u n a v a i l a b l e / i) ) . not . toBeInTheDocument ( ) ;
680+ } ) ;
681+
682+ await step ( 'Test custom styled form error display' , async ( ) => {
683+ // Fill in any valid data (this story always shows form error for demo)
684+ const emailInput = canvas . getByLabelText ( / e m a i l a d d r e s s / i) ;
685+ const passwordInput = canvas . getByLabelText ( / p a s s w o r d / i) ;
686+
687+ await userEvent . type ( emailInput , 'test@example.com' ) ;
688+ await userEvent . type ( passwordInput , 'password123' ) ;
689+
690+ // Submit form
691+ const submitButton = canvas . getByRole ( 'button' , { name : / s i g n i n / i } ) ;
692+ await userEvent . click ( submitButton ) ;
693+
694+ // Verify custom styled error appears
695+ await expect ( canvas . findByText ( / a u t h e n t i c a t i o n s e r v i c e i s t e m p o r a r i l y u n a v a i l a b l e / i) ) . resolves . toBeInTheDocument ( ) ;
696+ } ) ;
697+
698+ await step ( 'Verify custom error styling and structure' , async ( ) => {
699+ // Find the error message
700+ const errorMessage = canvas . getByText ( / a u t h e n t i c a t i o n s e r v i c e i s t e m p o r a r i l y u n a v a i l a b l e / i) ;
701+ expect ( errorMessage ) . toBeInTheDocument ( ) ;
702+
703+ // Verify the custom styling structure
704+ const errorContainer = errorMessage . closest ( 'div' ) ;
705+ expect ( errorContainer ) . toHaveClass ( 'flex' , 'items-center' , 'p-4' , 'bg-red-50' , 'border-l-4' , 'border-red-400' , 'rounded-md' ) ;
706+
707+ // Verify the icon is present (SVG element)
708+ const icon = errorContainer ?. querySelector ( 'svg' ) ;
709+ expect ( icon ) . toBeInTheDocument ( ) ;
710+ expect ( icon ) . toHaveClass ( 'h-5' , 'w-5' , 'text-red-400' ) ;
711+
712+ // Verify the message has custom styling
713+ expect ( errorMessage ) . toHaveClass ( 'text-red-800' , 'font-medium' ) ;
714+ } ) ;
715+
716+ await step ( 'Test accessibility of custom styled error' , async ( ) => {
717+ // Verify the error message has proper accessibility attributes
718+ const errorMessage = canvas . getByText ( / a u t h e n t i c a t i o n s e r v i c e i s t e m p o r a r i l y u n a v a i l a b l e / i) ;
719+
720+ // Check that it has the form-message data attribute
721+ expect ( errorMessage ) . toHaveAttribute ( 'data-slot' , 'form-message' ) ;
722+
723+ // Verify it's properly structured for screen readers
724+ expect ( errorMessage . tagName . toLowerCase ( ) ) . toBe ( 'p' ) ;
725+ } ) ;
726+ } ,
510727} ;
511728
512729export const PlacementVariations : Story = {
@@ -538,6 +755,96 @@ Each FormError instance shows the same error but can be styled differently using
538755 } ,
539756 } ,
540757 } ,
758+ play : async ( { canvasElement, step } ) => {
759+ const canvas = within ( canvasElement ) ;
760+
761+ await step ( 'Verify initial state with multiple placement options' , async ( ) => {
762+ // Verify form elements are present
763+ const emailInput = canvas . getByLabelText ( / e m a i l a d d r e s s / i) ;
764+ const passwordInput = canvas . getByLabelText ( / p a s s w o r d / i) ;
765+ const submitButton = canvas . getByRole ( 'button' , { name : / s u b m i t / i } ) ;
766+
767+ expect ( emailInput ) . toBeInTheDocument ( ) ;
768+ expect ( passwordInput ) . toBeInTheDocument ( ) ;
769+ expect ( submitButton ) . toBeInTheDocument ( ) ;
770+
771+ // Verify no errors are shown initially
772+ expect ( canvas . queryByText ( / t h i s e r r o r a p p e a r s i n m u l t i p l e l o c a t i o n s / i) ) . not . toBeInTheDocument ( ) ;
773+ } ) ;
774+
775+ await step ( 'Test multiple FormError placements with different styling' , async ( ) => {
776+ // Fill in valid data (this story always shows form error for demo)
777+ const emailInput = canvas . getByLabelText ( / e m a i l a d d r e s s / i) ;
778+ const passwordInput = canvas . getByLabelText ( / p a s s w o r d / i) ;
779+
780+ await userEvent . type ( emailInput , 'test@example.com' ) ;
781+ await userEvent . type ( passwordInput , 'password123' ) ;
782+
783+ // Submit form
784+ const submitButton = canvas . getByRole ( 'button' , { name : / s u b m i t / i } ) ;
785+ await userEvent . click ( submitButton ) ;
786+
787+ // Verify error appears in multiple locations
788+ await expect ( canvas . findByText ( / t h i s e r r o r a p p e a r s i n m u l t i p l e l o c a t i o n s / i) ) . resolves . toBeInTheDocument ( ) ;
789+
790+ // Verify multiple instances of the same error message
791+ const errorMessages = canvas . getAllByText ( / t h i s e r r o r a p p e a r s i n m u l t i p l e l o c a t i o n s / i) ;
792+ expect ( errorMessages ) . toHaveLength ( 3 ) ; // Top, inline, and bottom placements
793+ } ) ;
794+
795+ await step ( 'Verify different styling for each placement' , async ( ) => {
796+ const errorMessages = canvas . getAllByText ( / t h i s e r r o r a p p e a r s i n m u l t i p l e l o c a t i o n s / i) ;
797+
798+ // Check that each error message has different styling based on placement
799+ const topError = errorMessages [ 0 ] ;
800+ const inlineError = errorMessages [ 1 ] ;
801+ const bottomError = errorMessages [ 2 ] ;
802+
803+ // Verify top placement styling (red background with border)
804+ const topContainer = topError . closest ( 'div' ) ;
805+ expect ( topContainer ) . toHaveClass ( 'p-3' , 'bg-red-50' , 'border' , 'border-red-200' , 'rounded-lg' ) ;
806+
807+ // Verify inline placement styling (yellow background, centered)
808+ const inlineContainer = inlineError . closest ( 'div' ) ;
809+ expect ( inlineContainer ) . toHaveClass ( 'text-center' , 'p-2' , 'bg-yellow-50' , 'border' , 'border-yellow-200' , 'rounded' , 'text-yellow-800' ) ;
810+
811+ // Verify bottom placement styling (red background with left border)
812+ const bottomContainer = bottomError . closest ( 'div' ) ;
813+ expect ( bottomContainer ) . toHaveClass ( 'mt-4' , 'p-3' , 'bg-red-100' , 'border-l-4' , 'border-red-500' , 'rounded-r' ) ;
814+ } ) ;
815+
816+ await step ( 'Test accessibility across all placements' , async ( ) => {
817+ const errorMessages = canvas . getAllByText ( / t h i s e r r o r a p p e a r s i n m u l t i p l e l o c a t i o n s / i) ;
818+
819+ // Verify each error message has proper accessibility attributes
820+ errorMessages . forEach ( ( errorMessage ) => {
821+ expect ( errorMessage ) . toHaveAttribute ( 'data-slot' , 'form-message' ) ;
822+ expect ( errorMessage . tagName . toLowerCase ( ) ) . toBe ( 'p' ) ;
823+ } ) ;
824+ } ) ;
825+
826+ await step ( 'Verify form structure and error placement order' , async ( ) => {
827+ // Get all form elements in order
828+ const formElements = canvas . getByRole ( 'form' ) || canvasElement ;
829+ const allElements = Array . from ( formElements . querySelectorAll ( '*' ) ) ;
830+
831+ // Find positions of error messages and form fields
832+ const errorElements = allElements . filter ( el =>
833+ el . textContent ?. includes ( 'This error appears in multiple locations' )
834+ ) ;
835+ const emailInput = canvas . getByLabelText ( / e m a i l a d d r e s s / i) ;
836+ const passwordInput = canvas . getByLabelText ( / p a s s w o r d / i) ;
837+ const submitButton = canvas . getByRole ( 'button' , { name : / s u b m i t / i } ) ;
838+
839+ // Verify error placement order: top error before email, inline error between fields, bottom error after submit
840+ expect ( errorElements ) . toHaveLength ( 3 ) ;
841+
842+ // This verifies the structural placement without relying on exact DOM order
843+ expect ( errorElements [ 0 ] ) . toBeInTheDocument ( ) ; // Top error
844+ expect ( errorElements [ 1 ] ) . toBeInTheDocument ( ) ; // Inline error
845+ expect ( errorElements [ 2 ] ) . toBeInTheDocument ( ) ; // Bottom error
846+ } ) ;
847+ } ,
541848} ;
542849
543850// Action handlers for Storybook (not exported to avoid being treated as a story)
0 commit comments