Inline edit

패키지 네임스페이스가 아니라 레시피입니다. 보이는 자리에서 이름을 바꿀 수 있는 값은 Text와 그 뒤의 아이콘만 있는 Button입니다. 편집하는 동안에는 Field의 텍스트 입력과 Save, Cancel 버튼이 그 자리를 대신합니다. 키와 포커스는 useInlineEdit이 맡습니다. InlineEdit.* export는 없습니다.

이 페이지의 내용

Rename을 누르고 새 이름을 입력한 다음, Enter로 저장하거나 Escape로 이름을 되돌려 보세요.

Q3 report

import { Button, ErrorMessage, Field, Stack, Text, useInlineEdit } from '@tounsoo/todae';
import { Check, Pencil, X } from 'lucide-react';
import { useId } from 'react';

export function InlineName({ name, onRename }: { name: string; onRename: (name: string) => void }) {
  const nameId = useId();
  const errorId = useId();
  const edit = useInlineEdit({
    value: name,
    onCommit: (draft) => {
      if (draft.trim() === '') return false;
      onRename(draft.trim());
    },
  });
  const invalid = edit.draft.trim() === '';

  if (edit.editing) {
    return (
      <Stack.Root {...edit.getGroupProps()} gap="condensed">
        <Stack.Root direction="row" gap="condensed" align="center">
          <Field.Root aria-label="Name" invalid={invalid} aria-describedby={invalid ? errorId : undefined}>
            <Field.TextInput {...edit.getInputProps()} />
          </Field.Root>
          <Button.Root variant="ghost" aria-label="Save" onClick={edit.commit}>
            <Button.Icon>
              <Check />
            </Button.Icon>
          </Button.Root>
          <Button.Root variant="ghost" aria-label="Cancel" onClick={edit.cancel}>
            <Button.Icon>
              <X />
            </Button.Icon>
          </Button.Root>
        </Stack.Root>
        {invalid ? <ErrorMessage.Root id={errorId}>Enter a name.</ErrorMessage.Root> : null}
      </Stack.Root>
    );
  }

  return (
    <Stack.Root direction="row" gap="condensed" align="center">
      <Text.Root id={nameId}>{name}</Text.Root>
      <Button.Root {...edit.getTriggerProps()} variant="ghost" aria-label="Rename" aria-describedby={nameId}>
        <Button.Icon>
          <Pencil />
        </Button.Icon>
      </Button.Root>
    </Stack.Root>
  );
}

저장과 취소

Enter와 Save 버튼이 저장하고, Tab 등으로 포커스를 편집 밖으로 옮겨도 저장됩니다. Escape와 Cancel 버튼은 이름을 되돌립니다. 저장이나 취소는 이름이 설명하는 Rename으로 포커스를 되돌리므로, 스크린 리더가 버튼과 함께 새 이름을 읽고 페이지는 따로 알릴 필요가 없습니다. 빈 이름은 이름을 입력하거나 취소할 때까지 편집을 열어 두고 오류를 보여 줍니다. 빈 이름으로 포커스를 옮기면 이전 이름으로 돌아갑니다.

Table에서는 셀에 같은 마크업을 넣습니다.

서버에 저장하기

새 이름을 바로 보여 주고 보내세요. 저장에 실패하면 이전 이름을 되돌리고, useAnnouncer로 알리고, 다시 시도할 수 있게 하세요. start(draft)는 실패한 이름으로 편집을 엽니다.

const announce = useAnnouncer();
// The name that failed to save, offered again by Try again.
const [failed, setFailed] = useState<string | null>(null);
const edit = useInlineEdit({
  value: name,
  onCommit: (draft) => {
    const next = draft.trim();
    if (next === '') return false;
    const before = name;
    setName(next);
    // rename sends the name to your server.
    rename(next).catch(() => {
      setName(before);
      setFailed(next);
      announce(`Couldn't rename to ${next}.`, { politeness: 'assertive' });
    });
  },
});

{failed !== null && (
  <Button.Root
    variant="secondary"
    onClick={() => {
      setFailed(null);
      edit.start(failed);
    }}
  >
    Try again
  </Button.Root>
)}

접근성을 지키는 조합

Todae가 맡는 것: useInlineEdit은 입력에 포커스를 주고 이름을 선택하며, Enter와 포커스가 편집을 떠날 때 저장하고, Escape로 취소하고, IME 조합은 건드리지 않고, 둘러싼 Dialog가 닫히기 전에 편집을 취소하고, Save나 Cancel을 누르는 동안 포커스를 입력에 두고, 포커스를 Rename으로 되돌립니다. 아이콘만 있는 Button.Root는 aria-label을 tooltip으로 보여 줍니다. Field.Root invalid는 입력에 aria-invalid를 설정합니다.

직접 제공할 것: 입력과 Rename, Save, Cancel 버튼의 이름. Rename의 설명이 되는 이름의 id. 오류 메시지와, 필드의 aria-describedby에 넣은 그 id. 서버 저장이 실패했을 때의 알림. 모두 제품의 언어로.

주의할 점: 이름 목록에서 "Rename"이라고만 말하는 Rename 버튼. 예제처럼 각 버튼을 그 이름으로 설명하세요. 편집 그룹 밖의 Save나 Cancel 버튼. 포커스가 그 버튼으로 옮겨 가면서 저장됩니다. 편집이 닫힌 뒤 서버가 거절한 이름을 저장된 것처럼 보여 주는 것. 이름을 key로 쓰는 목록. 이름을 바꾸면 새 행이 마운트되어 포커스가 돌아갈 곳이 없습니다. 행의 key는 id로 정하세요.

언제 쓰나요

쓸 때: 파일, 목록, 대화, 행의 이름을 그 이름이 보이는 자리에서 바꿀 때.

쓰지 않을 때: 여러 줄의 텍스트 → Form이나 Dialog 안의 Field.Textarea. 함께 바뀌는 여러 값 → Form.