22/// <reference types="jest" />
33/// <reference types="@testing-library/jest-dom" />
44
5- import { render , screen } from '@testing-library/react' ;
5+ import { fireEvent , render , screen } from '@testing-library/react' ;
66import userEvent from '@testing-library/user-event' ;
77import * as AnalysisStore from '../../components/AnalysisStore' ;
88import { MorphemeBreakdownPopover , MorphemeGlossInput } from '../../components/MorphemeEditor' ;
@@ -105,43 +105,29 @@ describe('MorphemeBreakdownPopover', () => {
105105 expect ( onClose ) . toHaveBeenCalledTimes ( 1 ) ;
106106 } ) ;
107107
108- it ( 'closes when the backdrop is clicked' , async ( ) => {
108+ it ( 'closes without saving when interacting outside with unchanged text' , async ( ) => {
109+ const onSave = jest . fn ( ) ;
109110 const onClose = jest . fn ( ) ;
110- render ( < MorphemeBreakdownPopover initialValue = "test" onSave = { jest . fn ( ) } onClose = { onClose } /> ) ;
111- // The backdrop is the fixed full-screen div; getByRole won't find it, so query the DOM.
112- const backdrop = document . querySelector ( '.tw\\:fixed.tw\\:inset-0' ) ;
113- if ( ! backdrop ) throw new Error ( 'Backdrop not found' ) ;
114- await userEvent . click ( backdrop ) ;
111+ render ( < MorphemeBreakdownPopover initialValue = "test" onSave = { onSave } onClose = { onClose } /> ) ;
112+ // The platform-bible-react mock exposes a sentinel button that fires onInteractOutside,
113+ // simulating a pointer interaction outside the popover.
114+ await userEvent . click ( screen . getByTestId ( 'popover-outside' ) ) ;
115+ expect ( onSave ) . not . toHaveBeenCalled ( ) ;
115116 expect ( onClose ) . toHaveBeenCalledTimes ( 1 ) ;
116117 } ) ;
117118
118- it ( 'saves on backdrop click when the text was edited' , async ( ) => {
119+ it ( 'saves on outside interaction when the text was edited' , async ( ) => {
119120 const onSave = jest . fn ( ) ;
120121 render ( < MorphemeBreakdownPopover initialValue = "test" onSave = { onSave } onClose = { jest . fn ( ) } /> ) ;
121122 await userEvent . type ( screen . getByRole ( 'textbox' ) , ' -er' ) ;
122- const backdrop = document . querySelector ( '.tw\\:fixed.tw\\:inset-0' ) ;
123- if ( ! backdrop ) throw new Error ( 'Backdrop not found' ) ;
124- await userEvent . click ( backdrop ) ;
123+ await userEvent . click ( screen . getByTestId ( 'popover-outside' ) ) ;
125124 expect ( onSave ) . toHaveBeenCalledWith ( 'test -er' ) ;
126125 } ) ;
127126
128- it ( 'does not save on backdrop click when the text is unchanged' , async ( ) => {
129- const onSave = jest . fn ( ) ;
130- const onClose = jest . fn ( ) ;
131- render ( < MorphemeBreakdownPopover initialValue = "test" onSave = { onSave } onClose = { onClose } /> ) ;
132- const backdrop = document . querySelector ( '.tw\\:fixed.tw\\:inset-0' ) ;
133- if ( ! backdrop ) throw new Error ( 'Backdrop not found' ) ;
134- await userEvent . click ( backdrop ) ;
135- expect ( onSave ) . not . toHaveBeenCalled ( ) ;
136- expect ( onClose ) . toHaveBeenCalledTimes ( 1 ) ;
137- } ) ;
138-
139- it ( 'does not save on backdrop click when the input is only whitespace' , async ( ) => {
127+ it ( 'does not save on outside interaction when the input is only whitespace' , async ( ) => {
140128 const onSave = jest . fn ( ) ;
141129 render ( < MorphemeBreakdownPopover initialValue = " " onSave = { onSave } onClose = { jest . fn ( ) } /> ) ;
142- const backdrop = document . querySelector ( '.tw\\:fixed.tw\\:inset-0' ) ;
143- if ( ! backdrop ) throw new Error ( 'Backdrop not found' ) ;
144- await userEvent . click ( backdrop ) ;
130+ await userEvent . click ( screen . getByTestId ( 'popover-outside' ) ) ;
145131 expect ( onSave ) . not . toHaveBeenCalled ( ) ;
146132 } ) ;
147133
@@ -153,6 +139,32 @@ describe('MorphemeBreakdownPopover', () => {
153139 expect ( onClose ) . not . toHaveBeenCalled ( ) ;
154140 } ) ;
155141
142+ it ( 'stops clicks inside the panel from reaching ancestor click handlers' , async ( ) => {
143+ // The panel is portaled to document.body, but React synthetic events bubble through the React
144+ // tree to the token chip and its phrase-selection click handlers; the panel must contain them.
145+ const ancestorClick = jest . fn ( ) ;
146+ render (
147+ < div role = "presentation" onClick = { ancestorClick } >
148+ < MorphemeBreakdownPopover initialValue = "test" onSave = { jest . fn ( ) } onClose = { jest . fn ( ) } />
149+ </ div > ,
150+ ) ;
151+ await userEvent . click ( screen . getByText ( 'Split into morphemes' ) ) ;
152+ expect ( ancestorClick ) . not . toHaveBeenCalled ( ) ;
153+ } ) ;
154+
155+ it ( 'stops mouse-downs inside the panel from reaching ancestor mouse-down handlers' , ( ) => {
156+ // A mouse-down that escaped the panel would reach the chip label's mouse-down handler, which
157+ // focuses the gloss input behind the popover and blurs the editor mid-edit.
158+ const ancestorMouseDown = jest . fn ( ) ;
159+ render (
160+ < div role = "presentation" onMouseDown = { ancestorMouseDown } >
161+ < MorphemeBreakdownPopover initialValue = "test" onSave = { jest . fn ( ) } onClose = { jest . fn ( ) } />
162+ </ div > ,
163+ ) ;
164+ fireEvent . mouseDown ( screen . getByText ( 'Split into morphemes' ) ) ;
165+ expect ( ancestorMouseDown ) . not . toHaveBeenCalled ( ) ;
166+ } ) ;
167+
156168 it ( 'does not call onSave when the input is empty' , async ( ) => {
157169 const onSave = jest . fn ( ) ;
158170 render ( < MorphemeBreakdownPopover initialValue = "" onSave = { onSave } onClose = { jest . fn ( ) } /> ) ;
@@ -190,35 +202,12 @@ describe('MorphemeBreakdownPopover', () => {
190202 expect ( onSave ) . not . toHaveBeenCalled ( ) ;
191203 } ) ;
192204
193- it ( 'portals the panel to document.body so segment rows cannot stack above it' , ( ) => {
194- render ( < MorphemeBreakdownPopover initialValue = "test" onSave = { jest . fn ( ) } onClose = { jest . fn ( ) } /> ) ;
195- const panel = screen . getByText ( 'Split into morphemes' ) . closest ( 'div' ) ;
196- expect ( panel ?. parentElement ) . toBe ( document . body ) ;
197- } ) ;
198-
199- it ( 'positions the panel below the anchor when there is room under the viewport bottom' , ( ) => {
200- // The layout effect measures the anchor (panel's DOM parent) first, then the panel itself.
201- jest
202- . spyOn ( Element . prototype , 'getBoundingClientRect' )
203- . mockReturnValueOnce ( new DOMRect ( 50 , 100 , 40 , 20 ) )
204- . mockReturnValueOnce ( new DOMRect ( 0 , 0 , 200 , 100 ) ) ;
205- render ( < MorphemeBreakdownPopover initialValue = "test" onSave = { jest . fn ( ) } onClose = { jest . fn ( ) } /> ) ;
206- const panel = screen . getByText ( 'Split into morphemes' ) . closest ( 'div' ) ;
207- // Anchor bottom (120) plus the 4px margin.
208- expect ( panel ) . toHaveStyle ( { top : '124px' , left : '50px' } ) ;
209- } ) ;
210-
211- it ( 'flips the panel above the anchor when the viewport bottom is too close' , ( ) => {
212- // Anchor bottom at 720 leaves only 48px below in jsdom's 768px-tall window — not enough for
213- // the 100px-tall panel, so it flips above the anchor.
214- jest
215- . spyOn ( Element . prototype , 'getBoundingClientRect' )
216- . mockReturnValueOnce ( new DOMRect ( 50 , 700 , 40 , 20 ) )
217- . mockReturnValueOnce ( new DOMRect ( 0 , 0 , 200 , 100 ) ) ;
205+ it ( 'renders inside the popover content panel' , ( ) => {
206+ // Positioning, portaling, and flipping are owned by the platform-bible-react Popover; this
207+ // only verifies the editor renders as the popover's content.
218208 render ( < MorphemeBreakdownPopover initialValue = "test" onSave = { jest . fn ( ) } onClose = { jest . fn ( ) } /> ) ;
219- const panel = screen . getByText ( 'Split into morphemes' ) . closest ( 'div' ) ;
220- // Anchor top (700) minus panel height (100) minus the 4px margin.
221- expect ( panel ) . toHaveStyle ( { top : '596px' , left : '50px' } ) ;
209+ const content = screen . getByTestId ( 'popover-content' ) ;
210+ expect ( content ) . toContainElement ( screen . getByText ( 'Split into morphemes' ) ) ;
222211 } ) ;
223212} ) ;
224213
0 commit comments