Line data Source code
1 : import * as React from 'react';
2 :
3 : interface Props {
4 : /// The persisted name, null if there is none
5 : name: string | null;
6 : /// Commit an edited name, blank input means no name
7 : onCommit: (name: string) => void;
8 : /// Abandon the edit with Escape
9 : onCancel?: () => void;
10 : /// The accessible label
11 : ariaLabel: string;
12 : /// The placeholder while there is no name
13 : placeholder: string;
14 : /// The class of the input
15 : className?: string;
16 : }
17 :
18 : /// An editable name. The input owns its draft so typing does not rerender the surrounding view;
19 : /// blur or Enter commits the draft, Escape restores the persisted name.
20 1 : export const NameInput = React.forwardRef<HTMLInputElement, Props>((props, forwardedRef) => {
21 1 : const inputRef = React.useRef<HTMLInputElement | null>(null);
22 1 : const skipBlurCommitRef = React.useRef(false);
23 1 : React.useImperativeHandle(forwardedRef, () => inputRef.current!, []);
24 : // Re-sync the draft when the persisted name changes (e.g. a rename from elsewhere, or switching
25 : // the named object while the input stays mounted).
26 1 : React.useEffect(() => {
27 1 : if (inputRef.current != null) inputRef.current.value = props.name ?? '';
28 1 : }, [props.name]);
29 :
30 1 : const commitOnBlur = React.useCallback((e: React.FocusEvent<HTMLInputElement>) => {
31 1 : if (!skipBlurCommitRef.current) props.onCommit(e.currentTarget.value);
32 1 : }, [props.onCommit]);
33 : // Drop the focus without a commit. blur() fires its event synchronously, and none at all while the
34 : // window lacks focus, so the keys cannot leave it to the event to commit or to reset the flag.
35 1 : const blurWithoutCommit = React.useCallback((input: HTMLInputElement) => {
36 1 : skipBlurCommitRef.current = true;
37 1 : input.blur();
38 1 : skipBlurCommitRef.current = false;
39 1 : }, []);
40 1 : const onKeyDown = React.useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
41 1 : if (e.key === 'Enter') {
42 1 : props.onCommit(e.currentTarget.value);
43 1 : blurWithoutCommit(e.currentTarget);
44 1 : } else if (e.key === 'Escape') {
45 : // Abandon the edit: restore the persisted value and drop focus.
46 1 : e.currentTarget.value = props.name ?? '';
47 1 : blurWithoutCommit(e.currentTarget);
48 1 : props.onCancel?.();
49 1 : }
50 1 : e.stopPropagation();
51 1 : }, [props.name, props.onCommit, props.onCancel, blurWithoutCommit]);
52 1 : const onPointerDown = React.useCallback((e: React.PointerEvent<HTMLInputElement>) => {
53 : // Inputs are often portaled from sortable rows. Keep selection gestures from bubbling
54 : // through the React tree and activating a drag sensor.
55 1 : e.stopPropagation();
56 1 : }, []);
57 :
58 1 : return (
59 1 : <input
60 1 : ref={inputRef}
61 1 : className={props.className}
62 1 : type="text"
63 1 : aria-label={props.ariaLabel}
64 1 : defaultValue={props.name ?? ''}
65 1 : placeholder={props.placeholder}
66 1 : spellCheck={false}
67 1 : onBlur={commitOnBlur}
68 1 : onKeyDown={onKeyDown}
69 1 : onPointerDown={onPointerDown}
70 1 : />
71 : );
72 1 : });
|