feat(dialogs): save a dialog with Ctrl/Cmd+Enter (#1303)

Ctrl/Cmd+Enter in any field of a DialogShell presses its one primary footer
button, focusing it first so blur-committed fields keep their value. Dialogs
with no or several primaries leave the keys to the field, as does the journal
story editor, which opts out with data-keeps-ctrl-enter.
This commit is contained in:
Maurice
2026-11-13 09:25:24 +01:00
parent 8401428ca7
commit 208af03212
3 changed files with 165 additions and 6 deletions
@@ -465,8 +465,10 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
<div className="flex min-h-[220px] flex-1 flex-col overflow-hidden rounded-[10px] border border-edge bg-surface-input focus-within:ring-2 focus-within:ring-[color:var(--text-primary)]">
<MarkdownToolbar textareaRef={storyRef} onUpdate={setStory} />
{/* Written like a long text: Ctrl/Cmd+Enter stays with the story instead of saving the entry. */}
<textarea
ref={storyRef}
data-keeps-ctrl-enter
value={story}
onChange={e => setStory(e.target.value)}
placeholder={t('journey.editor.writeStory')}
@@ -1,7 +1,7 @@
// FE-COMP-DIALOGSHELL-001 to FE-COMP-DIALOGSHELL-013
// FE-COMP-DIALOGSHELL-001 to FE-COMP-DIALOGSHELL-020
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { useState } from 'react';
import { render, screen, fireEvent } from '../../../tests/helpers/render';
import { render, screen, fireEvent, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { resetBodyScrollLock } from '../../utils/bodyScrollLock';
import {
@@ -223,3 +223,127 @@ describe('body and footer parts', () => {
expect(NEUTRAL_TINT).toContain('var(--accent)');
});
});
// #1303: Ctrl/Cmd+Enter saves a dialog from the keyboard.
describe('DialogShell Ctrl/Cmd+Enter', () => {
/** Lets the deferred press run, so an assertion that nothing was pressed means it. */
const settle = () => new Promise(resolve => setTimeout(resolve, 0));
/** A note field that takes its value on blur, like a typed time, and a save that reads it. */
function Editor({ onSave, onTitleKey, primaries = 1, saveDisabled = false, blocked = false, keepsCtrlEnter = false }: {
onSave: (note: string) => void;
onTitleKey?: () => void;
primaries?: number;
saveDisabled?: boolean;
blocked?: boolean;
keepsCtrlEnter?: boolean;
}) {
const [note, setNote] = useState('');
const [draft, setDraft] = useState('');
const [title, setTitle] = useState('');
return (
<DialogShell onClose={onClose} labelledBy="e-title" blocked={blocked}
header={<DialogHeader tile={null} tint={NEUTRAL_TINT} labelId="e-title" onClose={onClose}
titleInput={{ value: title, onChange: setTitle, label: 'Title', onKeyDown: e => { if (e.key === 'Enter') onTitleKey?.(); } }} />}
footer={
<DialogFooter>
<DialogButton onClick={onClose}>Cancel</DialogButton>
{Array.from({ length: primaries }, (_, i) => (
<DialogButton key={i} variant="primary" disabled={saveDisabled} onClick={() => onSave(note)}>Save {i}</DialogButton>
))}
</DialogFooter>
}>
<textarea aria-label="Note" value={draft} onChange={e => setDraft(e.target.value)} onBlur={() => setNote(draft)}
data-keeps-ctrl-enter={keepsCtrlEnter || undefined} />
</DialogShell>
);
}
it('FE-COMP-DIALOGSHELL-014: the primary button is marked, the others are not', () => {
render(
<DialogFooter>
<DialogButton>Cancel</DialogButton>
<DialogButton variant="primary">Save</DialogButton>
</DialogFooter>,
);
expect(screen.getByRole('button', { name: 'Save' })).toHaveAttribute('data-dialog-primary');
expect(screen.getByRole('button', { name: 'Cancel' })).not.toHaveAttribute('data-dialog-primary');
});
it('FE-COMP-DIALOGSHELL-015: Ctrl+Enter in a field saves once, with the value the field commits on blur', async () => {
const onSave = vi.fn();
render(<Editor onSave={onSave} />);
await userEvent.type(screen.getByRole('textbox', { name: 'Note' }), 'Bring the passports');
await userEvent.keyboard('{Control>}{Enter}{/Control}');
await waitFor(() => expect(onSave).toHaveBeenCalledWith('Bring the passports'));
await settle();
expect(onSave).toHaveBeenCalledTimes(1);
// Spent on the save: the textarea did not get a line break.
expect(screen.getByRole('textbox', { name: 'Note' })).toHaveValue('Bring the passports');
});
it('FE-COMP-DIALOGSHELL-016: Cmd+Enter does the same, Enter alone and with Shift or Alt do not', async () => {
const onSave = vi.fn();
render(<Editor onSave={onSave} />);
const note = screen.getByRole('textbox', { name: 'Note' });
await userEvent.click(note);
await userEvent.keyboard('{Enter}{Shift>}{Control>}{Enter}{/Control}{/Shift}{Alt>}{Control>}{Enter}{/Control}{/Alt}');
await settle();
expect(onSave).not.toHaveBeenCalled();
await userEvent.click(note);
await userEvent.keyboard('{Meta>}{Enter}{/Meta}');
await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
});
it('FE-COMP-DIALOGSHELL-017: a title that saves on Enter does not save a second time', async () => {
const onSave = vi.fn();
const onTitleKey = vi.fn();
render(<Editor onSave={onSave} onTitleKey={onTitleKey} />);
await userEvent.click(screen.getByRole('textbox', { name: 'Title' }));
await userEvent.keyboard('{Control>}{Enter}{/Control}');
await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
expect(onTitleKey).not.toHaveBeenCalled();
});
it('FE-COMP-DIALOGSHELL-018: a disabled primary saves nothing, and the field does not save in its place', async () => {
const onSave = vi.fn();
const onTitleKey = vi.fn();
render(<Editor onSave={onSave} onTitleKey={onTitleKey} saveDisabled />);
await userEvent.click(screen.getByRole('textbox', { name: 'Title' }));
await userEvent.keyboard('{Control>}{Enter}{/Control}');
await settle();
expect(onSave).not.toHaveBeenCalled();
expect(onTitleKey).not.toHaveBeenCalled();
});
it('FE-COMP-DIALOGSHELL-019: with no primary or two of them, the keys stay with the field', async () => {
const onSave = vi.fn();
const onTitleKey = vi.fn();
const { rerender } = render(<Editor onSave={onSave} onTitleKey={onTitleKey} primaries={0} />);
await userEvent.click(screen.getByRole('textbox', { name: 'Title' }));
await userEvent.keyboard('{Control>}{Enter}{/Control}');
rerender(<Editor onSave={onSave} onTitleKey={onTitleKey} primaries={2} />);
await userEvent.keyboard('{Control>}{Enter}{/Control}');
await settle();
expect(onSave).not.toHaveBeenCalled();
expect(onTitleKey).toHaveBeenCalledTimes(2);
});
it('FE-COMP-DIALOGSHELL-020: a field that keeps Ctrl+Enter, or a question open over the dialog, saves nothing', async () => {
const onSave = vi.fn();
const { rerender } = render(<Editor onSave={onSave} keepsCtrlEnter />);
await userEvent.click(screen.getByRole('textbox', { name: 'Note' }));
await userEvent.keyboard('{Control>}{Enter}{/Control}');
rerender(<Editor onSave={onSave} blocked />);
await userEvent.click(screen.getByRole('textbox', { name: 'Note' }));
await userEvent.keyboard('{Control>}{Enter}{/Control}');
await settle();
expect(onSave).not.toHaveBeenCalled();
});
});
+37 -4
View File
@@ -50,6 +50,32 @@ export interface DialogShellProps {
discardGuard?: unknown
}
/**
* Ctrl/Cmd+Enter finishes the dialog from any field (#1303), so a form filled in
* with the keyboard can be saved without reaching for the mouse. It presses the
* footer's one primary button the way a hand would: the focus moves onto it
* first, so a field that takes its value on blur (a typed time) has it, and the
* press follows once that has rendered. It saves exactly what the button saves
* and nothing while the button is disabled. A footer with no primary, or two to
* choose between, leaves the keys to the field, and so does a field marked
* `data-keeps-ctrl-enter` (the journal's story, written like a long text).
*/
function pressPrimaryOnCtrlEnter(e: ReactKeyboardEvent<HTMLDivElement>, panel: HTMLDivElement) {
if (e.key !== 'Enter' || !(e.ctrlKey || e.metaKey) || e.altKey || e.shiftKey || e.repeat || e.nativeEvent.isComposing) return
// A dialog opened from this one sits in its own portal: its keys are its own.
const target = e.target
if (!(target instanceof Element) || !panel.contains(target) || target.closest('[data-keeps-ctrl-enter]')) return
const primaries = panel.querySelectorAll<HTMLButtonElement>(':scope > footer [data-dialog-primary]')
if (primaries.length !== 1) return
const primary = primaries[0]
// Spent here, so a field's own Enter (a title that saves on Enter) does not save a second time.
e.preventDefault()
e.stopPropagation()
if (primary.disabled) return
primary.focus()
setTimeout(() => { if (primary.isConnected && !primary.disabled) primary.click() })
}
const DISCARD_BUTTON = 'inline-flex items-center gap-1.5 rounded-[10px] bg-danger px-4 py-2 font-medium text-white hover:opacity-90' // theme-lint-disable: white on the danger fill, as ConfirmDialog draws it
/**
@@ -125,7 +151,10 @@ function DialogFrame({ onClose, labelledBy, width = 'detail', align = 'center',
aria-labelledby={labelledBy}
tabIndex={-1}
onPaste={onPaste}
onKeyDownCapture={markStart}
onKeyDownCapture={e => {
markStart()
if (!held && panelRef.current) pressPrimaryOnCtrlEnter(e, panelRef.current)
}}
onPointerDownCapture={markStart}
onKeyDown={e => { if (panelRef.current) trapTab(e, panelRef.current) }}
className={`trek-modal-enter relative flex max-h-full w-full ${PANEL_WIDTH[width]} flex-col overflow-hidden rounded-[22px] bg-surface-card shadow-2xl outline-none`}
@@ -303,11 +332,15 @@ export type DialogButtonProps = Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'c
icon?: ReactNode
}
/** A footer button: white on a hairline, or the accent fill for the one that finishes the dialog. */
/**
* A footer button: white on a hairline, or the accent fill for the one that finishes the dialog.
* The primary one is marked, for the dialog's Ctrl/Cmd+Enter.
*/
export function DialogButton({ variant = 'secondary', active = false, icon, children, type = 'button', ...rest }: DialogButtonProps) {
const look = variant === 'primary' ? BUTTON_LOOK.primary : active ? BUTTON_LOOK.active : BUTTON_LOOK.secondary
const primary = variant === 'primary'
const look = primary ? BUTTON_LOOK.primary : active ? BUTTON_LOOK.active : BUTTON_LOOK.secondary
return (
<button {...rest} type={type} className={look} style={fs(13, 'body')}>
<button {...rest} type={type} className={look} style={fs(13, 'body')} data-dialog-primary={primary ? '' : undefined}>
{icon}{children}
</button>
)