@estiva-app/ui 0.72.0 → 0.73.0-inline-text.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/InlineText.d.ts +56 -0
- package/dist/InlineText.d.ts.map +1 -0
- package/dist/editor.d.ts +1 -0
- package/dist/editor.d.ts.map +1 -1
- package/dist/editor.js +219 -112
- package/dist/editor.js.map +4 -4
- package/package.json +1 -1
- package/registry.json +97 -3
- package/src/InlineText.mdx +64 -0
- package/src/InlineText.stories.tsx +86 -0
- package/src/InlineText.test.tsx +187 -0
- package/src/InlineText.tsx +187 -0
- package/src/editor.ts +1 -0
- package/src/registry/registry.test.ts +4 -4
- package/stories/Choosing.mdx +1 -0
package/package.json
CHANGED
package/registry.json
CHANGED
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
"kind": "package",
|
|
5
5
|
"repo": "estiva-ui",
|
|
6
6
|
"package": "@estiva-app/ui",
|
|
7
|
-
"packageVersion": "0.
|
|
8
|
-
"exports":
|
|
9
|
-
"typeExports":
|
|
7
|
+
"packageVersion": "0.73.0-inline-text.1",
|
|
8
|
+
"exports": 178,
|
|
9
|
+
"typeExports": 164,
|
|
10
10
|
"files": null
|
|
11
11
|
},
|
|
12
12
|
"storybook": {
|
|
@@ -3445,6 +3445,100 @@
|
|
|
3445
3445
|
"app": null,
|
|
3446
3446
|
"looks": []
|
|
3447
3447
|
},
|
|
3448
|
+
{
|
|
3449
|
+
"name": "InlineText",
|
|
3450
|
+
"repo": "estiva-ui",
|
|
3451
|
+
"kind": "component",
|
|
3452
|
+
"importPath": "@estiva-app/ui/editor",
|
|
3453
|
+
"sourceFile": "src/InlineText.tsx",
|
|
3454
|
+
"purpose": "A page's title, or the line under it, edited where it stands, as in a document: the text is the editor, with no box and nothing moving.",
|
|
3455
|
+
"purposeFrom": "page",
|
|
3456
|
+
"props": [
|
|
3457
|
+
{
|
|
3458
|
+
"name": "value",
|
|
3459
|
+
"takes": "text",
|
|
3460
|
+
"required": true,
|
|
3461
|
+
"note": "The saved text. A new value is loaded when you are not in the field and hold nothing unsaved."
|
|
3462
|
+
},
|
|
3463
|
+
{
|
|
3464
|
+
"name": "placeholder",
|
|
3465
|
+
"takes": "text",
|
|
3466
|
+
"required": true,
|
|
3467
|
+
"note": "What it is for, muted, while it is empty: \"Untitled\", \"Add a summary\"."
|
|
3468
|
+
},
|
|
3469
|
+
{
|
|
3470
|
+
"name": "label",
|
|
3471
|
+
"takes": "text",
|
|
3472
|
+
"required": true,
|
|
3473
|
+
"note": "The accessible name: \"Title\", \"Summary\"."
|
|
3474
|
+
},
|
|
3475
|
+
{
|
|
3476
|
+
"name": "onCommit",
|
|
3477
|
+
"takes": "a handler",
|
|
3478
|
+
"required": true,
|
|
3479
|
+
"note": "Save it: resolve `true` when saved; `false` keeps your text in place."
|
|
3480
|
+
},
|
|
3481
|
+
{
|
|
3482
|
+
"name": "onText",
|
|
3483
|
+
"takes": "a handler",
|
|
3484
|
+
"required": false,
|
|
3485
|
+
"note": "The text as it is being written, on focus and every change; `null` when you leave."
|
|
3486
|
+
},
|
|
3487
|
+
{
|
|
3488
|
+
"name": "onEnter",
|
|
3489
|
+
"takes": "a handler",
|
|
3490
|
+
"required": false,
|
|
3491
|
+
"note": "Enter, after the save has started: where the caret goes next."
|
|
3492
|
+
},
|
|
3493
|
+
{
|
|
3494
|
+
"name": "variant",
|
|
3495
|
+
"takes": "title | summary",
|
|
3496
|
+
"required": false,
|
|
3497
|
+
"note": "`title`: the page's heading. `summary`: the line under it, quieter."
|
|
3498
|
+
},
|
|
3499
|
+
{
|
|
3500
|
+
"name": "clearable",
|
|
3501
|
+
"takes": "true/false",
|
|
3502
|
+
"required": false,
|
|
3503
|
+
"note": "Empty is a value (a summary). Otherwise leaving it empty brings the saved one back (a title)."
|
|
3504
|
+
},
|
|
3505
|
+
{
|
|
3506
|
+
"name": "readOnly",
|
|
3507
|
+
"takes": "true/false",
|
|
3508
|
+
"required": false,
|
|
3509
|
+
"note": "Text only, the same size and place: nobody can change it, or the editor is not ready."
|
|
3510
|
+
},
|
|
3511
|
+
{
|
|
3512
|
+
"name": "className",
|
|
3513
|
+
"takes": "text",
|
|
3514
|
+
"required": false,
|
|
3515
|
+
"note": "Placement only."
|
|
3516
|
+
},
|
|
3517
|
+
{
|
|
3518
|
+
"name": "ref",
|
|
3519
|
+
"takes": "Ref<InlineTextHandle>",
|
|
3520
|
+
"required": false,
|
|
3521
|
+
"note": null
|
|
3522
|
+
}
|
|
3523
|
+
],
|
|
3524
|
+
"variants": [
|
|
3525
|
+
{
|
|
3526
|
+
"prop": "variant",
|
|
3527
|
+
"values": [
|
|
3528
|
+
"title",
|
|
3529
|
+
"summary"
|
|
3530
|
+
]
|
|
3531
|
+
}
|
|
3532
|
+
],
|
|
3533
|
+
"ownsBehaviours": [],
|
|
3534
|
+
"status": "stable",
|
|
3535
|
+
"migrationStage": null,
|
|
3536
|
+
"docsId": "primitives-inlinetext--docs",
|
|
3537
|
+
"storyId": "primitives-inlinetext--default",
|
|
3538
|
+
"docPage": "src/InlineText.mdx",
|
|
3539
|
+
"app": null,
|
|
3540
|
+
"looks": []
|
|
3541
|
+
},
|
|
3448
3542
|
{
|
|
3449
3543
|
"name": "InputChip",
|
|
3450
3544
|
"repo": "estiva-ui",
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
|
|
2
|
+
import * as InlineTextStories from './InlineText.stories'
|
|
3
|
+
|
|
4
|
+
<Meta of={InlineTextStories} />
|
|
5
|
+
|
|
6
|
+
# InlineText
|
|
7
|
+
|
|
8
|
+
A page's title, or the line under it, edited where it stands, as in a document: the text is the editor, with no box and nothing moving.
|
|
9
|
+
|
|
10
|
+
<Canvas of={InlineTextStories.Default} />
|
|
11
|
+
|
|
12
|
+
## When
|
|
13
|
+
|
|
14
|
+
- **`variant="title"`**: a page's heading. Left empty, the saved one comes back.
|
|
15
|
+
- **`variant="summary"`** with **`clearable`**: the quieter line under it. Left empty, it is saved as nothing.
|
|
16
|
+
- `readOnly` for someone who cannot change it, or until the app can save: the same text in the same place.
|
|
17
|
+
|
|
18
|
+
<Canvas of={InlineTextStories.Empty} />
|
|
19
|
+
<Canvas of={InlineTextStories.Refused} />
|
|
20
|
+
|
|
21
|
+
## When not
|
|
22
|
+
|
|
23
|
+
- A field in a form, a dialog or a settings row → **TextInput** or **Textarea**, in a **Form**.
|
|
24
|
+
- Text you click to open as a field, in a list or a sidebar → **EditableText**.
|
|
25
|
+
- Several paragraphs, lists or links → the app's block editor, with **EditorBlockHandle** and **SelectionToolbar**.
|
|
26
|
+
|
|
27
|
+
## How
|
|
28
|
+
|
|
29
|
+
```tsx
|
|
30
|
+
import { InlineText, type InlineTextHandle } from '@estiva-app/ui/editor'
|
|
31
|
+
|
|
32
|
+
const summary = useRef<InlineTextHandle>(null)
|
|
33
|
+
|
|
34
|
+
<InlineText variant="title" label="Title" placeholder="Untitled" value={title} onCommit={rename} onEnter={() => summary.current?.focus()} />
|
|
35
|
+
<InlineText ref={summary} variant="summary" clearable label="Summary" placeholder="Add a summary" value={line} onCommit={summarize} onText={setCount} />
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
- **`onCommit` resolves `true` when saved.** `false` keeps your text where it is; say why under it (a `FieldLine` with `tone="error"`). A change made elsewhere is not loaded over unsaved text.
|
|
39
|
+
- **`onText`** gives the text as it is written, and `null` on leaving: for a count under it.
|
|
40
|
+
- **`onEnter`** says where the caret goes after Enter: the next line down. `ref.focus('start' | 'end')` puts it there.
|
|
41
|
+
- `label` is required: it is the editor's accessible name.
|
|
42
|
+
- Base UI has no part for an editable line of a document; the editor is Tiptap, the same kind of surface as the block editor below it.
|
|
43
|
+
|
|
44
|
+
## What it owns
|
|
45
|
+
|
|
46
|
+
It does these by itself. Write none of them around it.
|
|
47
|
+
|
|
48
|
+
| It does | So you never write |
|
|
49
|
+
|---|---|
|
|
50
|
+
| The size and colour of a title or a summary, the same at rest and while written | a class for the text, or for a field state |
|
|
51
|
+
| Line breaks and pasted lines become one line of plain text | a paste handler or a clean-up before saving |
|
|
52
|
+
| An unchanged or (for a title) empty value is not a write | a compare before saving |
|
|
53
|
+
| Your text stays after a refused save, and a change from elsewhere waits | a draft kept beside it |
|
|
54
|
+
| The placeholder while it is empty | an empty check |
|
|
55
|
+
|
|
56
|
+
## Keys
|
|
57
|
+
|
|
58
|
+
| Key | What it does |
|
|
59
|
+
|---|---|
|
|
60
|
+
| Enter | Saves, and calls `onEnter` |
|
|
61
|
+
| Escape | Stops editing and saves what was typed |
|
|
62
|
+
| Ctrl+Z / Ctrl+Shift+Z | Undo / redo |
|
|
63
|
+
|
|
64
|
+
<Controls />
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { useRef, useState } from 'react'
|
|
3
|
+
import { FieldLine } from './Field'
|
|
4
|
+
import { InlineText, type InlineTextHandle } from './InlineText'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A page's title and the line under it, edited where they stand. Click into
|
|
8
|
+
* either and type: no box, nothing moves. Enter saves and goes on down the
|
|
9
|
+
* page; Escape or clicking away saves too.
|
|
10
|
+
*/
|
|
11
|
+
const meta = {
|
|
12
|
+
title: 'Primitives/InlineText',
|
|
13
|
+
component: InlineText,
|
|
14
|
+
parameters: { layout: 'padded' },
|
|
15
|
+
decorators: [
|
|
16
|
+
(Story) => (
|
|
17
|
+
<div className="w-[560px]">
|
|
18
|
+
<Story />
|
|
19
|
+
</div>
|
|
20
|
+
),
|
|
21
|
+
],
|
|
22
|
+
} satisfies Meta<typeof InlineText>
|
|
23
|
+
|
|
24
|
+
export default meta
|
|
25
|
+
type Story = StoryObj
|
|
26
|
+
|
|
27
|
+
const LINE = 'A line of plain text under the title, saying in a sentence what this is about.'
|
|
28
|
+
const LONG = 'A line of plain text under the title that goes on for longer than a line should, so it wraps the way the text around it does and the reader sees all of it, without anything cut off at the edge or hidden behind a control.'
|
|
29
|
+
|
|
30
|
+
/** A title, the line under it, and the text below: Enter in the title goes to the line, Enter in the line goes to the text. */
|
|
31
|
+
function Page({ title = 'Item one', line = LINE, refuse = false, readOnly = false }: { title?: string; line?: string; refuse?: boolean; readOnly?: boolean }) {
|
|
32
|
+
const lineRef = useRef<InlineTextHandle>(null)
|
|
33
|
+
const textRef = useRef<HTMLParagraphElement>(null)
|
|
34
|
+
const [saved, setSaved] = useState({ title, line })
|
|
35
|
+
const [refused, setRefused] = useState(false)
|
|
36
|
+
return (
|
|
37
|
+
<div className="flex flex-col">
|
|
38
|
+
<InlineText
|
|
39
|
+
variant="title"
|
|
40
|
+
label="Title"
|
|
41
|
+
placeholder="Untitled"
|
|
42
|
+
value={saved.title}
|
|
43
|
+
readOnly={readOnly}
|
|
44
|
+
onCommit={(next) => (setSaved((s) => ({ ...s, title: next })), true)}
|
|
45
|
+
onEnter={() => lineRef.current?.focus('start')}
|
|
46
|
+
/>
|
|
47
|
+
<InlineText
|
|
48
|
+
ref={lineRef}
|
|
49
|
+
variant="summary"
|
|
50
|
+
label="Summary"
|
|
51
|
+
placeholder="Add a line"
|
|
52
|
+
clearable
|
|
53
|
+
value={saved.line}
|
|
54
|
+
readOnly={readOnly}
|
|
55
|
+
onText={(text) => text !== null && setRefused(false)}
|
|
56
|
+
onCommit={(next) => {
|
|
57
|
+
if (refuse) {
|
|
58
|
+
setRefused(true)
|
|
59
|
+
return false
|
|
60
|
+
}
|
|
61
|
+
setSaved((s) => ({ ...s, line: next }))
|
|
62
|
+
return true
|
|
63
|
+
}}
|
|
64
|
+
onEnter={() => textRef.current?.focus()}
|
|
65
|
+
/>
|
|
66
|
+
{refused && <FieldLine tone="error">Not saved: the change was refused. Your text is still here.</FieldLine>}
|
|
67
|
+
<p ref={textRef} tabIndex={-1} className="mt-4 text-body-1 text-text-primary outline-none">
|
|
68
|
+
The text of the page starts here. Nothing above it moves while you write in the title or the line under it.
|
|
69
|
+
</p>
|
|
70
|
+
</div>
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export const Default: Story = { render: () => <Page /> }
|
|
75
|
+
|
|
76
|
+
/** Nothing written yet: what each is for, muted, in its place. */
|
|
77
|
+
export const Empty: Story = { render: () => <Page title="" line="" /> }
|
|
78
|
+
|
|
79
|
+
/** A long line wraps like the text around it. */
|
|
80
|
+
export const Long: Story = { render: () => <Page line={LONG} /> }
|
|
81
|
+
|
|
82
|
+
/** The save is refused: the text stays where you wrote it, and the app says why under it. */
|
|
83
|
+
export const Refused: Story = { render: () => <Page refuse /> }
|
|
84
|
+
|
|
85
|
+
/** Someone who cannot change it: the same text, in the same place. */
|
|
86
|
+
export const ReadOnly: Story = { render: () => <Page readOnly /> }
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* What the InlineText page claims, pinned: the text is the editor, Enter saves
|
|
4
|
+
* and moves on, Escape and leaving save, nothing unchanged is written, a title
|
|
5
|
+
* left empty comes back and a summary left empty is cleared, a refused save
|
|
6
|
+
* keeps your text, a paste is one line, and read-only is text only.
|
|
7
|
+
*
|
|
8
|
+
* jsdom lays nothing out, so text goes in through the editor and the caret is
|
|
9
|
+
* put at the end by hand; the keys are real key presses.
|
|
10
|
+
*/
|
|
11
|
+
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
|
12
|
+
import { act, cleanup, render, screen, waitFor } from '@testing-library/react'
|
|
13
|
+
import userEvent from '@testing-library/user-event'
|
|
14
|
+
import type { Editor } from '@tiptap/core'
|
|
15
|
+
import { InlineText, type InlineTextProps } from './InlineText'
|
|
16
|
+
|
|
17
|
+
// jsdom draws nothing, so it has no rects; the editor asks for them when it scrolls to the caret.
|
|
18
|
+
beforeAll(() => {
|
|
19
|
+
Element.prototype.scrollIntoView = vi.fn()
|
|
20
|
+
Element.prototype.getClientRects = () => [] as unknown as DOMRectList
|
|
21
|
+
Range.prototype.getClientRects = () => [] as unknown as DOMRectList
|
|
22
|
+
Range.prototype.getBoundingClientRect = () => new DOMRect()
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
afterEach(cleanup)
|
|
26
|
+
|
|
27
|
+
const field = (name = 'Title') => screen.getByRole('textbox', { name })
|
|
28
|
+
const editorOf = (box: HTMLElement) => (box as HTMLElement & { editor: Editor }).editor
|
|
29
|
+
|
|
30
|
+
function draw(props: Partial<InlineTextProps> = {}) {
|
|
31
|
+
const onCommit = props.onCommit ?? vi.fn(() => true)
|
|
32
|
+
const base = { value: 'Item one', placeholder: 'Untitled', label: 'Title' }
|
|
33
|
+
const view = render(<InlineText {...base} {...props} onCommit={onCommit} />)
|
|
34
|
+
return { onCommit, rerender: (next: Partial<InlineTextProps>) => view.rerender(<InlineText {...base} {...props} {...next} onCommit={onCommit} />) }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Click into it: focus, with the caret at the end. */
|
|
38
|
+
async function enter(name?: string) {
|
|
39
|
+
const box = field(name)
|
|
40
|
+
await act(async () => {
|
|
41
|
+
box.focus()
|
|
42
|
+
editorOf(box).commands.setTextSelection(editorOf(box).state.doc.content.size - 1)
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Type at the caret. */
|
|
47
|
+
async function type(text: string, name?: string) {
|
|
48
|
+
await act(async () => {
|
|
49
|
+
editorOf(field(name)).commands.insertContent(text)
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Delete everything in it. */
|
|
54
|
+
async function clear(name?: string) {
|
|
55
|
+
await act(async () => {
|
|
56
|
+
editorOf(field(name)).commands.clearContent(true)
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
describe('InlineText: the text is the editor', () => {
|
|
61
|
+
it('is an editable text box named for what it edits, holding the saved text, with no button to open it', () => {
|
|
62
|
+
draw()
|
|
63
|
+
expect(field().getAttribute('contenteditable')).toBe('true')
|
|
64
|
+
expect(field().textContent).toBe('Item one')
|
|
65
|
+
expect(screen.queryByRole('button')).toBeNull()
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('shows the placeholder while empty, and not once there is text', async () => {
|
|
69
|
+
draw({ value: '', clearable: true })
|
|
70
|
+
expect(screen.queryByText('Untitled')).not.toBeNull()
|
|
71
|
+
await enter()
|
|
72
|
+
await type('A')
|
|
73
|
+
expect(screen.queryByText('Untitled')).toBeNull()
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('read-only is the text alone: not editable and no placeholder', () => {
|
|
77
|
+
draw({ value: '', readOnly: true })
|
|
78
|
+
expect(field().getAttribute('contenteditable')).toBe('false')
|
|
79
|
+
expect(screen.queryByText('Untitled')).toBeNull()
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
describe('InlineText: keys and saving', () => {
|
|
84
|
+
it('Enter saves the text and moves on', async () => {
|
|
85
|
+
const onEnter = vi.fn()
|
|
86
|
+
const { onCommit } = draw({ onEnter })
|
|
87
|
+
await enter()
|
|
88
|
+
await type(' two')
|
|
89
|
+
await userEvent.keyboard('{Enter}')
|
|
90
|
+
expect(onCommit).toHaveBeenCalledWith('Item one two')
|
|
91
|
+
expect(onEnter).toHaveBeenCalledTimes(1)
|
|
92
|
+
expect(field().textContent).toBe('Item one two')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('Escape stops editing and saves what was typed', async () => {
|
|
96
|
+
const { onCommit } = draw()
|
|
97
|
+
await enter()
|
|
98
|
+
await type(' two')
|
|
99
|
+
await userEvent.keyboard('{Escape}')
|
|
100
|
+
expect(document.activeElement).not.toBe(field())
|
|
101
|
+
expect(onCommit).toHaveBeenCalledWith('Item one two')
|
|
102
|
+
expect(field().textContent).toBe('Item one two')
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('leaving saves it', async () => {
|
|
106
|
+
const { onCommit } = draw()
|
|
107
|
+
await enter()
|
|
108
|
+
await type(' two')
|
|
109
|
+
await act(async () => field().blur())
|
|
110
|
+
expect(onCommit).toHaveBeenCalledWith('Item one two')
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('an unchanged text is not a write', async () => {
|
|
114
|
+
const { onCommit } = draw()
|
|
115
|
+
await enter()
|
|
116
|
+
await userEvent.keyboard('{Enter}')
|
|
117
|
+
await userEvent.keyboard('{Escape}')
|
|
118
|
+
expect(onCommit).not.toHaveBeenCalled()
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('a title left empty brings the saved one back, and is not a write', async () => {
|
|
122
|
+
const { onCommit } = draw()
|
|
123
|
+
await enter()
|
|
124
|
+
await clear()
|
|
125
|
+
await userEvent.keyboard('{Escape}')
|
|
126
|
+
expect(onCommit).not.toHaveBeenCalled()
|
|
127
|
+
expect(field().textContent).toBe('Item one')
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('a clearable one left empty is saved as nothing', async () => {
|
|
131
|
+
const { onCommit } = draw({ clearable: true, label: 'Summary' })
|
|
132
|
+
await enter('Summary')
|
|
133
|
+
await clear('Summary')
|
|
134
|
+
await userEvent.keyboard('{Escape}')
|
|
135
|
+
expect(onCommit).toHaveBeenCalledWith('')
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('a paste of several lines is one line of plain text', async () => {
|
|
139
|
+
const { onCommit } = draw({ value: '', clearable: true })
|
|
140
|
+
await enter()
|
|
141
|
+
await userEvent.paste('Line one\n\nline two\n')
|
|
142
|
+
await userEvent.keyboard('{Enter}')
|
|
143
|
+
expect(onCommit).toHaveBeenCalledWith('Line one line two')
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('reports the text being written, and null on leaving', async () => {
|
|
147
|
+
const onText = vi.fn()
|
|
148
|
+
draw({ onText })
|
|
149
|
+
await enter()
|
|
150
|
+
expect(onText).toHaveBeenLastCalledWith('Item one')
|
|
151
|
+
await type('!')
|
|
152
|
+
expect(onText).toHaveBeenLastCalledWith('Item one!')
|
|
153
|
+
await userEvent.keyboard('{Escape}')
|
|
154
|
+
expect(onText).toHaveBeenLastCalledWith(null)
|
|
155
|
+
})
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
describe('InlineText: a refused save, and a change from elsewhere', () => {
|
|
159
|
+
it('a refused save keeps your text where it is, and a change from elsewhere does not overwrite it', async () => {
|
|
160
|
+
const onCommit = vi.fn(() => false)
|
|
161
|
+
const { rerender } = draw({ onCommit })
|
|
162
|
+
await enter()
|
|
163
|
+
await type(' two')
|
|
164
|
+
await userEvent.keyboard('{Escape}')
|
|
165
|
+
expect(onCommit).toHaveBeenCalledWith('Item one two')
|
|
166
|
+
expect(field().textContent).toBe('Item one two')
|
|
167
|
+
rerender({ value: 'Item three' })
|
|
168
|
+
expect(field().textContent).toBe('Item one two')
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('a change from elsewhere is loaded while you are not in it', async () => {
|
|
172
|
+
const { rerender } = draw()
|
|
173
|
+
rerender({ value: 'Item three' })
|
|
174
|
+
await waitFor(() => expect(field().textContent).toBe('Item three'))
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('your own save coming back is not loaded over what you go on typing', async () => {
|
|
178
|
+
const { rerender, onCommit } = draw()
|
|
179
|
+
await enter()
|
|
180
|
+
await type(' two')
|
|
181
|
+
await userEvent.keyboard('{Enter}')
|
|
182
|
+
expect(onCommit).toHaveBeenCalledWith('Item one two')
|
|
183
|
+
await type('!')
|
|
184
|
+
rerender({ value: 'Item one two' })
|
|
185
|
+
expect(field().textContent).toBe('Item one two!')
|
|
186
|
+
})
|
|
187
|
+
})
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { useCallback, useEffect, useImperativeHandle, useRef, useState, type Ref } from 'react'
|
|
2
|
+
import { Extension, Node } from '@tiptap/core'
|
|
3
|
+
import { history, redo, undo } from '@tiptap/pm/history'
|
|
4
|
+
import { EditorContent, useEditor } from '@tiptap/react'
|
|
5
|
+
import { cn } from './cn'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A line of plain text on a page that you edit where it stands, as in a
|
|
9
|
+
* document: a title, a summary. Ship's title editor (Katerina, 29 September,
|
|
10
|
+
* findings E1 and E2), moved here for the summary (Miky, 16 October: "it feels
|
|
11
|
+
* like a document, not a form") so every app's header edits the same way.
|
|
12
|
+
*
|
|
13
|
+
* - **No box**, on hover or on click, and nothing moves: the text is the
|
|
14
|
+
* editor, in its own size and colour. The text cursor is the only sign.
|
|
15
|
+
* - **The caret goes where you click**, and nothing is selected.
|
|
16
|
+
* - **Enter saves and moves on** — `onEnter` says where: the title to the
|
|
17
|
+
* summary, the summary to the description.
|
|
18
|
+
* - **Escape stops editing and keeps the text**, saving it, as leaving does.
|
|
19
|
+
* Nothing is thrown away; Ctrl+Z undoes.
|
|
20
|
+
* - **An unchanged value is not a write.** Leaving it empty brings the saved
|
|
21
|
+
* one back, unless `clearable`: then empty is saved, as nothing.
|
|
22
|
+
* - **A refused save keeps your text where it is**, and the app says why
|
|
23
|
+
* beside it; a change made elsewhere does not overwrite it until you have
|
|
24
|
+
* saved, changed or emptied it.
|
|
25
|
+
*
|
|
26
|
+
* One paragraph and no marks, so pasted line breaks become spaces and it wraps
|
|
27
|
+
* like text, which a one-line `<input>` cannot. A Tiptap editor rather than a
|
|
28
|
+
* raw `contenteditable`, so it is the same kind of surface as the description
|
|
29
|
+
* under it.
|
|
30
|
+
*/
|
|
31
|
+
export interface InlineTextProps {
|
|
32
|
+
/** The saved text. A new value is loaded when you are not in the field and hold nothing unsaved. */
|
|
33
|
+
value: string
|
|
34
|
+
/** What it is for, muted, while it is empty: "Untitled", "Add a summary". */
|
|
35
|
+
placeholder: string
|
|
36
|
+
/** The accessible name: "Title", "Summary". */
|
|
37
|
+
label: string
|
|
38
|
+
/** Save it: resolve `true` when saved; `false` keeps your text in place. */
|
|
39
|
+
onCommit: (value: string) => Promise<boolean> | boolean
|
|
40
|
+
/** The text as it is being written, on focus and every change; `null` when you leave. For a count. */
|
|
41
|
+
onText?: (text: string | null) => void
|
|
42
|
+
/** Enter, after the save has started: where the caret goes next. */
|
|
43
|
+
onEnter?: () => void
|
|
44
|
+
/** `title`: the page's heading. `summary`: the line under it, quieter. */
|
|
45
|
+
variant?: 'title' | 'summary'
|
|
46
|
+
/** Empty is a value (a summary). Otherwise leaving it empty brings the saved one back (a title). */
|
|
47
|
+
clearable?: boolean
|
|
48
|
+
/** Text only, the same size and place: nobody can change it, or the editor is not ready. */
|
|
49
|
+
readOnly?: boolean
|
|
50
|
+
/** Placement only. */
|
|
51
|
+
className?: string
|
|
52
|
+
ref?: Ref<InlineTextHandle>
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
type CaretAt = 'start' | 'end'
|
|
56
|
+
|
|
57
|
+
export interface InlineTextHandle {
|
|
58
|
+
/** Put the caret in it, at the start or the end. */
|
|
59
|
+
focus: (at?: CaretAt) => void
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Size and tone, per variant: the same for the text at rest and while it is written. */
|
|
63
|
+
const VARIANT = {
|
|
64
|
+
title: 'text-h2 text-text-primary',
|
|
65
|
+
summary: 'text-body-1 text-text-secondary',
|
|
66
|
+
} as const
|
|
67
|
+
|
|
68
|
+
/** The room around the text: the caret has a little space at the edges, and the text still lines up with what is above and below it. */
|
|
69
|
+
const ROOM = '-mx-2 px-2 py-1'
|
|
70
|
+
|
|
71
|
+
// The whole schema: one paragraph of text. Written with the core, so the
|
|
72
|
+
// package's peers stay what they were.
|
|
73
|
+
const OneLine = Node.create({ name: 'doc', topNode: true, content: 'paragraph' })
|
|
74
|
+
const Paragraph = Node.create({
|
|
75
|
+
name: 'paragraph',
|
|
76
|
+
content: 'text*',
|
|
77
|
+
parseHTML: () => [{ tag: 'p' }],
|
|
78
|
+
renderHTML: () => ['p', 0],
|
|
79
|
+
})
|
|
80
|
+
const Text = Node.create({ name: 'text' })
|
|
81
|
+
const Undo = Extension.create({
|
|
82
|
+
name: 'inlineTextUndo',
|
|
83
|
+
addProseMirrorPlugins: () => [history()],
|
|
84
|
+
addKeyboardShortcuts() {
|
|
85
|
+
const run = (command: typeof undo) => () => command(this.editor.state, this.editor.view.dispatch)
|
|
86
|
+
return { 'Mod-z': run(undo), 'Shift-Mod-z': run(redo), 'Mod-y': run(redo) }
|
|
87
|
+
},
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
const doc = (text: string) => ({ type: 'doc', content: [{ type: 'paragraph', content: text ? [{ type: 'text', text }] : [] }] })
|
|
91
|
+
|
|
92
|
+
/** One line: every run of white space, line breaks included, is one space. */
|
|
93
|
+
const oneLine = (text: string) => text.replace(/\s+/g, ' ').trim()
|
|
94
|
+
|
|
95
|
+
export function InlineText({ value, placeholder, label, onCommit, onText, onEnter, variant = 'title', clearable = false, readOnly = false, className, ref }: InlineTextProps) {
|
|
96
|
+
const saved = useRef(value)
|
|
97
|
+
// The placeholder follows the text as it is typed or deleted, not the saved value.
|
|
98
|
+
const [empty, setEmpty] = useState(!value)
|
|
99
|
+
// Read at key time: the editor's handlers are built once, and a prop they
|
|
100
|
+
// closed over would be the first render's.
|
|
101
|
+
const latest = useRef({ onEnter, onText, onCommit, clearable })
|
|
102
|
+
latest.current = { onEnter, onText, onCommit, clearable }
|
|
103
|
+
|
|
104
|
+
const save = useCallback(async (text: string) => {
|
|
105
|
+
const next = oneLine(text)
|
|
106
|
+
if (next === saved.current) return
|
|
107
|
+
if (!next && !latest.current.clearable) return
|
|
108
|
+
if (await latest.current.onCommit(next)) saved.current = next
|
|
109
|
+
}, [])
|
|
110
|
+
|
|
111
|
+
const editor = useEditor({
|
|
112
|
+
extensions: [OneLine, Paragraph, Text, Undo],
|
|
113
|
+
content: doc(value),
|
|
114
|
+
editable: !readOnly,
|
|
115
|
+
editorProps: {
|
|
116
|
+
attributes: {
|
|
117
|
+
'aria-label': label,
|
|
118
|
+
role: 'textbox',
|
|
119
|
+
'aria-multiline': 'false',
|
|
120
|
+
'aria-placeholder': placeholder,
|
|
121
|
+
...(readOnly ? { 'aria-readonly': 'true' } : {}),
|
|
122
|
+
class: 'outline-none',
|
|
123
|
+
},
|
|
124
|
+
handleKeyDown: (view, event) => {
|
|
125
|
+
if (event.key === 'Enter') {
|
|
126
|
+
event.preventDefault()
|
|
127
|
+
void save(view.state.doc.textContent)
|
|
128
|
+
latest.current.onEnter?.()
|
|
129
|
+
return true
|
|
130
|
+
}
|
|
131
|
+
if (event.key === 'Escape') {
|
|
132
|
+
view.dom.blur()
|
|
133
|
+
return true
|
|
134
|
+
}
|
|
135
|
+
return false
|
|
136
|
+
},
|
|
137
|
+
// A paste is its plain text on this one line, whatever it was copied from.
|
|
138
|
+
handlePaste: (view, event) => {
|
|
139
|
+
const text = event.clipboardData?.getData('text/plain')
|
|
140
|
+
if (text == null) return false
|
|
141
|
+
view.dispatch(view.state.tr.insertText(text.replace(/\s+/g, ' ')))
|
|
142
|
+
return true
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
onFocus: ({ editor: e }) => latest.current.onText?.(e.getText()),
|
|
146
|
+
onUpdate: ({ editor: e }) => {
|
|
147
|
+
setEmpty(e.isEmpty)
|
|
148
|
+
latest.current.onText?.(e.getText())
|
|
149
|
+
},
|
|
150
|
+
onBlur: ({ editor: e }) => {
|
|
151
|
+
latest.current.onText?.(null)
|
|
152
|
+
// Left empty where empty is not a value: the saved text comes back, so
|
|
153
|
+
// the page never shows the placeholder while everyone else sees the text.
|
|
154
|
+
if (e.isEmpty && saved.current && !latest.current.clearable) {
|
|
155
|
+
e.commands.setContent(doc(saved.current), { emitUpdate: false })
|
|
156
|
+
setEmpty(false)
|
|
157
|
+
return
|
|
158
|
+
}
|
|
159
|
+
void save(e.getText())
|
|
160
|
+
},
|
|
161
|
+
}, [readOnly])
|
|
162
|
+
|
|
163
|
+
// A change made elsewhere is loaded while you are not in the field and hold
|
|
164
|
+
// nothing unsaved; your own save coming back is left alone.
|
|
165
|
+
useEffect(() => {
|
|
166
|
+
if (!editor || editor.isDestroyed || value === saved.current || editor.isFocused) return
|
|
167
|
+
if (oneLine(editor.getText()) !== saved.current) return
|
|
168
|
+
editor.commands.setContent(doc(value), { emitUpdate: false })
|
|
169
|
+
saved.current = value
|
|
170
|
+
setEmpty(!value)
|
|
171
|
+
}, [editor, value])
|
|
172
|
+
|
|
173
|
+
useImperativeHandle(ref, () => ({
|
|
174
|
+
focus: (at = 'start') => editor?.commands.focus(at),
|
|
175
|
+
}), [editor])
|
|
176
|
+
|
|
177
|
+
return (
|
|
178
|
+
<div className={cn('relative', VARIANT[variant], className)}>
|
|
179
|
+
<EditorContent editor={editor} className={cn(ROOM, !readOnly && 'cursor-text')} />
|
|
180
|
+
{/* Empty — never saved, or just deleted — shows what it is for, muted,
|
|
181
|
+
and is not part of the text. Drawn over the editor, where the text starts. */}
|
|
182
|
+
{empty && !readOnly && (
|
|
183
|
+
<span aria-hidden className="pointer-events-none absolute left-0 top-1 text-text-muted">{placeholder}</span>
|
|
184
|
+
)}
|
|
185
|
+
</div>
|
|
186
|
+
)
|
|
187
|
+
}
|
package/src/editor.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
export { KeptSelection, SelectionToolbar, keepSelection, releaseSelection, normalizeHref, type MarkId, type SelectionAction, type SelectionToolbarProps } from './SelectionToolbar'
|
|
11
11
|
export { ComposeBox, ComposeKeys, COMPOSE_EDITOR_CLASSES, type ComposeBoxProps } from './ComposeBox'
|
|
12
|
+
export { InlineText, type InlineTextProps, type InlineTextHandle } from './InlineText'
|
|
12
13
|
export {
|
|
13
14
|
CommentHighlights,
|
|
14
15
|
setCommentAnchors,
|