mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 15:51:00 +00:00
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user