diff --git a/bulma-ui/src/form/DateInputBase.tsx b/bulma-ui/src/form/DateInputBase.tsx index a00cdfcc3..592627c6b 100644 --- a/bulma-ui/src/form/DateInputBase.tsx +++ b/bulma-ui/src/form/DateInputBase.tsx @@ -332,14 +332,18 @@ export const DateInputBase = forwardRef( const containerClass = usePrefixedClassNames('dateinput-container'); const triggerClass = usePrefixedClassNames('dateinput-trigger'); + // What the latest request asked for, so a second request in the same + // event sees it before React renders. The callbacks fire here rather than + // in a state updater, which StrictMode calls twice, and after the state + // is set, so a request a callback makes is the one that lands last. + const requestedOpenRef = useRef(false); const setOpen = useCallback( (next: boolean) => { - setOpenState(prev => { - if (prev === next) return prev; - if (next) onOpen?.(); - else onClose?.(); - return next; - }); + if (requestedOpenRef.current === next) return; + requestedOpenRef.current = next; + setOpenState(next); + if (next) onOpen?.(); + else onClose?.(); }, [onOpen, onClose] ); diff --git a/bulma-ui/src/form/DateTimeInputBase.tsx b/bulma-ui/src/form/DateTimeInputBase.tsx index 6c69104b5..444ddf697 100644 --- a/bulma-ui/src/form/DateTimeInputBase.tsx +++ b/bulma-ui/src/form/DateTimeInputBase.tsx @@ -363,18 +363,22 @@ export const DateTimeInputBase = forwardRef< 'datetimeinput-footer-time-pill' ); + // What the latest request asked for, so a second request in the same event + // sees it before React renders. The callbacks fire here rather than in a + // state updater, which StrictMode calls twice, and after the state is set, + // so a request a callback makes is the one that lands last. + const requestedOpenRef = useRef(false); const setOpen = useCallback( (next: boolean) => { - setOpenState(prev => { - if (prev === next) return prev; - if (next) { - valueAtOpenRef.current = value; - onOpen?.(); - } else { - onClose?.(); - } - return next; - }); + if (requestedOpenRef.current === next) return; + requestedOpenRef.current = next; + setOpenState(next); + if (next) { + valueAtOpenRef.current = value; + onOpen?.(); + } else { + onClose?.(); + } }, [onOpen, onClose, value] ); diff --git a/bulma-ui/src/form/TimeInputBase.tsx b/bulma-ui/src/form/TimeInputBase.tsx index 0d030a8ba..e49e7b3a2 100644 --- a/bulma-ui/src/form/TimeInputBase.tsx +++ b/bulma-ui/src/form/TimeInputBase.tsx @@ -304,18 +304,22 @@ export const TimeInputBase = forwardRef( 'timeinput-footer-done' ); + // What the latest request asked for, so a second request in the same + // event sees it before React renders. The callbacks fire here rather than + // in a state updater, which StrictMode calls twice, and after the state + // is set, so a request a callback makes is the one that lands last. + const requestedOpenRef = useRef(false); const setOpen = useCallback( (next: boolean) => { - setOpenState(prev => { - if (prev === next) return prev; - if (next) { - valueAtOpenRef.current = value; - onOpen?.(); - } else { - onClose?.(); - } - return next; - }); + if (requestedOpenRef.current === next) return; + requestedOpenRef.current = next; + setOpenState(next); + if (next) { + valueAtOpenRef.current = value; + onOpen?.(); + } else { + onClose?.(); + } }, [onOpen, onClose, value] ); diff --git a/bulma-ui/src/form/__tests__/DateInput.test.tsx b/bulma-ui/src/form/__tests__/DateInput.test.tsx index cdc159205..9a00dba06 100644 --- a/bulma-ui/src/form/__tests__/DateInput.test.tsx +++ b/bulma-ui/src/form/__tests__/DateInput.test.tsx @@ -1989,3 +1989,78 @@ describe('DateInput focus handed back on close', () => { }); }); }); + +describe('DateInput onOpen and onClose', () => { + const june15 = new Date(2024, 5, 15); + const pressEscape = (on: Element) => + act(() => { + fireEvent.keyDown(on, { key: 'Escape' }); + }); + + it('fire once per open and close under StrictMode', () => { + const onOpen = jest.fn(); + const onClose = jest.fn(); + const { getByRole, queryByRole } = render( + + + + ); + act(() => { + getByRole('combobox').focus(); + }); + expect(getByRole('dialog')).toBeInTheDocument(); + pressEscape(document.activeElement!); + expect(queryByRole('dialog')).toBeNull(); + expect(onOpen).toHaveBeenCalledTimes(1); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('still opens after an onClose that focuses the input', () => { + // Under openOnFocus that focus asks to open from inside onClose, before + // the close has rendered, and the later request wins. + const Picker = () => { + const inputRef = React.useRef(null); + const refocusedRef = React.useRef(false); + return ( + { + if (refocusedRef.current) return; + refocusedRef.current = true; + inputRef.current?.focus(); + }} + /> + ); + }; + const { getByRole, queryByRole } = render(); + const input = getByRole('combobox'); + act(() => { + input.focus(); + }); + pressEscape(document.activeElement!); + expect(getByRole('dialog')).toBeInTheDocument(); + pressEscape(document.activeElement!); + expect(queryByRole('dialog')).toBeNull(); + fireEvent.click(input); + expect(getByRole('dialog')).toBeInTheDocument(); + }); + + it('fires onClose once when the input and the popover both take Escape', () => { + const onClose = jest.fn(); + const { getByRole, queryByRole } = render( + + ); + const input = getByRole('combobox'); + act(() => { + fireEvent.click(getByRole('button', { name: 'Choose date' })); + }); + act(() => { + input.focus(); + }); + expect(getByRole('dialog')).toBeInTheDocument(); + pressEscape(input); + expect(queryByRole('dialog')).toBeNull(); + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/bulma-ui/src/form/__tests__/DateTimeInput.test.tsx b/bulma-ui/src/form/__tests__/DateTimeInput.test.tsx index a89a5e4bf..251c91e92 100644 --- a/bulma-ui/src/form/__tests__/DateTimeInput.test.tsx +++ b/bulma-ui/src/form/__tests__/DateTimeInput.test.tsx @@ -1397,3 +1397,78 @@ describe('DateTimeInput focus handed back on close', () => { }); }); }); + +describe('DateTimeInput onOpen and onClose', () => { + const v = new Date(2024, 5, 15, 9, 30); + const pressEscape = (on: Element) => + act(() => { + fireEvent.keyDown(on, { key: 'Escape' }); + }); + + it('fire once per open and close under StrictMode', () => { + const onOpen = jest.fn(); + const onClose = jest.fn(); + const { getByRole, queryByRole } = render( + + + + ); + act(() => { + getByRole('combobox').focus(); + }); + expect(getByRole('dialog')).toBeInTheDocument(); + pressEscape(document.activeElement!); + expect(queryByRole('dialog')).toBeNull(); + expect(onOpen).toHaveBeenCalledTimes(1); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('still opens after an onClose that focuses the input', () => { + // Under openOnFocus that focus asks to open from inside onClose, before + // the close has rendered, and the later request wins. + const Picker = () => { + const inputRef = React.useRef(null); + const refocusedRef = React.useRef(false); + return ( + { + if (refocusedRef.current) return; + refocusedRef.current = true; + inputRef.current?.focus(); + }} + /> + ); + }; + const { getByRole, queryByRole } = render(); + const input = getByRole('combobox'); + act(() => { + input.focus(); + }); + pressEscape(document.activeElement!); + expect(getByRole('dialog')).toBeInTheDocument(); + pressEscape(document.activeElement!); + expect(queryByRole('dialog')).toBeNull(); + fireEvent.click(input); + expect(getByRole('dialog')).toBeInTheDocument(); + }); + + it('fires onClose once when the input and the popover both take Escape', () => { + const onClose = jest.fn(); + const { getByRole, queryByRole } = render( + + ); + const input = getByRole('combobox'); + act(() => { + fireEvent.click(getByRole('button', { name: 'Choose date and time' })); + }); + act(() => { + input.focus(); + }); + expect(getByRole('dialog')).toBeInTheDocument(); + pressEscape(input); + expect(queryByRole('dialog')).toBeNull(); + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/bulma-ui/src/form/__tests__/TimeInput.test.tsx b/bulma-ui/src/form/__tests__/TimeInput.test.tsx index 673343cd2..98299de72 100644 --- a/bulma-ui/src/form/__tests__/TimeInput.test.tsx +++ b/bulma-ui/src/form/__tests__/TimeInput.test.tsx @@ -1715,3 +1715,81 @@ describe('TimeInput focus handed back on close', () => { }); }); }); + +describe('TimeInput onOpen and onClose', () => { + const pressEscape = (on: Element) => + act(() => { + fireEvent.keyDown(on, { key: 'Escape' }); + }); + + it('fire once per open and close under StrictMode', () => { + const onOpen = jest.fn(); + const onClose = jest.fn(); + const { getByRole, queryByRole } = render( + + + + ); + act(() => { + getByRole('combobox').focus(); + }); + expect(getByRole('dialog')).toBeInTheDocument(); + pressEscape(document.activeElement!); + expect(queryByRole('dialog')).toBeNull(); + expect(onOpen).toHaveBeenCalledTimes(1); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('still opens after an onClose that focuses the input', () => { + // Under openOnFocus that focus asks to open from inside onClose, before + // the close has rendered, and the later request wins. + const Picker = () => { + const inputRef = React.useRef(null); + const refocusedRef = React.useRef(false); + return ( + { + if (refocusedRef.current) return; + refocusedRef.current = true; + inputRef.current?.focus(); + }} + /> + ); + }; + const { getByRole, queryByRole } = render(); + const input = getByRole('combobox'); + act(() => { + input.focus(); + }); + pressEscape(document.activeElement!); + expect(getByRole('dialog')).toBeInTheDocument(); + pressEscape(document.activeElement!); + expect(queryByRole('dialog')).toBeNull(); + fireEvent.click(input); + expect(getByRole('dialog')).toBeInTheDocument(); + }); + + it('fires onClose once when the input and the popover both take Escape', () => { + const onClose = jest.fn(); + const { getByRole, queryByRole } = render( + + ); + const input = getByRole('combobox'); + act(() => { + fireEvent.click(getByRole('button', { name: 'Choose time' })); + }); + act(() => { + input.focus(); + }); + expect(getByRole('dialog')).toBeInTheDocument(); + pressEscape(input); + expect(queryByRole('dialog')).toBeNull(); + expect(onClose).toHaveBeenCalledTimes(1); + }); +});