@estiva-app/ui 0.72.0-widgets2.2 → 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/CommentHighlights.d.ts +100 -0
- package/dist/CommentHighlights.d.ts.map +1 -0
- package/dist/ComposeBox.d.ts +55 -0
- package/dist/ComposeBox.d.ts.map +1 -0
- package/dist/ComposerTriggers.d.ts.map +1 -1
- package/dist/InlineText.d.ts +56 -0
- package/dist/InlineText.d.ts.map +1 -0
- package/dist/SelectionToolbar.d.ts +17 -1
- package/dist/SelectionToolbar.d.ts.map +1 -1
- package/dist/editor.d.ts +4 -1
- package/dist/editor.d.ts.map +1 -1
- package/dist/editor.js +748 -240
- package/dist/editor.js.map +4 -4
- package/dist/index.js.map +1 -1
- package/dist/looks.d.ts +6 -0
- package/dist/looks.d.ts.map +1 -1
- package/package.json +1 -5
- package/registry.json +403 -3
- package/src/CommentHighlights.mdx +61 -0
- package/src/CommentHighlights.stories.tsx +72 -0
- package/src/CommentHighlights.test.ts +100 -0
- package/src/CommentHighlights.ts +413 -0
- package/src/ComposeBox.mdx +58 -0
- package/src/ComposeBox.stories.tsx +81 -0
- package/src/ComposeBox.tsx +183 -0
- package/src/ComposerTriggers.tsx +9 -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/SelectionToolbar.actions.test.tsx +44 -0
- package/src/SelectionToolbar.mdx +1 -0
- package/src/SelectionToolbar.tsx +36 -2
- package/src/editor.ts +16 -1
- package/src/looks.ts +8 -0
- package/src/registry/app.test.ts +7 -1
- package/src/registry/registry.test.ts +6 -6
- package/stories/Choosing.mdx +3 -0
- package/dist/widgets-2/fixtures.d.ts +0 -102
- package/dist/widgets-2/fixtures.d.ts.map +0 -1
- package/dist/widgets-2/places.d.ts +0 -105
- package/dist/widgets-2/places.d.ts.map +0 -1
- package/dist/widgets-2/proposed.d.ts +0 -130
- package/dist/widgets-2/proposed.d.ts.map +0 -1
- package/dist/widgets-2.d.ts +0 -12
- package/dist/widgets-2.d.ts.map +0 -1
- package/dist/widgets-2.js +0 -585
- package/dist/widgets-2.js.map +0 -7
- package/src/widgets-2/fixtures.ts +0 -161
- package/src/widgets-2/places.tsx +0 -316
- package/src/widgets-2/proposed.tsx +0 -596
- package/src/widgets-2.ts +0 -11
- package/stories/widgets-2/ReadMe.mdx +0 -60
- package/stories/widgets-2/Widgets.stories.tsx +0 -504
- package/stories/widgets-2/today.tsx +0 -352
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState, type ReactNode } from 'react'
|
|
2
|
+
import { Extension } from '@tiptap/core'
|
|
3
|
+
import { EditorContent, type Editor } from '@tiptap/react'
|
|
4
|
+
import { IconArrowUp } from '@tabler/icons-react'
|
|
5
|
+
import { IconButton } from './IconButton'
|
|
6
|
+
import { Toolbar } from './Toolbar'
|
|
7
|
+
import { cn } from './cn'
|
|
8
|
+
import { richTextClassName } from './RichText'
|
|
9
|
+
import { WRITING_BOX_CLASSES } from './looks'
|
|
10
|
+
import { SelectionToolbar, type SelectionToolbarProps } from './SelectionToolbar'
|
|
11
|
+
import { isSuggestionActive } from './suggestionPopup'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The box a message, a reply or a comment is written in, from Katerina's Leaf
|
|
15
|
+
* prototype (9 October). Peek's `ComposeBox`, its look and its keys, with
|
|
16
|
+
* what only Peek knows left in Peek: drafts, uploads, highlights, its text
|
|
17
|
+
* format. The app makes the editor (its extensions are its own) and adds
|
|
18
|
+
* `ComposeKeys`; this draws the box around it and sends.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** The editor's own classes inside the box: Peek's, rich text with no outline. Put them in `editorProps.attributes.class`. */
|
|
22
|
+
export const COMPOSE_EDITOR_CLASSES = richTextClassName('default', 'outline-none w-full bg-transparent min-h-[20px] [&>p+p]:-mt-1')
|
|
23
|
+
|
|
24
|
+
type ComposeKeysStorage = { send: (() => boolean) | null }
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Enter sends; Shift+Enter is a new line — in a list a new item (or out of the
|
|
28
|
+
* list, on an empty one), in a quote a new line (or out of it, on an empty one).
|
|
29
|
+
* Peek's keys, as its ComposeBox has them. Enter while a `/`, `@` or `[` list is
|
|
30
|
+
* open picks a row instead. `ComposeBox` says what sending does.
|
|
31
|
+
*/
|
|
32
|
+
export const ComposeKeys = Extension.create<Record<string, never>, ComposeKeysStorage>({
|
|
33
|
+
name: 'composeKeys',
|
|
34
|
+
addStorage() {
|
|
35
|
+
return { send: null }
|
|
36
|
+
},
|
|
37
|
+
addKeyboardShortcuts() {
|
|
38
|
+
return {
|
|
39
|
+
Enter: () => {
|
|
40
|
+
if (isSuggestionActive()) return false
|
|
41
|
+
this.storage.send?.()
|
|
42
|
+
return true
|
|
43
|
+
},
|
|
44
|
+
'Shift-Enter': ({ editor }) => {
|
|
45
|
+
const { $from } = editor.state.selection
|
|
46
|
+
for (let d = $from.depth; d > 0; d--) {
|
|
47
|
+
const node = $from.node(d)
|
|
48
|
+
if (node.type.name === 'listItem') {
|
|
49
|
+
return node.textContent.length === 0 ? editor.commands.liftListItem('listItem') : editor.commands.splitListItem('listItem')
|
|
50
|
+
}
|
|
51
|
+
if (node.type.name === 'blockquote') {
|
|
52
|
+
if ($from.parent.textContent.length === 0) return editor.commands.lift('paragraph')
|
|
53
|
+
break
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return editor.commands.splitBlock()
|
|
57
|
+
},
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
/** Nothing written: no text, no chip and no picture — empty lines, lists or quotes only. */
|
|
63
|
+
function isEmptyDoc(editor: Editor): boolean {
|
|
64
|
+
const doc = editor.state.doc
|
|
65
|
+
if (doc.textContent.length > 0) return false
|
|
66
|
+
let empty = true
|
|
67
|
+
doc.descendants((node) => {
|
|
68
|
+
if (node.isAtom && node.type.name !== 'hardBreak') empty = false
|
|
69
|
+
})
|
|
70
|
+
return empty
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface ComposeBoxProps {
|
|
74
|
+
/** The app's editor, with `ComposeKeys` in its extensions and `COMPOSE_EDITOR_CLASSES` on it. `null` while it loads. */
|
|
75
|
+
editor: Editor | null
|
|
76
|
+
/**
|
|
77
|
+
* Send what is written. The box empties afterwards — unless this returns
|
|
78
|
+
* `false`: nothing was sent, and the words stay.
|
|
79
|
+
*/
|
|
80
|
+
onSend: (editor: Editor) => void | boolean
|
|
81
|
+
/** Said in the empty box: words, and a `Kbd` where a key helps ("type / for commands"). */
|
|
82
|
+
placeholder: ReactNode
|
|
83
|
+
/** The tools under the text, left: `ToolbarButton`s (attach, highlight). One Tab stop. */
|
|
84
|
+
tools?: ReactNode
|
|
85
|
+
/** What is attached, under the text: `AttachmentCard pending`s. */
|
|
86
|
+
attachments?: ReactNode
|
|
87
|
+
/** Something to send even with no words — a file ready. */
|
|
88
|
+
ready?: boolean
|
|
89
|
+
/** Sending waits — a file still uploading. */
|
|
90
|
+
holding?: boolean
|
|
91
|
+
/** The bar down the text's left: an urgent mention or a highlight, as Peek draws it. */
|
|
92
|
+
marked?: boolean
|
|
93
|
+
/** The selection strip's buttons: its marks, Link, and the app's own (`actions`). */
|
|
94
|
+
selection?: Pick<SelectionToolbarProps, 'marks' | 'link' | 'actions'>
|
|
95
|
+
className?: string
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The box for writing: the text, the selection strip over it, what is
|
|
100
|
+
* attached, the tools and Send. Enter sends (`ComposeKeys`), and so does the
|
|
101
|
+
* button.
|
|
102
|
+
*/
|
|
103
|
+
export function ComposeBox({ editor, onSend, placeholder, tools, attachments, ready = false, holding = false, marked = false, selection, className }: ComposeBoxProps) {
|
|
104
|
+
const [empty, setEmpty] = useState(true)
|
|
105
|
+
const canSend = (!empty || ready) && !holding
|
|
106
|
+
|
|
107
|
+
const send = useCallback((): boolean => {
|
|
108
|
+
if (!editor || holding || (isEmptyDoc(editor) && !ready)) return true
|
|
109
|
+
if (onSend(editor) === false) return true
|
|
110
|
+
editor.commands.clearContent(true)
|
|
111
|
+
editor.commands.focus()
|
|
112
|
+
return true
|
|
113
|
+
}, [editor, holding, ready, onSend])
|
|
114
|
+
|
|
115
|
+
// Enter, through `ComposeKeys`, sends the way the button does.
|
|
116
|
+
useEffect(() => {
|
|
117
|
+
if (!editor) return
|
|
118
|
+
const storage = (editor.storage as unknown as { composeKeys?: ComposeKeysStorage }).composeKeys
|
|
119
|
+
if (!storage) return
|
|
120
|
+
storage.send = send
|
|
121
|
+
return () => {
|
|
122
|
+
storage.send = null
|
|
123
|
+
}
|
|
124
|
+
}, [editor, send])
|
|
125
|
+
|
|
126
|
+
useEffect(() => {
|
|
127
|
+
if (!editor) return
|
|
128
|
+
const read = () => setEmpty(isEmptyDoc(editor))
|
|
129
|
+
read()
|
|
130
|
+
editor.on('update', read)
|
|
131
|
+
return () => {
|
|
132
|
+
editor.off('update', read)
|
|
133
|
+
}
|
|
134
|
+
}, [editor])
|
|
135
|
+
|
|
136
|
+
return (
|
|
137
|
+
<div className={cn('relative', className)}>
|
|
138
|
+
<div className={cn(WRITING_BOX_CLASSES, 'focus-within:border-border-strong transition-colors')}>
|
|
139
|
+
<div data-composer-editor className={cn('relative min-h-[20px] transition-all', marked && 'border-l-4 border-border-strong pl-2')}>
|
|
140
|
+
<EditorContent editor={editor} />
|
|
141
|
+
<SelectionToolbar editor={editor} {...selection} />
|
|
142
|
+
{empty && (
|
|
143
|
+
<div className="pointer-events-none absolute inset-0 flex flex-wrap items-center gap-1 text-body-2 text-text-muted">{placeholder}</div>
|
|
144
|
+
)}
|
|
145
|
+
</div>
|
|
146
|
+
|
|
147
|
+
{attachments && <div className="flex flex-wrap gap-2">{attachments}</div>}
|
|
148
|
+
|
|
149
|
+
{/* The tools are one strip — one Tab stop, arrows along it; Send, the answer rather than a tool, keeps its own. */}
|
|
150
|
+
<div className="flex items-center justify-between">
|
|
151
|
+
{tools ? (
|
|
152
|
+
<Toolbar aria-label="Compose tools" surface={false} className="flex items-center gap-1">
|
|
153
|
+
{tools}
|
|
154
|
+
</Toolbar>
|
|
155
|
+
) : (
|
|
156
|
+
<span />
|
|
157
|
+
)}
|
|
158
|
+
<div className="flex items-center gap-2.5">
|
|
159
|
+
<span className="hidden select-none font-mono text-menu font-normal tracking-wider text-text-muted signal:inline">Enter to send · Shift+Enter new line</span>
|
|
160
|
+
{/* Focus stays in the text: the press sends on its way down. A keyboard press is the click (`detail` 0), so a click never sends twice. */}
|
|
161
|
+
<IconButton
|
|
162
|
+
variant="primary"
|
|
163
|
+
onMouseDown={(e) => {
|
|
164
|
+
e.preventDefault()
|
|
165
|
+
send()
|
|
166
|
+
}}
|
|
167
|
+
onClick={(e) => {
|
|
168
|
+
if (e.detail === 0) send()
|
|
169
|
+
}}
|
|
170
|
+
disabled={!canSend}
|
|
171
|
+
aria-label="Send"
|
|
172
|
+
tooltip="Send"
|
|
173
|
+
tooltipShortcut="Enter"
|
|
174
|
+
glow={canSend}
|
|
175
|
+
>
|
|
176
|
+
<IconArrowUp size={16} stroke={1.5} />
|
|
177
|
+
</IconButton>
|
|
178
|
+
</div>
|
|
179
|
+
</div>
|
|
180
|
+
</div>
|
|
181
|
+
</div>
|
|
182
|
+
)
|
|
183
|
+
}
|
package/src/ComposerTriggers.tsx
CHANGED
|
@@ -7,6 +7,7 @@ import { NodeViewWrapper, ReactNodeViewRenderer, type ReactNodeViewProps } from
|
|
|
7
7
|
import { Avatar } from './Avatar'
|
|
8
8
|
import { EnterHint } from './Menu'
|
|
9
9
|
import { inlineChipClassName } from './InlineChip'
|
|
10
|
+
import { WithTooltip } from './Tooltip'
|
|
10
11
|
import type { SuggestionMenuRow } from './SuggestionMenu'
|
|
11
12
|
import { suggestionPopup, type SuggestionPopupOptions } from './suggestionPopup'
|
|
12
13
|
|
|
@@ -308,9 +309,14 @@ export const CaptionedReference = Mention.extend<MentionOptions & CaptionedRefer
|
|
|
308
309
|
const { icon, iconFor } = this.options
|
|
309
310
|
return ReactNodeViewRenderer(
|
|
310
311
|
({ node }: ReactNodeViewProps) => (
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
<
|
|
312
|
+
// The caption is cut at 24ch, so the whole of it is on hover, in the package's tooltip (UIG-14) — as Peek's sent chip has it.
|
|
313
|
+
<NodeViewWrapper as="span">
|
|
314
|
+
<WithTooltip inline label={captionOf(node.attrs.label, node.attrs.snippet)}>
|
|
315
|
+
<span className={inlineChipClassName('neutral', 'max-w-[24ch] cursor-default')}>
|
|
316
|
+
<span className="flex items-center justify-center w-4 h-4 shrink-0 text-text-secondary">{iconFor?.(node.attrs.uri ?? null) ?? icon}</span>
|
|
317
|
+
<span className="truncate">{captionOf(node.attrs.label, node.attrs.snippet)}</span>
|
|
318
|
+
</span>
|
|
319
|
+
</WithTooltip>
|
|
314
320
|
</NodeViewWrapper>
|
|
315
321
|
),
|
|
316
322
|
{ as: 'span' },
|
|
@@ -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
|
+
})
|