Skip to content

A dialog flow

Confirming a destructive action is four screens' worth of state in one component: nothing happening, a dialog asking, a deletion that can still be taken back, and a deletion that cannot. The parts are all in the library. What this recipe is about is the seams between them, and in particular where the keyboard goes at each one.

It assumes focus and traps for what a trap is, and the Dialog and Toast pages for the two components it uses.

Press Tab to reach a row's Delete button and Enter to open the dialog. Tab inside it and the ring stays on the two buttons. Escape or Keep it puts the keyboard back on the row you came from. Delete removes the row and puts the keyboard on Undo, so the deletion can be taken back with the same key that made it.

The flow, as cells

tsx
/**
 * The flow, as four cells and four handlers.
 *
 * There is nothing happening, a dialog asking, a deletion that can
 * still be taken back, and a deletion that cannot. Every handler is
 * written so that running it twice is the same as running it once.
 *
 * That last property is not decoration. Confirming runs `dismiss`
 * twice: `remove` clears `confirming` itself, which is what takes the
 * dialog off the screen, and the close is then reported back through
 * `onClose`, which is `dismiss` again. Writing a cell is unaffected by
 * being written again, which is why `dismiss` is one assignment rather
 * than an append to a log.
 *
 * The undo window is the notice's lifetime: when the `Toast` closes,
 * on its own timer, the deletion becomes permanent. Tying the two
 * together is what stops the message saying one thing while the list
 * means another.
 */
function noteFlow() {
  const notes = internalState<readonly Note[]>(NOTES);
  /** The note the dialog is asking about, or null when it is closed. */
  const confirming = internalState<Note | null>(null);
  /** Deleted, and still recoverable: the note, and where it was. */
  const pending = internalState<{ note: Note; index: number } | null>(null);
  const notice = internalState(false);

  /** Safe to run more than once: confirming clears the cell, and the close reports back here. */
  const dismiss = (): void => {
    confirming.value = null;
  };

  const remove = (): void => {
    const note = confirming.value;
    if (note === null) {
      return;
    }
    pending.value = { note, index: notes.value.indexOf(note) };
    notes.value = notes.value.filter(entry => entry !== note);
    dismiss();
    notice.value = true;
  };

  const undo = (): void => {
    const held = pending.value;
    if (held === null) {
      return;
    }
    const restored = [...notes.value];
    restored.splice(held.index, 0, held.note);
    notes.value = restored;
    pending.value = null;
    notice.value = false;
  };

  /** The notice going away is the deletion becoming permanent. */
  const commit = (): void => {
    pending.value = null;
    notice.value = false;
  };

  return { notes, confirming, pending, notice, dismiss, remove, undo, commit };
}

Four cells, and the whole of the flow is which of them is set. There is no state machine type and no reducer, because the four states are already distinguishable: confirming holds the note being asked about, pending holds the note that can still come back, and both are null the rest of the time.

Every handler is written to be safe to run twice, and one of them has to be. Confirming runs dismiss twice: remove clears confirming itself, which is what takes the dialog off the screen, and the close is then reported back through onClose, which is dismiss again. Setting a cell is unaffected by being set again, which is why dismiss is one assignment. A handler that appended to a log, or decremented a counter, would be a bug waiting for its second call.

undo keeps the index as well as the note, so the row goes back where it was rather than onto the end. That is a one-word decision that a reader will notice immediately if it goes the other way.

One hand-written button, used everywhere

tsx
/**
 * A hand-written button, with everything a hand-written button needs.
 *
 * Four things, and all four are the application's to supply: the
 * hover and press states, the pointer cursor, the focus ring, and the
 * keys. Nothing in the runtime turns Enter on a focused button into a
 * click, so a control built out of intrinsics is not operable from
 * the keyboard until it says which keys operate it.
 *
 * `takesFocus` adds `autoFocus()`, which fires on the node's first
 * layout and once only. That is the whole mechanism behind the undo
 * button taking the caret when it appears.
 */
function Action(options: {
  label: string;
  text: string;
  danger?: boolean;
  takesFocus?: boolean;
  onPress: () => void;
}): UiChild {
  const press = () => options.onPress();
  const danger = options.danger === true;
  const modifiers = danger ? [HOVER_ACCENT, RING] : [HOVER_CONTROL, RING];

  return (
    <button
      label={options.label}
      onClick={press}
      onKeyDown={keymap({ Enter: press, ' ': press })}
      padding={8}
      borderRadius={6}
      borderWidth={danger ? 0 : 1}
      borderColor="border"
      backgroundColor={danger ? 'danger' : 'background'}
      cursor="pointer"
      modifiers={options.takesFocus === true ? [...modifiers, AUTO_FOCUS] : modifiers}>
      <text text={options.text} fontSize={12} color={danger ? 'background' : 'text'} />
    </button>
  );
}

Every button in this recipe is an intrinsic <button> rather than a library control, so it has to supply four things: the hover and press states, the pointer cursor, the focus ring, and the keys. Nothing in the runtime turns Enter on a focused button into a click, and keyboard operability is where that line is drawn and why. Writing the four once, in a helper, is the difference between a flow that works from the keyboard and one that mostly does.

The modifiers are module constants, so every button in the flow is given the same hover, the same press and the same ring rather than a description of them repeated at each call site. A modifier's arguments are compared by value, so building them in the render would keep them attached too; the constants are for saying it once.

The row

