Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
d61a7c2
fix: scroll a question opened by link into view
PhilReinking Oct 12, 2026
3943fa4
feat: add an outline to jump to a question
PhilReinking Oct 12, 2026
1c57ee6
feat: open a question's settings as a bottom sheet on phones
PhilReinking Oct 12, 2026
91186a3
fix: make the editor's controls 44 px on phones
PhilReinking Oct 12, 2026
8644f3d
fix: keep the top of the document in view on load
PhilReinking Oct 12, 2026
9e58fb5
Merge branch 'feat/bits-282-document-editor' into feat/bits-282b-outl…
PhilReinking Oct 12, 2026
1949324
fix: hide the group grip on phones, like the question grip
PhilReinking Oct 12, 2026
ffcd480
Merge branch 'feat/bits-282-document-editor' into feat/bits-282b-outl…
PhilReinking Oct 12, 2026
c0d4a20
fix: keep 44 px targets up to 1024 px, and 44 px menu items
PhilReinking Oct 12, 2026
6927dc7
Merge branch 'feat/bits-282-document-editor' into feat/bits-282b-outl…
PhilReinking Oct 12, 2026
2c53510
fix: 44 px fields with 16 px text in the question card below 1024 px
PhilReinking Oct 13, 2026
a9368ce
fix: show a blue bar while the question text has focus
PhilReinking Oct 13, 2026
d7d794f
fix: add a policy with a dashed row, like Add choice
PhilReinking Oct 13, 2026
b516020
fix: size the app to the visible screen on iPhone Safari (100dvh)
PhilReinking Oct 13, 2026
bc69718
fix: keep the Outline button 44 px below 1024 px
PhilReinking Oct 13, 2026
ea36e24
style: re-indent FormDocument's template (Prettier)
PhilReinking Oct 13, 2026
62c5024
style: wrap a long line in DocumentQuestion (Prettier)
PhilReinking Oct 13, 2026
3945f4c
docs: add the outline and phone Settings to the changelog line (#272)
PhilReinking Oct 13, 2026
ecc9d3a
fix: 44 px outline rows and Close below 1024, drop the sheet's drag h…
PhilReinking Oct 13, 2026
711992e
fix: give focus back to Settings when the phone sheet closes
PhilReinking Oct 13, 2026
68235f3
Merge branch 'feat/bits-282-document-editor' into feat/bits-282b-outl…
PhilReinking Oct 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ Lines marked **Upgrade step** need you to act after updating.
- The form list loads faster when a team has many forms. It now needs the same few database queries for one form or a hundred. Before, every form added 3 queries. (#256)
- On screens narrower than 1280 px, a form's editor bar now sits in two rows under the app header: the form with Dashboard and Preview, then Editor, Settings, Integrations and Submissions as tabs. Nothing in it scrolls sideways any more, and the header keeps the logo and your team name. On every screen, keyboard focus in the editor bar and the account menu now shows a blue ring. (#257)
- A form's pages have one top bar instead of two: the form's name with its menu, a Draft or Live label, the pages, Preview, Publish and your account. Publish first checks for questions without text, rules that point to a deleted question and a missing privacy link. Once the form is live, Share has its link, Open form and Unpublish. On a phone the pages sit in a tab bar at the bottom. (#265)
- The form editor shows your whole form as one column. Each question shows its number, answer type, text, and if it is required, has rules or is hidden. The question you work on opens right there, also on a phone, where you couldn't edit questions before. Hiding a question, random order and custom answers are now under the question's Settings. (#268)
- The form editor shows your whole form as one column. Each question shows its number, answer type, text, and if it is required, has rules or is hidden. The question you work on opens right there, also on a phone, where you couldn't edit questions before. Hiding a question, random order and custom answers are now under the question's Settings. An outline next to the form jumps to any question, and on a phone a question's Settings open from the bottom of the screen. (#268, #272)
- Deleting a question, a group, an answer choice, a consent policy or a logic rule in the form editor now asks first in the app's own dialog. It names the question, how many answers will be deleted with it, and the questions whose rules will stop working. A choice nobody picked, or a policy nobody agreed to, is still deleted at once. (#269)

### Fixed
Expand Down
33 changes: 33 additions & 0 deletions resources/css/_touch-fields.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/* The form editor's question card below lg: every field 44 px tall with
16 px text (iOS zooms into a focused field under 16 px), and a 44 px tap
area on switches and checkboxes. One rule here instead of a class on
every D9 field in the Configure* parts. */
@media (max-width: 1023px) {
.touch-fields
:is(
input:not([type="checkbox"], [type="radio"]),
textarea,
button[aria-haspopup="listbox"]
) {
min-height: 2.75rem;
font-size: 1rem;
}

.touch-fields [role="option"] {
padding-block: 0.625rem;
}

/* D9Switch is relative and 24 px tall */
.touch-fields [role="switch"]::before {
content: "";
position: absolute;
inset: -10px 0;
}

/* D9Checkbox: its label covers the whole 20 px row, stretched to 44 px */
.touch-fields input[type="checkbox"] + div > label::before {
content: "";
position: absolute;
inset: -12px 0;
}
}
1 change: 1 addition & 0 deletions resources/css/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
@import "./_tooltip.css";
@import "./_typography.css";
@import "./_tiptap.css";
@import "./_touch-fields.css";

@import "tailwindcss/utilities";

Expand Down
4 changes: 3 additions & 1 deletion resources/js/Layouts/AppLayout.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@

<jet-banner />

<div class="flex min-h-screen flex-col bg-grey-50">
<div
class="flex min-h-screen flex-col bg-grey-50 supports-[height:100dvh]:min-h-dvh"
>
<FormTopBar v-if="form.formId">
<template #save-status><slot name="save-status" /></template>
<template #preview><slot name="preview" /></template>
Expand Down
4 changes: 2 additions & 2 deletions resources/js/components/Factory/Document/BlockMenu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
>
<template #button>
<span
class="grid h-9 w-9 place-items-center rounded-md border border-grey-300 bg-white text-grey-700 hover:bg-grey-100"
class="grid h-11 w-11 place-items-center rounded-md border border-grey-300 lg:h-9 lg:w-9 bg-white text-grey-700 hover:bg-grey-100"
>
<D9Icon name="ellipsis-h" />
<span class="sr-only">{{ label }}</span>
Expand Down Expand Up @@ -67,7 +67,7 @@ const store = useForm();
const workbench = useWorkbench();
const { addBlock } = useAddBlock();

const item = "w-full text-left text-sm";
const item = "w-full min-h-11 text-left text-sm lg:min-h-0";
const isGroup = computed(() => props.block.type === "group");

// the open block saves through the workbench: a store save swaps its object, and its next save would send the old value
Expand Down
11 changes: 6 additions & 5 deletions resources/js/components/Factory/Document/DocumentGroup.vue
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
<template>
<section
:id="`row-${group.uuid}`"
:aria-labelledby="`${id}-label`"
class="rounded-lg border border-grey-200 bg-grey-50 py-2 pr-2"
class="scroll-mt-4 rounded-lg border border-grey-200 bg-grey-50 py-2 pr-2"
:class="isOpen && 'ring-1 ring-blue-500'"
>
<div
class="group/header flex flex-wrap items-center gap-x-2 gap-y-1 pb-2 pl-1"
class="group/header flex flex-wrap items-center gap-x-2 gap-y-1 pb-2 pl-3 sm:pl-1"
>
<span
aria-hidden="true"
title="Drag to reorder"
class="document-grip grid h-6 w-6 cursor-grab place-items-center rounded text-grey-500 hover:bg-grey-100 hover:text-grey-900 lg:opacity-0 lg:group-focus-within/header:opacity-100 lg:group-hover/header:opacity-100"
class="document-grip hidden h-6 w-6 cursor-grab place-items-center rounded text-grey-500 hover:bg-grey-100 hover:text-grey-900 sm:grid lg:opacity-0 lg:group-focus-within/header:opacity-100 lg:group-hover/header:opacity-100"
>
<D9Icon name="grip-vertical" />
</span>
Expand All @@ -24,12 +25,12 @@
type="text"
placeholder="Group"
title="Use a group to show or hide several questions with one rule."
class="h-9 min-w-0 flex-1 rounded-md border-0 bg-transparent px-2 text-sm font-semibold text-grey-700 placeholder:text-grey-600 hover:bg-grey-100 focus:bg-white focus:ring-2 focus:ring-blue-500"
class="h-11 min-w-0 flex-1 rounded-md border-0 bg-transparent px-2 text-base font-semibold lg:h-9 lg:text-sm text-grey-700 placeholder:text-grey-600 hover:bg-grey-100 focus:bg-white focus:ring-2 focus:ring-blue-500"
/>
<span v-if="group.is_disabled" class="text-xs text-grey-600">Hidden</span>
<button
type="button"
class="flex h-9 items-center rounded-md px-2 text-sm font-semibold text-blue-700 hover:bg-blue-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
class="flex h-11 items-center rounded-md px-2 text-sm font-semibold text-blue-700 lg:h-9 hover:bg-blue-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
@click="logic.showLogicEditor(group)"
>
Rules<span v-if="group.logics?.length" class="tabular-nums"
Expand Down
44 changes: 44 additions & 0 deletions resources/js/components/Factory/Document/DocumentOutline.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { mount } from "@vue/test-utils";
import { beforeEach, describe, expect, it } from "vitest";
import { useForm, useWorkbench } from "@/stores";
import DocumentOutline from "./DocumentOutline.vue";

const blocks = [
{ id: 1, uuid: "q1", type: "none", message: "<p>Welcome</p>" },
{ id: 2, uuid: "g1", type: "group", title: "Food" },
{ id: 3, uuid: "q2", type: "radio", message: "", parent_block: "g1" },
{ id: 4, uuid: "q3", type: "input-short", message: "<p>Name?</p>" },
] as FormBlockModel[];

const numbers = { q1: 1, q2: 2, q3: 3 };

describe("the outline", () => {
beforeEach(() => {
useForm().$reset();
useWorkbench().$reset();
useForm().blocks = blocks;
});

it("lists questions in document order under their group, never by id", () => {
const wrapper = mount(DocumentOutline, { props: { numbers } });
const lines = wrapper.findAll("li").map((li) => li.text());

expect(lines).toEqual([
"1Welcome",
"Food",
"2No question text yet",
"3Name?",
]);
});

it("marks the open question and says which one was picked", async () => {
useWorkbench().block = blocks[3];
const wrapper = mount(DocumentOutline, { props: { numbers } });
const current = wrapper.find("[aria-current=true]");

expect(current.text()).toBe("3Name?");

await wrapper.findAll("button")[0].trigger("click");
expect(wrapper.emitted("pick")?.[0]).toEqual([blocks[0]]);
});
});
78 changes: 78 additions & 0 deletions resources/js/components/Factory/Document/DocumentOutline.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
<template>
<nav :aria-labelledby="`${id}-title`" class="px-2 py-3">
<div class="flex h-11 items-center justify-between pl-2 lg:h-8">
<h2 :id="`${id}-title`" class="text-xs font-semibold text-grey-600">
Outline
</h2>
<slot name="close" />
</div>
<ul class="mt-1 flex flex-col gap-px">
<li v-for="item in items" :key="item.block.uuid">
<div
v-if="item.block.type === 'group'"
class="mt-1.5 flex h-7 items-center gap-1.5 px-2 text-xs font-semibold text-grey-700"
>
<D9Icon name="layer-group" aria-hidden="true" />
<span class="truncate">{{ item.block.title || "Group" }}</span>
</div>
<button
v-else
type="button"
class="flex h-11 w-full items-center gap-2 rounded-md pr-2 lg:h-8 text-left text-[13px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
:class="[
item.inGroup ? 'pl-5' : 'pl-2',
isCurrent(item.block)
? 'bg-blue-50 font-medium text-blue-700 hover:bg-blue-100'
: 'text-grey-800 hover:bg-grey-100',
]"
:aria-current="isCurrent(item.block) ? 'true' : undefined"
@click="emit('pick', item.block)"
>
<span
class="w-5 shrink-0 text-right tabular-nums"
:class="isCurrent(item.block) ? 'text-blue-700' : 'text-grey-600'"
>{{ numbers[item.block.uuid] }}</span
>
<span dir="auto" class="truncate">{{
plainText(item.block.message) || "No question text yet"
}}</span>
</button>
</li>
</ul>
</nav>
</template>

<script setup lang="ts">
import { computed, useId } from "vue";
import { D9Icon } from "@deck9/ui";
import { useForm, useWorkbench } from "@/stores";
import { plainText } from "./questionSummary";

defineProps<{
numbers: Record<string, number>;
}>();
const emit = defineEmits<{ pick: [block: FormBlockModel] }>();

const store = useForm();
const workbench = useWorkbench();
const id = useId();

// the document's order: each group, then its questions
const items = computed(() => {
const blocks = store.blocks ?? [];

return blocks
.filter((block) => !block.parent_block)
.flatMap((block) => [
{ block, inGroup: false },
...(block.type === "group"
? blocks
.filter((child) => child.parent_block === block.uuid)
.map((child) => ({ block: child, inGroup: true }))
: []),
]);
});

const isCurrent = (block: FormBlockModel) =>
workbench.block?.uuid === block.uuid;
</script>
9 changes: 6 additions & 3 deletions resources/js/components/Factory/Document/DocumentQuestion.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
<template>
<div :id="`row-${block.uuid}`" class="group/question flex items-start">
<div class="flex w-12 shrink-0 flex-col items-end gap-1 pr-2 pt-3">
<div
:id="`row-${block.uuid}`"
class="group/question flex scroll-mt-4 items-start"
>
<div class="flex w-8 shrink-0 flex-col sm:w-12 items-end gap-1 pr-2 pt-3">
<span
aria-hidden="true"
class="flex h-6 items-center text-[13px] tabular-nums text-grey-600"
Expand All @@ -9,7 +12,7 @@
<span
aria-hidden="true"
title="Drag to reorder"
class="document-grip grid h-6 w-6 cursor-grab place-items-center rounded text-grey-500 hover:bg-grey-100 hover:text-grey-900 lg:opacity-0 lg:group-focus-within/question:opacity-100 lg:group-hover/question:opacity-100"
class="document-grip hidden h-6 w-6 cursor-grab sm:grid place-items-center rounded text-grey-500 hover:bg-grey-100 hover:text-grey-900 lg:opacity-0 lg:group-focus-within/question:opacity-100 lg:group-hover/question:opacity-100"
>
<D9Icon name="grip-vertical" />
</span>
Expand Down
66 changes: 66 additions & 0 deletions resources/js/components/Factory/Document/FormDocument.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { flushPromises, mount, VueWrapper } from "@vue/test-utils";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useForm, useWorkbench } from "@/stores";
import FormDocument from "./FormDocument.vue";

const blocks = [
{ id: 1, uuid: "b1", type: "none", message: "<p>Welcome</p>" },
{ id: 2, uuid: "b2", type: "none", message: "<p>Second</p>" },
] as FormBlockModel[];

const stubs = {
DocumentList: {
template: `<div><div id="row-b1" /><div id="row-b2" /></div>`,
},
BlockLogicEditor: true,
};

let wrapper: VueWrapper | undefined;
const scrolled = vi.fn();

describe("the form document", () => {
beforeEach(() => {
Element.prototype.scrollIntoView = function (
this: Element,
options?: boolean | ScrollIntoViewOptions,
) {
scrolled(this.id, (options as ScrollIntoViewOptions)?.block);
};
scrolled.mockClear();
useForm().$reset();
useWorkbench().$reset();
});

afterEach(() => wrapper?.unmount());

it("scrolls the question opened by link into view once it loads", async () => {
wrapper = mount(FormDocument, {
props: { status: "loading" },
global: { stubs },
attachTo: document.body,
});

useForm().blocks = blocks;
useWorkbench().block = blocks[1];
await wrapper.setProps({ status: "loaded" });
await wrapper.vm.$nextTick();

// the smallest scroll, so a question already in view stays put
expect(scrolled).toHaveBeenCalledWith("row-b2", "nearest");
});

it("opens and scrolls to a question picked in the outline", async () => {
useForm().blocks = blocks;
wrapper = mount(FormDocument, {
props: { status: "loaded" },
global: { stubs },
attachTo: document.body,
});

await wrapper.findAll("nav li button")[1].trigger("click");
await flushPromises();

expect(useWorkbench().putOnWorkbench).toHaveBeenCalledWith(blocks[1]);
expect(scrolled).toHaveBeenCalledWith("row-b2", "start");
});
});
Loading