Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 10 additions & 6 deletions bulma-ui/src/form/DateInputBase.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -332,14 +332,18 @@ export const DateInputBase = forwardRef<HTMLInputElement, DateInputBaseProps>(
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?.();
Comment thread
claude[bot] marked this conversation as resolved.
},
[onOpen, onClose]
);
Expand Down
24 changes: 14 additions & 10 deletions bulma-ui/src/form/DateTimeInputBase.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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]
);
Expand Down
24 changes: 14 additions & 10 deletions bulma-ui/src/form/TimeInputBase.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -304,18 +304,22 @@ export const TimeInputBase = forwardRef<HTMLInputElement, TimeInputBaseProps>(
'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]
);
Expand Down
75 changes: 75 additions & 0 deletions bulma-ui/src/form/__tests__/DateInput.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<React.StrictMode>
<DateInput defaultValue={june15} onOpen={onOpen} onClose={onClose} />
</React.StrictMode>
);
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<HTMLInputElement>(null);
const refocusedRef = React.useRef(false);
return (
<DateInput
ref={inputRef}
defaultValue={june15}
onClose={() => {
if (refocusedRef.current) return;
refocusedRef.current = true;
inputRef.current?.focus();
}}
/>
);
};
const { getByRole, queryByRole } = render(<Picker />);
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', () => {
Comment thread
claude[bot] marked this conversation as resolved.
const onClose = jest.fn();
const { getByRole, queryByRole } = render(
<DateInput defaultValue={june15} onClose={onClose} openOnFocus={false} />
);
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);
});
});
75 changes: 75 additions & 0 deletions bulma-ui/src/form/__tests__/DateTimeInput.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<React.StrictMode>
<DateTimeInput defaultValue={v} onOpen={onOpen} onClose={onClose} />
</React.StrictMode>
);
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<HTMLInputElement>(null);
const refocusedRef = React.useRef(false);
return (
<DateTimeInput
ref={inputRef}
defaultValue={v}
onClose={() => {
if (refocusedRef.current) return;
refocusedRef.current = true;
inputRef.current?.focus();
}}
/>
);
};
const { getByRole, queryByRole } = render(<Picker />);
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(
<DateTimeInput defaultValue={v} onClose={onClose} openOnFocus={false} />
);
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);
});
});
78 changes: 78 additions & 0 deletions bulma-ui/src/form/__tests__/TimeInput.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<React.StrictMode>
<TimeInput defaultValue={at(9, 30)} onOpen={onOpen} onClose={onClose} />
</React.StrictMode>
);
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<HTMLInputElement>(null);
const refocusedRef = React.useRef(false);
return (
<TimeInput
ref={inputRef}
defaultValue={at(9, 30)}
onClose={() => {
if (refocusedRef.current) return;
refocusedRef.current = true;
inputRef.current?.focus();
}}
/>
);
};
const { getByRole, queryByRole } = render(<Picker />);
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(
<TimeInput
defaultValue={at(9, 30)}
onClose={onClose}
openOnFocus={false}
/>
);
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);
});
});
Loading