tsx
/**
 * A row, and the button that starts the flow.
 *
 * The row is a `listitem` with no label of its own, so its name is
 * the text it draws. The button inside is a semantics node in its own
 * right, so it is not swallowed into that name, and its label says
 * which note it deletes: "Delete" three times over is three identical
 * announcements.
 */
function noteRow(title: string, onDelete: () => void): UiChild {
  return (
    <row
      gap={10}
      y="center"
      padding={8}
      borderRadius={8}
      borderWidth={1}
      borderColor="border"
      backgroundColor="surface"
      role="listitem">
      <text text={title} fontSize={13} color="text" flexGrow={1} />
      {Action({ label: `Delete ${title}`, text: 'Delete', onPress: onDelete })}
    </row>
  );
}

The row is a listitem with no label of its own, so its name comes from the text it draws. The button inside declares a role, so it is a node in its own right rather than being folded into the row's name, and its label says which note it deletes: three buttons all called "Delete" are three identical announcements. See semantics for the naming rule this relies on.

Where the keyboard goes

tsx
/**
 * A list, a confirmation, and a way back.
 *
 * Two things about focus are worth watching, and the spec drives both
 * with the keyboard alone.
 *
 * **Cancel gives the keyboard back.** The trap the dialog took is
 * released when it closes, and releasing a trap restores focus to
 * whatever held it when the trap was taken, which is the row's Delete
 * button. Nothing here arranges that.
 *
 * **Confirming cannot.** The button that opened the dialog goes with
 * the row it was on, and a trap will not restore focus to a node that
 * has left the tree, so focus would be dropped. The undo button
 * carries `autoFocus()` for exactly that reason: it appears in the
 * same frame the row leaves, takes the caret, and the reader can take
 * the deletion back with the key they just pressed.
 *
 * The notice is `dismissible={false}` deliberately. Its close button
 * would be a focus stop drawn over the app in an order nobody can
 * predict from the page, so the action lives in the page instead and
 * the notice only announces.
 */
export function NoteList(_inputs: Inputs<{}>, _ctx: ComponentContext) {
  const flow = noteFlow();

  return (
    <column gap={12} padding={20} width={percent(100)} height={percent(100)}>
      <column gap={8} role="list" label="Notes">
        {flow.notes.pipe(
          map(list =>
            list.map(note =>
              noteRow(note.title, () => {
                flow.confirming.value = note;
              })
            )
          )
        )}
      </column>

      <row gap={10} y="center" minHeight={34}>
        {flow.pending.pipe(
          map(held =>
            held === null
              ? []
              : [
                  Action({
                    label: `Undo deleting ${held.note.title}`,
                    text: 'Undo',
                    takesFocus: true,
                    onPress: flow.undo
                  })
                ]
          )
        )}
        <text
          text={flow.pending.pipe(
            map(held => (held === null ? 'Nothing has been deleted.' : `Deleted "${held.note.title}".`))
          )}
          fontSize={12}
          color="textMuted"
        />
      </row>

      <Dialog
        open={flow.confirming.pipe(map(note => note !== null))}
        title="Delete this note?"
        description={flow.confirming.pipe(
          map(note => (note === null ? '' : `"${note.title}" leaves the list. You can take it back once.`))
        )}
        onClose={flow.dismiss}
        content={
          <row gap={8}>
            {Action({ label: 'Keep it', text: 'Keep it', onPress: flow.dismiss })}
            {Action({ label: 'Delete', text: 'Delete', danger: true, onPress: flow.remove })}
          </row>
        }
      />

      <Toast
        open={flow.notice}
        message={flow.pending.pipe(map(held => (held === null ? 'Note deleted' : `Deleted "${held.note.title}"`)))}
        duration={4000}
        dismissible={false}
        onClose={flow.commit}
      />
    </column>
  );
}

The two ends of the flow need different answers, and only one of them is automatic.

Cancelling is the framework's. A trap restores focus to whatever held it when the trap was taken, and that is the row's Delete button. Nothing in this file arranges it, and nothing should: the dialog took the trap, so the dialog releases it.

Confirming cannot be. The button that opened the dialog leaves with the row it was on, and a trap will not restore focus to a node that is no longer in the tree, so focus is dropped instead of landing somewhere arbitrary. Something has to say where it goes, and the undo button says it, with autoFocus(). The modifier fires on the node's first layout and once only, so the button that appears in the same frame the row leaves takes the caret, and the reader can undo with the key they just pressed.

The notice is dismissible={false} on purpose. Its close button would be a focus stop drawn over the application in an order nothing on the page predicts, so the action lives in the page and the toast only announces. It is a status rather than an alert, because the deletion has already happened and interrupting a screen reader mid-sentence to say so is not an improvement.

The undo window is the notice's lifetime. When the toast's timer closes it, commit clears the pending deletion, and the Undo button goes with it. Tying the two together is what stops the message saying one thing while the list means another.

What has been checked

The spec beside the example drives the whole flow with the keyboard and never sends a click: Tab to the row, Enter to open, Tab inside the trap, Escape and Enter to close it both ways, Enter to confirm, and Enter again to undo. It asserts the trap holds, that focus returns to the opener on cancel, that it lands on the undo on confirm, that the note goes back to its own index, and that advancing the clock past the notice's duration makes the deletion permanent.

What that does not cover is drawing, or the pointer. The canvas above is Canvas2D, and Chrome and other Chromium browsers are the extent of what this page has been opened in.

Next

A settings page is the same library used for a screen that needs no confirmation at all, and Menu is the other overlay a keyboard can reach.