Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
19 changes: 18 additions & 1 deletion src/client/PageDocument.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,14 @@ export function PageDocument({
const [move, setMove] = useState(false);
const [notice, setNotice] = useState('');
const open = useRef(true);
const titleRef = useRef<HTMLTextAreaElement | null>(null);
// The title wraps instead of scrolling sideways, so the field grows to fit every line.
useEffect(() => {
const el = titleRef.current;
if (!el) return;
el.style.height = 'auto';
el.style.height = `${el.scrollHeight}px`;
}, [draft.title]);
useEffect(() => {
open.current = true;
return () => {
Expand Down Expand Up @@ -303,15 +311,24 @@ export function PageDocument({
<button onClick={() => setMove(false)}>Done</button>
</div>
)}
<input
<textarea
ref={titleRef}
className="document-title"
aria-label="Page title"
placeholder="Untitled page"
rows={1}
maxLength={160}
value={draft.title}
onChange={(event) =>
controller.edit({ title: event.target.value })
}
onKeyDown={(event) => {
// A title is one paragraph: Enter leaves the field, Shift+Enter keeps a break.
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
event.currentTarget.blur();
}
}}
/>
{sourceMode ? (
<>
Expand Down
8 changes: 8 additions & 0 deletions src/client/editor.css
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,14 @@
margin-bottom: 24px;
outline: none;
border-radius: 0;
/* A textarea so long titles wrap; it is resized to its content, not by the reader. */
font-family: inherit;
display: block;
resize: none;
overflow: hidden;
white-space: pre-wrap;
overflow-wrap: break-word;
field-sizing: content;
}
.document-title::placeholder {
color: #747c70;
Expand Down
116 changes: 116 additions & 0 deletions tests/page-document-title.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { createElement } from 'react';
import { act, create } from 'react-test-renderer';
import { beforeEach, expect, it, vi } from 'vitest';
const api = vi.hoisted(() => vi.fn());
vi.mock('../src/client/api', () => ({ api }));
vi.mock('../src/client/editor/DocumentMenu', () => ({
DocumentMenu: () => null,
}));
vi.mock('../src/client/editor/RichEditor', () => ({ default: () => null }));
vi.mock('../src/client/PageConversation', () => ({
PageConversation: () => null,
}));
import { PageDocument } from '../src/client/PageDocument';

const page = {
id: 'a',
spaceId: 'space',
parentId: null,
title: 'A',
content: 'text',
revision: 1,
createdAt: 0,
updatedAt: 0,
sourceThreadId: null,
};
const props = () => ({
page,
pages: [page],
workspace: {} as never,
paused: false,
onHome: vi.fn(),
onOutline: vi.fn(),
onSubpage: vi.fn(),
onDirty: vi.fn(),
onSaved: vi.fn(),
onRefresh: vi.fn(),
onDeleted: vi.fn(),
onSchedule: vi.fn(),
onThread: vi.fn(),
onSettings: vi.fn(),
onCreateDot: vi.fn(),
});

beforeEach(() => {
api.mockReset();
api.mockResolvedValue({ ok: true });
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
vi.stubGlobal('window', {
confirm: () => true,
addEventListener: () => {},
removeEventListener: () => {},
});
});

// A long title used to be a single-line <input>, so it was clipped at the right edge
// instead of wrapping. It is a wrapping textarea now (#68).
it('renders the page title as a wrapping textarea that grows to its content', async () => {
let tree: ReturnType<typeof create> | undefined;
await act(async () => {
tree = create(createElement(PageDocument, props()));
});
const titles = tree!.root.findAllByType('textarea' as never);
const title = titles.find((t) => t.props['aria-label'] === 'Page title');
expect(title).toBeDefined();
// rows=1 with height driven from content: one line until the text wraps.
expect(title!.props.rows).toBe(1);
expect(title!.props.maxLength).toBe(160);
expect(
tree!.root
.findAllByType('input' as never)
.some((i) => i.props['aria-label'] === 'Page title'),
).toBe(false);
});

it('leaves the title field on Enter without inserting a newline, and keeps it on Shift+Enter', async () => {
let tree: ReturnType<typeof create> | undefined;
await act(async () => {
tree = create(createElement(PageDocument, props()));
});
const title = tree!.root
.findAllByType('textarea' as never)
.find((t) => t.props['aria-label'] === 'Page title')!;
const onKeyDown = title.props.onKeyDown as (e: unknown) => void;

let prevented = false;
let blurred = false;
onKeyDown({
key: 'Enter',
shiftKey: false,
preventDefault: () => {
prevented = true;
},
currentTarget: {
blur: () => {
blurred = true;
},
},
});
expect(prevented).toBe(true);
expect(blurred).toBe(true);

prevented = false;
onKeyDown({
key: 'Enter',
shiftKey: true,
preventDefault: () => {
prevented = true;
},
currentTarget: {
blur: () => {
blurred = true;
},
},
});
expect(prevented).toBe(false);
});