From 1dcb94e2b41683fe9b77881bd8e31e94d6c89520 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Tue, 13 Oct 2026 09:09:39 +0100 Subject: [PATCH 1/7] Add a decision option with one confirm and choose it for its author The add field was a single-line input sized to its content, so a long option widened the row and the card scrolled sideways to follow the caret. It is now a wrapping textarea, and the sidecar card clips rather than hides overflow so focus can never scroll it. The leading plus tile becomes the confirm check once there is text. Enter or the check adds the option; after the server acknowledges it with its id, the view selects it for the adder, moves focus to the new row and announces it, so Save is ready at once. A collaborator's single choice in the shared draft is left alone. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/theme.css | 92 ++++++++++- e2e/conversation-plan-ui.e2e.ts | 24 +-- e2e/inline-decision-deck.e2e.ts | 38 +++++ e2e/sidecar-option-async.e2e.ts | 6 +- e2e/sidecar-option-contracts.e2e.ts | 49 +++--- e2e/sidecar.e2e.ts | 14 +- .../question-terminal-origin.fixture.ts | 2 +- e2e/source/question-terminal-origin.native.ts | 8 +- packages/editor/src/card.tsx | 2 +- .../question/src/react/add-option.test.ts | 33 +++- packages/question/src/react/question-view.tsx | 145 ++++++++++++++---- .../src/react/questionnaire-controller.ts | 3 +- .../question/src/react/shared-options.test.ts | 2 +- .../use-questionnaire-option-growth.test.ts | 5 +- 14 files changed, 337 insertions(+), 86 deletions(-) diff --git a/apps/web/src/theme.css b/apps/web/src/theme.css index 754dbc082..3975e00cb 100644 --- a/apps/web/src/theme.css +++ b/apps/web/src/theme.css @@ -1620,7 +1620,7 @@ button.chat-tool-call-toggle { } /* Coloured icon holders: the global icon colour would otherwise win. */ -:is(.question-mark, .question-check) [data-nucleo-icon], +:is(.question-mark, .question-check, .question-add-confirm) [data-nucleo-icon], .question-callout .plan-research-badge [data-nucleo-icon] { color: inherit; } @@ -1763,6 +1763,7 @@ label.question-option:has(.question-input:not(:disabled)) { display: grid; flex: 1; min-width: 0; + overflow-wrap: anywhere; } .question-label { @@ -1852,10 +1853,98 @@ button.question-add:disabled { } .question-adding { + align-items: flex-start; outline: var(--edge-width) solid var(--color-control-edge); outline-offset: calc(-1 * var(--edge-width)); } +/* The leading tile: a quiet plus while the field is empty, the confirm check once it has text. */ +.question-add-confirm { + position: relative; + display: grid; + place-items: center; + inline-size: 1.5rem; + block-size: 1.5rem; + padding: 0; + border: 0; + border-radius: var(--radius-sm); + background: var(--color-inset); + color: var(--color-text-tertiary); + cursor: pointer; + transition: + background-color var(--duration-fast) var(--ease-out), + color var(--duration-fast) var(--ease-out), + scale var(--duration-fast) var(--ease-out); +} + +/* A touch-sized target around the small tile. */ +.question-add-confirm::after { + content: ""; + position: absolute; + inset: calc(var(--spacing) * -1.5); +} + +.question-add-confirm:disabled { + cursor: default; +} + +.question-add-confirm[data-ready] { + background: var(--color-brand); + color: var(--color-white); +} + +.question-add-confirm[data-ready]:hover { + background: var(--color-brand-hover); +} + +.question-add-confirm[data-ready]:active { + scale: 0.96; +} + +.question-add-confirm:focus-visible { + outline: var(--focus-ring-width) solid var(--focus-ring-color); + outline-offset: var(--focus-ring-offset); +} + +.question-add-confirm > svg { + grid-area: 1 / 1; + transition: opacity var(--duration-fast) var(--ease-out); +} + +.question-add-confirm > svg:last-child, +.question-add-confirm[data-ready] > svg:first-child { + opacity: 0; +} + +.question-add-confirm[data-ready] > svg:last-child { + opacity: 1; +} + +.question-adding[aria-busy] .question-add-confirm { + cursor: progress; + opacity: 0.6; +} + +@media (prefers-reduced-motion: no-preference) { + .question-add-confirm > svg { + transition: + opacity var(--duration-fast) var(--ease-out), + scale var(--duration-fast) var(--ease-out), + filter var(--duration-fast) var(--ease-out); + } + + .question-add-confirm > svg:last-child, + .question-add-confirm[data-ready] > svg:first-child { + scale: 0.25; + filter: blur(4px); + } + + .question-add-confirm[data-ready] > svg:last-child { + scale: 1; + filter: none; + } +} + .question-adding:focus-within { outline-color: var(--color-brand); } @@ -1886,6 +1975,7 @@ button.question-add:disabled { outline: none; resize: none; field-sizing: content; + overflow-wrap: anywhere; } .question-field:focus-visible { diff --git a/e2e/conversation-plan-ui.e2e.ts b/e2e/conversation-plan-ui.e2e.ts index 5fb0c4a1a..cb7288499 100644 --- a/e2e/conversation-plan-ui.e2e.ts +++ b/e2e/conversation-plan-ui.e2e.ts @@ -322,22 +322,22 @@ test("a human choice overrides an advisory chat suggestion", async ({ join, room await sendChat(ana, "Should we ship a small pilot?"); await waitForEvent(ana, "thread.opened"); await sendChat(bo, "Start with a small pilot."); - let withOption = await waitForEvent(ana, "option.added"); - let humanOptionId = withOption.threads[0]!.contributions.find(item => item.kind === "option")!.id; + await waitForEvent(ana, "option.added"); let card = decisionCard(ana); - await card.getByRole("button", { name: "Add an option", exact: true }).click(); - await card.getByRole("textbox", { name: "New option" }).fill("Ship to everyone"); - await ana.keyboard.press("Enter"); - let suggestion = card.getByRole("radio", { name: "Ship to everyone" }); - await expect(suggestion).toBeVisible(); - await sendChat(bo, "Let's just go with Ship to everyone."); + await sendChat(bo, "Let's just go with a small pilot."); await waitForEvent(ana, "settle.suggested"); + let suggestion = card.getByRole("radio", { name: "Start with a small pilot." }); await expect(suggestion).toBeChecked(); await expect(card.getByText("from chat", { exact: true })).toBeVisible(); - let humanChoice = card.getByRole("radio", { name: "Start with a small pilot." }); - await card.getByText("Start with a small pilot.", { exact: true }).click(); + // Adding an option is choosing it, so it replaces the suggestion in one step. + await card.getByRole("button", { name: "Add an option", exact: true }).click(); + await card.getByRole("textbox", { name: "New option" }).fill("Ship to everyone"); + await ana.keyboard.press("Enter"); + let humanChoice = card.getByRole("radio", { name: "Ship to everyone" }); await expect(humanChoice).toBeChecked(); + await expect(suggestion).not.toBeChecked(); await expect(card.getByText("from chat", { exact: true })).toHaveCount(0); + let humanOptionId = await humanChoice.getAttribute("value"); await card.getByRole("button", { name: "Save", exact: true }).click(); let decided = await waitForEvent(ana, "decision.recorded"); expect(decided.threads[0]!.decision).toMatchObject({ @@ -373,7 +373,7 @@ test("typing an option before a suggestion keeps the composer intent until Escap await expect(card.getByText("from chat", { exact: true })).toBeVisible(); }); -test("adding an option does not suppress a later chat suggestion", async ({ join, room }) => { +test("an added option stays the adder's choice when chat later suggests it", async ({ join, room }) => { let ana = await join("ana"); let bo = await join("bo"); await openJevWire(ana, room); @@ -404,7 +404,7 @@ test("adding an option does not suppress a later chat suggestion", async ({ join expect(label).toBeTruthy(); await expect(card.getByRole("radio", { name: label! })).toBeChecked(); await expect(card.getByRole("radio", { name: "Start with a small pilot." })).not.toBeChecked(); - await expect(card.getByText("from chat", { exact: true })).toBeVisible(); + await expect(card.getByText("from chat", { exact: true })).toHaveCount(0); }); test("failed analysis retries in Chat while editor selection survives hiding Chat", async ({ join, room, seed }) => { diff --git a/e2e/inline-decision-deck.e2e.ts b/e2e/inline-decision-deck.e2e.ts index e44bcdcf7..81e9e8984 100644 --- a/e2e/inline-decision-deck.e2e.ts +++ b/e2e/inline-decision-deck.e2e.ts @@ -554,3 +554,41 @@ test("on a phone, adjacent decisions stay chips instead of a deck", async ({ joi } await expect(deck(page)).toHaveCount(0); }); + +test("a long new option wraps in place and one confirm adds and chooses it", async ({ join, seed }) => { + let fixture = deckSeed({ before: "Intro paragraph." }, 2); + await seed(fixture.source, fixture.state); + let page = await join("ana"); + let card = top(page); + let save = card.getByRole("button", { name: "Save", exact: true }); + await expect(save).toBeDisabled(); + + await card.getByRole("button", { name: "Add an option", exact: true }).click(); + let field = card.getByRole("textbox", { name: "New option" }); + let confirm = card.getByRole("button", { name: "Add option", exact: true }); + await expect(confirm).toBeDisabled(); + let label = "Keep room state in a small embedded database next to the server process, " + + "so it survives restarts and needs no separate service"; + await field.pressSequentially(label); + await expect(confirm).toBeEnabled(); + + // The field grows downwards; nothing around it scrolls sideways to follow the caret. + let geometry = await field.evaluate(node => { + let line = parseFloat(getComputedStyle(node).lineHeight); + let scrolled: string[] = []; + for (let at: Element | null = node.parentElement; at; at = at.parentElement) { + if (at.scrollLeft !== 0) scrolled.push(at.tagName); + } + return { height: node.getBoundingClientRect().height, line, scrolled }; + }); + expect(geometry.height).toBeGreaterThan(geometry.line * 1.5); + expect(geometry.scrolled).toEqual([]); + await expect(card.getByRole("heading", { name: fixture.prompts[0] })).toBeInViewport(); + + await confirm.click(); + let added = card.getByRole("radio", { name: label, exact: true }); + await expect(added).toBeChecked(); + await expect(field).toHaveCount(0); + await expect(card.getByRole("button", { name: "Add an option", exact: true })).toBeVisible(); + await expect(save).toBeEnabled(); +}); diff --git a/e2e/sidecar-option-async.e2e.ts b/e2e/sidecar-option-async.e2e.ts index 1e864a301..b7ea32314 100644 --- a/e2e/sidecar-option-async.e2e.ts +++ b/e2e/sidecar-option-async.e2e.ts @@ -96,7 +96,11 @@ test("adding an option keeps focus through a delayed definition update", async ( await expect(field).toHaveJSProperty("readOnly", true); held.releaseReply(); await expect(field).toHaveCount(0); - await expect(trigger).toBeFocused(); + // The confirmed option is chosen and takes focus; the add row is quiet again. + let added = card.getByRole("radio", { name: "A delayed refresh option", exact: true }); + await expect(added).toBeChecked(); + await expect(added).toBeFocused(); + await expect(trigger).toBeVisible(); }); test("Escape during a delayed option reply restores focus and respects moving away", async ({ join, page, seed }) => { diff --git a/e2e/sidecar-option-contracts.e2e.ts b/e2e/sidecar-option-contracts.e2e.ts index 4cdcc4a44..d2a2fcc6e 100644 --- a/e2e/sidecar-option-contracts.e2e.ts +++ b/e2e/sidecar-option-contracts.e2e.ts @@ -67,8 +67,11 @@ test("adding an option shows it to everyone and keeps a selection", async ({ joi await expect(card(ben).getByRole("radio", { name: "In PostgreSQL" })).toBeVisible(); await expect(card(ben).getByRole("radio", { name: "In SQLite" })).toBeChecked(); await expect(card(ana).getByRole("textbox", { name: "New option" })).toHaveCount(0); - await expect(card(ana).getByRole("button", { name: "Add an option", exact: true })).toBeFocused(); + // Ben's choice is his: Ana's option is added for everyone but not chosen over it. + await expect(card(ana).getByRole("radio", { name: "In PostgreSQL" })).toBeFocused(); + await expect(card(ana).getByRole("radio", { name: "In PostgreSQL" })).not.toBeChecked(); + // Ben's own add replaces his own choice; Ana's next add still leaves it. for (let [writer, label] of [[ben, "In MySQL"], [ana, "In files"]] as const) { await card(writer).getByRole("button", { name: "Add an option", exact: true }).click(); let field = card(writer).getByRole("textbox", { name: "New option" }); @@ -77,25 +80,21 @@ test("adding an option shows it to everyone and keeps a selection", async ({ joi await expect(field).toHaveCount(0); for (let page of [ana, ben]) { await expect(card(page).getByRole("radio", { name: label, exact: true })).toBeVisible(); - await expect( - card(page).getByRole("radio", { - name: "In SQLite Transactional, but opaque without a client.", - exact: true, - }), - ).toBeChecked(); + await expect(card(page).getByRole("radio", { name: "In MySQL", exact: true })) + .toBeChecked(); } } for (let page of [ana, ben]) { await expect(card(page).getByRole("radio")).toHaveCount(5); - for (let label of ["In PostgreSQL", "In MySQL", "In files"]) { - await expect(card(page).getByRole("radio", { name: label, exact: true })).not.toBeChecked(); + for (let label of ["In SQLite", "In PostgreSQL", "In files"]) { + await expect(card(page).getByRole("radio", { name: label })).not.toBeChecked(); } await expect(card(page).getByRole("button", { name: "Add an option", exact: true })) .toBeEnabled(); } }); -test("adding an option leaves an unanswered decision unselected after refresh", async ({ join, page, seed }) => { +test("adding options selects only the adder's latest one after refresh", async ({ join, page, seed }) => { await seed(PROSE); let snapshots: Array<{ definition: Definition; model: number[] }> = []; let definition: Definition | undefined; @@ -139,21 +138,28 @@ test("adding an option leaves an unanswered decision unselected after refresh", let save = card.getByRole("button", { name: "Save", exact: true }); await expect(options).toHaveCount(2); await expect.poll(() => snapshots.length).toBeGreaterThan(0); - let unanswered = () => { + let chosen = (label: string | null) => { let snapshot = snapshots.at(-1)!; let current = definition!; let question = current.questions[0]!; let draft = read(restore(snapshot.model, current), current)[question.id]; - expect(draft?.choice).toBeNull(); + let id = question.options.find(option => option.label === label)?.id ?? null; + expect(draft?.choice).toBe(id); expect(draft?.mode).toBe("choices"); expect(Object.values(draft?.options ?? {})).toEqual( Array.from({ length: question.options.length }, () => false), ); }; - let checkVisible = async (count: number) => { + let checkVisible = async (count: number, label: string | null) => { await expect(options).toHaveCount(count); - for (let option of await options.all()) await expect(option).not.toBeChecked(); - await expect(save).toBeDisabled(); + for (let option of await options.all()) { + let name = await option.getAttribute("value"); + let match = label + && definition!.questions[0]!.options.find(each => each.id === name)?.label === label; + if (match) await expect(option).toBeChecked(); + else await expect(option).not.toBeChecked(); + } + if (!label) await expect(save).toBeDisabled(); }; let ana = await join("ana"); await ana.getByRole("button", { name: /^Decisions/ }).click(); @@ -173,8 +179,9 @@ test("adding an option leaves an unanswered decision unselected after refresh", } await expect.poll(() => definition?.questions[0]?.options.length).toBe(6); let beforeIds = definition!.questions[0]!.options.map(option => option.id); - unanswered(); - await checkVisible(6); + // This snapshot predates the adds; the live card already shows Ana's latest choice. + chosen(null); + await checkVisible(6, "In memory"); let previousChanges = changed; await add("In a repository"); @@ -186,8 +193,8 @@ test("adding an option leaves an unanswered decision unselected after refresh", beforeIds, ); expect(after.questions[0]!.options[6]?.label).toBe("In a repository"); - unanswered(); - await checkVisible(7); + chosen(null); + await checkVisible(7, "In a repository"); await expect(card.getByText("from chat", { exact: true })).toHaveCount(0); let previousSnapshots = snapshots.length; @@ -195,8 +202,8 @@ test("adding an option leaves an unanswered decision unselected after refresh", await ben.getByRole("button", { name: /^Decisions/ }).click(); await expect.poll(() => snapshots.length).toBeGreaterThan(previousSnapshots); await expect.poll(() => definition?.questions[0]?.options.length).toBe(7); - unanswered(); - await checkVisible(7); + chosen("In a repository"); + await checkVisible(7, "In a repository"); }); test("a duplicate option is refused inline", async ({ join, seed }) => { diff --git a/e2e/sidecar.e2e.ts b/e2e/sidecar.e2e.ts index 27fca329a..925801c6a 100644 --- a/e2e/sidecar.e2e.ts +++ b/e2e/sidecar.e2e.ts @@ -638,16 +638,16 @@ test("decision cards save independently with progressive custom answers", async field = scope.getByRole("textbox", { name: "New option" }); await field.fill("Only collaborative anchors"); await field.press("Enter"); - // It joins everyone's list as the next lettered row and is not chosen for anyone. + // It joins everyone's list as the next lettered row, already chosen by its author. let added = scope.getByRole("checkbox", { name: "Only collaborative anchors" }); await expect(added).toBeVisible(); - await expect(added).not.toBeChecked(); + await expect(added).toBeChecked(); await expect(scope.locator("label", { hasText: "Only collaborative anchors" })).toContainText( "D", ); - await expect(addRow).toBeFocused(); - await expect(saveScope).toBeDisabled(); - await added.check(); + await expect(added).toBeFocused(); + await expect(addRow).toBeVisible(); + await expect(saveScope).toBeEnabled(); await scope.getByRole("checkbox", { name: /^Anchors/ }).check(); await saveScope.click(); @@ -890,11 +890,11 @@ test("an option one member adds is shared, durable, and choosable by another", a await field.fill("In PostgreSQL"); await field.press("Enter"); - // Everyone sees it as the next lettered row, chosen by nobody. + // Everyone sees it as the next lettered row, chosen in the shared draft by its author. for (let page of [ana, bo]) { let row = storage(page).getByRole("radio", { name: "In PostgreSQL" }); await expect(row).toBeVisible(); - await expect(row).not.toBeChecked(); + await expect(row).toBeChecked(); await expect(storage(page).locator("label", { hasText: "In PostgreSQL" })).toContainText("C"); } await expect(storage(ana).getByRole("textbox", { name: "New option" })).toHaveCount(0); diff --git a/e2e/source/question-terminal-origin.fixture.ts b/e2e/source/question-terminal-origin.fixture.ts index 6ab96bf0e..0191e620d 100644 --- a/e2e/source/question-terminal-origin.fixture.ts +++ b/e2e/source/question-terminal-origin.fixture.ts @@ -38,7 +38,7 @@ function SuggestionFixture({ legacy = false }) { }; window.terminalOriginFixture.acknowledgeOption = () => { if (!pending.current) throw new Error("No pending option to acknowledge"); - pending.current.resolve({ ok: true }); + pending.current.resolve({ ok: true, option: "native-added" }); pending.current = undefined; }; return createElement(QuestionView, { definition, drafts: { q: draft }, suggested, diff --git a/e2e/source/question-terminal-origin.native.ts b/e2e/source/question-terminal-origin.native.ts index 780527d19..9c7c1f228 100644 --- a/e2e/source/question-terminal-origin.native.ts +++ b/e2e/source/question-terminal-origin.native.ts @@ -122,9 +122,11 @@ test("a real human edit removes from chat until the suggestion lifecycle is clea await expect(page.getByText("Adding option", { exact: true })).toHaveCount(0); await expect(field).toHaveCount(0); await expect(page.getByRole("button", { name: "Add an option", exact: true })).toBeVisible(); - await expect(badge).toHaveCount(0); - await expect(save).toBeDisabled(); - await option.check(); + // The acknowledged option is chosen in the same step, so Save is ready. + await expect(option).toBeChecked(); + await expect(option).toBeFocused(); + await expect(page.getByRole("status").filter({ hasText: "Added and selected Another approach" })) + .toBeAttached(); await expect(badge).toHaveCount(0); await expect(save).toBeEnabled(); expect(await page.evaluate(() => window.terminalOriginFixture.snapshot())).toMatchObject({ diff --git a/packages/editor/src/card.tsx b/packages/editor/src/card.tsx index 2ce1d1b07..12e213923 100644 --- a/packages/editor/src/card.tsx +++ b/packages/editor/src/card.tsx @@ -52,7 +52,7 @@ export function SidecarCard( return (
expect(markup).not.toContain("Add an option"); }); + +test("a just-added option becomes this person's choice without taking a collaborator's", () => { + let single = AUTH.questions[0]!; + let other = single.options[0]!.id; + let theirs = { mode: "choices" as const, choice: other, options: {}, custom: "" }; + expect(chooseAdded(single, undefined, "new", false)).toEqual({ mode: "choices", choice: "new" }); + expect(chooseAdded(single, theirs, "new", true)).toEqual({ mode: "choices", choice: "new" }); + expect(chooseAdded(single, theirs, "new", false)).toBeUndefined(); + + let multiple = { ...single, multiple: true }; + let held = { mode: "choices" as const, choice: null, options: { [other]: true }, custom: "" }; + expect(chooseAdded(multiple, held, "new", false)) + .toEqual({ mode: "choices", options: { [other]: true, new: true } }); + expect(chooseAdded(multiple, undefined, "new", false)) + .toEqual({ mode: "choices", options: { new: true } }); +}); + +test("a pasted line break in a new option becomes a space", () => { + expect(optionText("Keep it\nsimple")).toBe("Keep it simple"); + expect(optionText("One \r\n two\n\nthree")).toBe("One two three"); + expect(optionText(" spaced ")).toBe(" spaced "); +}); + +test("option rows carry their id, so a just-added option can take focus", () => { + let markup = renderToStaticMarkup(createElement(QuestionView, { + definition: AUTH, + drafts: {}, + onAddOption: async () => ({ ok: true as const, option: "new" }), + })); + expect(markup).toContain(`value="${AUTH.questions[0]!.options[0]!.id}"`); +}); diff --git a/packages/question/src/react/question-view.tsx b/packages/question/src/react/question-view.tsx index 3dd2cc4be..091fa21ee 100644 --- a/packages/question/src/react/question-view.tsx +++ b/packages/question/src/react/question-view.tsx @@ -9,7 +9,7 @@ * as they arrive rather than tracking local state. */ -import { useEffect, useId, useReducer, useRef, useState } from "react"; +import { useEffect, useId, useLayoutEffect, useReducer, useRef, useState } from "react"; import { CheckIcon, ChevronIcon, DecisionIcon, PlusIcon, WarningIcon } from "@chopin/icons"; import { INPUT_EXPIRY_MS, MAX_LABEL, MAX_SHARED_OPTIONS } from "../limits"; @@ -34,7 +34,8 @@ export type Collaborator = { question?: string; }; -export type AddOptionResult = { ok: true } | { ok: false; message: string }; +/** `option` is the id the server gave the new option, so the view can select it. */ +export type AddOptionResult = { ok: true; option?: string } | { ok: false; message: string }; export type QuestionStepRenderProps = { children: ReactNode; @@ -219,6 +220,7 @@ function Choices( { @@ -293,20 +295,45 @@ export function duplicateOf(question: Item, label: string) { return question.options.find(option => option.label.trim().toLowerCase() === key); } +/** Option labels are one line in the document, so a pasted line break becomes a space. */ +export function optionText(typed: string): string { + return typed.replace(/\s*[\r\n]+\s*/g, " "); +} + +/** + * The draft change that chooses a just-added option, as if its row had been + * clicked. Undefined when that would replace a collaborator's single choice in + * the shared draft: adding is for everyone, but only the adder's own pick moves. + */ +export function chooseAdded( + question: Item, + draft: Draft | undefined, + option: string, + own: boolean, +): Partial | undefined { + if (question.multiple) { + return { mode: "choices", options: { ...draft?.options, [option]: true } }; + } + if (!own && answered(question, draft)) return undefined; + return { mode: "choices", choice: option }; +} + /** * The last row: a prompt to add an option, which becomes the field for it. * - * Enter adds it for everyone, Escape closes the field. While confirmation is - * pending the field stays focusable so Escape still works; a rejection keeps - * the text intact. + * The leading tile turns into the confirm button once there is text. Enter or + * the tile adds the option for everyone; once the server has made it durable + * the host selects it for this person. Escape closes the field. While + * confirmation is pending the field stays focusable so Escape still works; a + * rejection keeps the text intact. */ function AddOption( - { question, offset, disabled, onAdd, onFailed, onEdit, onCancelEdit, onCommitEdit }: { + { question, disabled, onAdd, onAdded, onFailed, onEdit, onCancelEdit, onCommitEdit }: { question: Item; - /** Rows already shown below the options, such as a legacy custom answer. */ - offset: number; disabled: boolean; onAdd?: (label: string) => Promise; + /** The server added the option; `focus` when this field still held focus. */ + onAdded: (option: string, label: string, focus: boolean) => void; onFailed: (message: string | undefined) => void; onEdit?: () => void; onCancelEdit?: () => void; @@ -316,15 +343,14 @@ function AddOption( let [text, setText] = useState(null); let [pending, setPending] = useState(null); let hintId = useId(); - let input = useRef(null); + let group = useRef(null); + let input = useRef(null); let trigger = useRef(null); let focus = useRef<"field" | "trigger">(undefined); let edit = useRef(0); - let letterIndex = question.options.length + offset; - let duplicate = text ? duplicateOf(question, text.trim()) : undefined; - useEffect(() => () => { - edit.current++; - }, []); + let label = text?.trim() ?? ""; + let duplicate = label ? duplicateOf(question, label) : undefined; + let ready = !!label && !duplicate && !disabled; useEffect(() => { let target = focus.current; @@ -333,6 +359,14 @@ function AddOption( else if (target === "trigger") trigger.current?.focus(); }); + // Browsers without `field-sizing` would otherwise scroll a long option inside one line. + useLayoutEffect(() => { + let field = input.current; + if (!field || CSS.supports("field-sizing", "content")) return; + field.style.blockSize = "auto"; + field.style.blockSize = `${field.scrollHeight}px`; + }, [text]); + useEffect(() => { let viewport = window.visualViewport; if (!viewport) return; @@ -356,7 +390,6 @@ function AddOption( }, []); let add = async () => { - let label = text?.trim(); if (!label || !onAdd || pending !== null || duplicateOf(question, label)) return; let submittedEdit = edit.current; setPending(label); @@ -368,12 +401,18 @@ function AddOption( result = { ok: false, message: "Could not add this option." }; } setPending(null); + // Escape or blur abandoned the field. Reaching the option limit unmounts it + // without abandoning anything, so that case still selects. if (edit.current !== submittedEdit) return; - let ownsFocus = document.activeElement === input.current; + let field = group.current; + let ownsFocus = field + ? field.contains(document.activeElement) + : document.activeElement === document.body; if (result.ok) { onCommitEdit?.(); setText(null); - if (ownsFocus) focus.current = "trigger"; + if (result.option) onAdded(result.option, label, ownsFocus); + else if (ownsFocus) focus.current = "trigger"; } else { onFailed(result.message); if (ownsFocus) focus.current = "field"; @@ -403,13 +442,26 @@ function AddOption( } return ( -
+
- {letter(letterIndex)} - + + +