@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.
Files changed (55) hide show
  1. package/dist/CommentHighlights.d.ts +100 -0
  2. package/dist/CommentHighlights.d.ts.map +1 -0
  3. package/dist/ComposeBox.d.ts +55 -0
  4. package/dist/ComposeBox.d.ts.map +1 -0
  5. package/dist/ComposerTriggers.d.ts.map +1 -1
  6. package/dist/InlineText.d.ts +56 -0
  7. package/dist/InlineText.d.ts.map +1 -0
  8. package/dist/SelectionToolbar.d.ts +17 -1
  9. package/dist/SelectionToolbar.d.ts.map +1 -1
  10. package/dist/editor.d.ts +4 -1
  11. package/dist/editor.d.ts.map +1 -1
  12. package/dist/editor.js +748 -240
  13. package/dist/editor.js.map +4 -4
  14. package/dist/index.js.map +1 -1
  15. package/dist/looks.d.ts +6 -0
  16. package/dist/looks.d.ts.map +1 -1
  17. package/package.json +1 -5
  18. package/registry.json +403 -3
  19. package/src/CommentHighlights.mdx +61 -0
  20. package/src/CommentHighlights.stories.tsx +72 -0
  21. package/src/CommentHighlights.test.ts +100 -0
  22. package/src/CommentHighlights.ts +413 -0
  23. package/src/ComposeBox.mdx +58 -0
  24. package/src/ComposeBox.stories.tsx +81 -0
  25. package/src/ComposeBox.tsx +183 -0
  26. package/src/ComposerTriggers.tsx +9 -3
  27. package/src/InlineText.mdx +64 -0
  28. package/src/InlineText.stories.tsx +86 -0
  29. package/src/InlineText.test.tsx +187 -0
  30. package/src/InlineText.tsx +187 -0
  31. package/src/SelectionToolbar.actions.test.tsx +44 -0
  32. package/src/SelectionToolbar.mdx +1 -0
  33. package/src/SelectionToolbar.tsx +36 -2
  34. package/src/editor.ts +16 -1
  35. package/src/looks.ts +8 -0
  36. package/src/registry/app.test.ts +7 -1
  37. package/src/registry/registry.test.ts +6 -6
  38. package/stories/Choosing.mdx +3 -0
  39. package/dist/widgets-2/fixtures.d.ts +0 -102
  40. package/dist/widgets-2/fixtures.d.ts.map +0 -1
  41. package/dist/widgets-2/places.d.ts +0 -105
  42. package/dist/widgets-2/places.d.ts.map +0 -1
  43. package/dist/widgets-2/proposed.d.ts +0 -130
  44. package/dist/widgets-2/proposed.d.ts.map +0 -1
  45. package/dist/widgets-2.d.ts +0 -12
  46. package/dist/widgets-2.d.ts.map +0 -1
  47. package/dist/widgets-2.js +0 -585
  48. package/dist/widgets-2.js.map +0 -7
  49. package/src/widgets-2/fixtures.ts +0 -161
  50. package/src/widgets-2/places.tsx +0 -316
  51. package/src/widgets-2/proposed.tsx +0 -596
  52. package/src/widgets-2.ts +0 -11
  53. package/stories/widgets-2/ReadMe.mdx +0 -60
  54. package/stories/widgets-2/Widgets.stories.tsx +0 -504
  55. 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
+ }
@@ -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
- <NodeViewWrapper as="span" className={inlineChipClassName('neutral', 'max-w-[24ch] cursor-default')}>
312
- <span className="flex items-center justify-center w-4 h-4 shrink-0 text-text-secondary">{iconFor?.(node.attrs.uri ?? null) ?? icon}</span>
313
- <span className="truncate">{captionOf(node.attrs.label, node.attrs.snippet)}</span>
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
+ })