@estiva-app/ui 0.39.0 → 0.40.0
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/RichText.d.ts +78 -0
- package/dist/RichText.d.ts.map +1 -0
- package/dist/eslint/index.js +1 -0
- package/dist/eslint/index.js.map +2 -2
- package/dist/eslint/no-restyled-part.d.ts.map +1 -1
- package/dist/gates/app-checks.d.ts.map +1 -1
- package/dist/gates/index.js +11 -1
- package/dist/gates/index.js.map +2 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +350 -209
- package/dist/index.js.map +4 -4
- package/package.json +1 -1
- package/registry.json +90 -3
- package/src/RichText.mdx +79 -0
- package/src/RichText.stories.tsx +91 -0
- package/src/RichText.test.tsx +149 -0
- package/src/RichText.tsx +294 -0
- package/src/eslint/no-restyled-part.ts +1 -0
- package/src/gates/app-checks.ts +7 -0
- package/src/index.ts +1 -0
- package/src/registry/registry.test.ts +5 -5
- package/stories/Choosing.mdx +1 -0
- package/tailwind-preset.js +1 -0
- package/tokens.css +15 -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.40.0",
|
|
8
|
+
"exports": 90,
|
|
9
|
+
"typeExports": 101,
|
|
10
10
|
"files": null
|
|
11
11
|
},
|
|
12
12
|
"storybook": {
|
|
@@ -4079,6 +4079,93 @@
|
|
|
4079
4079
|
"app": null,
|
|
4080
4080
|
"looks": []
|
|
4081
4081
|
},
|
|
4082
|
+
{
|
|
4083
|
+
"name": "RichText",
|
|
4084
|
+
"repo": "estiva-ui",
|
|
4085
|
+
"kind": "component",
|
|
4086
|
+
"importPath": "@estiva-app/ui",
|
|
4087
|
+
"sourceFile": "src/RichText.tsx",
|
|
4088
|
+
"purpose": "Formatted text, drawn from a parsed tree: headings, paragraphs, both lists, a quote, code, a divider, a table and links, in one look for every app.",
|
|
4089
|
+
"purposeFrom": "page",
|
|
4090
|
+
"props": [
|
|
4091
|
+
{
|
|
4092
|
+
"name": "blocks",
|
|
4093
|
+
"takes": "readonly RichTextBlock[]",
|
|
4094
|
+
"required": true,
|
|
4095
|
+
"note": "The tree, already parsed."
|
|
4096
|
+
},
|
|
4097
|
+
{
|
|
4098
|
+
"name": "size",
|
|
4099
|
+
"takes": "default | small",
|
|
4100
|
+
"required": false,
|
|
4101
|
+
"note": "Default `default`, the size of text you read."
|
|
4102
|
+
},
|
|
4103
|
+
{
|
|
4104
|
+
"name": "renderText",
|
|
4105
|
+
"takes": "a handler",
|
|
4106
|
+
"required": false,
|
|
4107
|
+
"note": "Draws plain text: what is left of a run once its links are drawn."
|
|
4108
|
+
},
|
|
4109
|
+
{
|
|
4110
|
+
"name": "renderReference",
|
|
4111
|
+
"takes": "a handler",
|
|
4112
|
+
"required": false,
|
|
4113
|
+
"note": "Draws a run that stands for an address. By default its text."
|
|
4114
|
+
},
|
|
4115
|
+
{
|
|
4116
|
+
"name": "renderBlock",
|
|
4117
|
+
"takes": "a handler",
|
|
4118
|
+
"required": false,
|
|
4119
|
+
"note": "Draws a block this part has no design for (an attachment, a widget)."
|
|
4120
|
+
}
|
|
4121
|
+
],
|
|
4122
|
+
"variants": [
|
|
4123
|
+
{
|
|
4124
|
+
"prop": "size",
|
|
4125
|
+
"values": [
|
|
4126
|
+
"default",
|
|
4127
|
+
"small"
|
|
4128
|
+
]
|
|
4129
|
+
}
|
|
4130
|
+
],
|
|
4131
|
+
"ownsBehaviours": [],
|
|
4132
|
+
"status": "stable",
|
|
4133
|
+
"migrationStage": null,
|
|
4134
|
+
"docsId": "components-richtext--docs",
|
|
4135
|
+
"storyId": "components-richtext--every-block",
|
|
4136
|
+
"docPage": "src/RichText.mdx",
|
|
4137
|
+
"app": null,
|
|
4138
|
+
"looks": [
|
|
4139
|
+
"[&>:is(h1,h2,h3)]:mb-1 [&>:is(h1,h2,h3)]:mt-2 [&>:is(h1,h2,h3):first-child]:mt-0 [&>:is(h1,h2,h3):last-child]:mb-0 [&>:is(h1,h2,h3)+:is(h1,h2,h3)]:mt-1 [&>:is(h1,h2)]:text-text-primary",
|
|
4140
|
+
"[&_:is(ul,ol)]:pl-5 [&_li>p]:m-0 [&_li]:break-words [&_li]:marker:text-text-muted [&_ol]:list-decimal [&_ul]:list-disc",
|
|
4141
|
+
"[&_blockquote>p]:m-0 [&_blockquote]:border-l-[3px] [&_blockquote]:border-border-strong [&_blockquote]:pl-2.5",
|
|
4142
|
+
"[&_:not(pre)>code]:rounded-sm [&_:not(pre)>code]:bg-bg-code [&_:not(pre)>code]:px-1 [&_:not(pre)>code]:py-0.5 [&_code]:font-mono [&_code]:text-[0.9em] [&_code]:text-text-primary",
|
|
4143
|
+
"[&_pre>code]:text-[1em] [&_pre]:whitespace-pre-wrap [&_pre]:break-words [&_pre]:rounded-md [&_pre]:bg-bg-code [&_pre]:p-2 [&_pre]:font-mono [&_pre]:text-[0.9em] [&_pre]:text-text-primary",
|
|
4144
|
+
"[&_:is(th,td)>p]:m-0 [&_:is(th,td)]:border [&_:is(th,td)]:border-border-default [&_:is(th,td)]:px-2 [&_:is(th,td)]:py-1 [&_:is(th,td)]:text-left [&_:is(th,td)]:align-top [&_table]:w-full [&_table]:border-collapse [&_th]:bg-bg-code [&_th]:font-medium [&_th]:text-text-primary",
|
|
4145
|
+
"[&_em]:italic [&_strong]:font-semibold [&_u]:underline [&_u]:underline-offset-2",
|
|
4146
|
+
"text-body-2 [&>:is(h2,h3)]:text-body-2 [&>:is(h2,h3)]:font-semibold [&>h1]:text-h4",
|
|
4147
|
+
"text-caption leading-snug [&>:is(h2,h3)]:text-caption [&>:is(h2,h3)]:font-semibold [&>h1]:text-body-2 [&>h1]:font-semibold"
|
|
4148
|
+
]
|
|
4149
|
+
},
|
|
4150
|
+
{
|
|
4151
|
+
"name": "richTextClassName",
|
|
4152
|
+
"repo": "estiva-ui",
|
|
4153
|
+
"kind": "helper",
|
|
4154
|
+
"importPath": "@estiva-app/ui",
|
|
4155
|
+
"sourceFile": "src/RichText.tsx",
|
|
4156
|
+
"purpose": "RichText's look, for a box you draw yourself: an editor's, so that what is being written looks the same as what is read.",
|
|
4157
|
+
"purposeFrom": "comment",
|
|
4158
|
+
"props": [],
|
|
4159
|
+
"variants": [],
|
|
4160
|
+
"ownsBehaviours": [],
|
|
4161
|
+
"status": "stable",
|
|
4162
|
+
"migrationStage": null,
|
|
4163
|
+
"docsId": "components-richtext--docs",
|
|
4164
|
+
"storyId": "components-richtext--every-block",
|
|
4165
|
+
"docPage": null,
|
|
4166
|
+
"app": null,
|
|
4167
|
+
"looks": []
|
|
4168
|
+
},
|
|
4082
4169
|
{
|
|
4083
4170
|
"name": "ScrollArea",
|
|
4084
4171
|
"repo": "estiva-ui",
|
package/src/RichText.mdx
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
|
|
2
|
+
import * as RichTextStories from './RichText.stories'
|
|
3
|
+
|
|
4
|
+
<Meta of={RichTextStories} />
|
|
5
|
+
|
|
6
|
+
# RichText
|
|
7
|
+
|
|
8
|
+
Formatted text, drawn from a parsed tree: headings, paragraphs, both lists, a
|
|
9
|
+
quote, code, a divider, a table and links, in one look for every app.
|
|
10
|
+
|
|
11
|
+
<Canvas of={RichTextStories.EveryBlock} />
|
|
12
|
+
|
|
13
|
+
## When
|
|
14
|
+
|
|
15
|
+
- **`default`** — text someone reads: a post, a reply, a description.
|
|
16
|
+
- **`small`** — the same text one step down, with every heading, list, quote
|
|
17
|
+
and table kept: an item shown inside a card.
|
|
18
|
+
- `renderReference` — a run that stands for an address: the app draws what it
|
|
19
|
+
points at, a person or an item.
|
|
20
|
+
- `renderText` — words the app recognises in plain text, drawn as chips.
|
|
21
|
+
- `renderBlock` — a block this part has no design for, such as an attachment.
|
|
22
|
+
|
|
23
|
+
## When not
|
|
24
|
+
|
|
25
|
+
- One word standing for a person or a thing, inside your own sentence →
|
|
26
|
+
**InlineChip**.
|
|
27
|
+
- One link inside your own sentence → **Link**.
|
|
28
|
+
- A value set out as a label and its value → **Property**.
|
|
29
|
+
|
|
30
|
+
## How
|
|
31
|
+
|
|
32
|
+
```tsx
|
|
33
|
+
import { InlineChip, RichText } from '@estiva-app/ui'
|
|
34
|
+
|
|
35
|
+
// toRenderTree comes from @estiva-app/protocol
|
|
36
|
+
<RichText
|
|
37
|
+
blocks={toRenderTree(value, format)}
|
|
38
|
+
renderReference={(reference) => <InlineChip tone="person">{nameOf(reference)}</InlineChip>}
|
|
39
|
+
/>
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
- **It takes the tree, not the text.** Parse first; the format comes from the
|
|
43
|
+
item's tag, never from its body. The protocol's `RenderBlock` fits
|
|
44
|
+
`RichTextBlock`. To draw a block somewhere else (below the text, as a card),
|
|
45
|
+
leave it out of `blocks`.
|
|
46
|
+
- **The colour is set here**: the text is `text-secondary`, and headings
|
|
47
|
+
of level 1 and 2 are `text-primary`. Do not wrap it to recolour it.
|
|
48
|
+
- **Links are found here**, in every run that is not code: `[words](address)`
|
|
49
|
+
and a bare `https://` address. Both open in a new tab, and a click on one
|
|
50
|
+
is not also a click on what the text sits in, such as a card. `renderText`
|
|
51
|
+
gets only the text between links, so an address is never read as a name.
|
|
52
|
+
- **Nothing is set as HTML.** Every string reaches the page as text; a `<b>`
|
|
53
|
+
typed by an author shows as `<b>`.
|
|
54
|
+
- A block of a kind it does not know shows its words and its children. It is
|
|
55
|
+
never dropped.
|
|
56
|
+
- Code, in a sentence or as a block, is 0.9 of the text around it, so it
|
|
57
|
+
shrinks with `small`. A long line in a block wraps; nothing scrolls
|
|
58
|
+
sideways.
|
|
59
|
+
- Each block carries its `id` as `data-block-id`, for an app that points at
|
|
60
|
+
one block.
|
|
61
|
+
- **An editor wears the same look.** Put `richTextClassName()` (or
|
|
62
|
+
`richTextClassName('small')`) on the editor's root, so what is being written
|
|
63
|
+
looks exactly like what is read. It styles the elements inside the box,
|
|
64
|
+
including the paragraph an editor puts inside a list item, a quote and a
|
|
65
|
+
table cell.
|
|
66
|
+
|
|
67
|
+
```tsx
|
|
68
|
+
import { richTextClassName } from '@estiva-app/ui'
|
|
69
|
+
|
|
70
|
+
const editorAttributes = { class: richTextClassName('default', 'outline-none') }
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## What it owns
|
|
74
|
+
|
|
75
|
+
Nothing. It only draws. Clicks and keys are yours.
|
|
76
|
+
|
|
77
|
+
## Props
|
|
78
|
+
|
|
79
|
+
<Controls of={RichTextStories.EveryBlock} />
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { IconSquareRounded } from '@tabler/icons-react'
|
|
3
|
+
import { RichText, type RichTextBlock, type RichTextRun } from './RichText'
|
|
4
|
+
import { InlineChip } from './InlineChip'
|
|
5
|
+
import { Card } from './Card'
|
|
6
|
+
|
|
7
|
+
const t = (text: string, ...marks: string[]): RichTextRun => ({ text, marks })
|
|
8
|
+
const p = (...inline: RichTextRun[]): RichTextBlock => ({ type: 'paragraph', inline })
|
|
9
|
+
const items = (...texts: string[]): RichTextBlock[] => texts.map((text) => ({ type: 'listItem', inline: [t(text)] }))
|
|
10
|
+
const cell = (type: 'tableHeader' | 'tableCell', text: string): RichTextBlock => ({ type: 'unknown', typeName: type, children: [p(t(text))] })
|
|
11
|
+
const row = (type: 'tableHeader' | 'tableCell', ...texts: string[]): RichTextBlock => ({ type: 'unknown', typeName: 'tableRow', children: texts.map((x) => cell(type, x)) })
|
|
12
|
+
|
|
13
|
+
/** Every block the part draws, in the order a reader meets them. */
|
|
14
|
+
const EVERY_BLOCK: RichTextBlock[] = [
|
|
15
|
+
{ type: 'heading', level: 1, inline: [t('A heading')] },
|
|
16
|
+
{ type: 'heading', level: 2, inline: [t('A smaller heading')] },
|
|
17
|
+
{ type: 'heading', level: 3, inline: [t('A third level, in the text colour')] },
|
|
18
|
+
p(t('Text can be '), t('bold', 'bold'), t(', '), t('italic', 'italic'), t(' or '), t('underlined', 'underline'), t(', and hold '), t('code', 'code'), t(' in a sentence.\nA line break stays a line break.')),
|
|
19
|
+
{ type: 'bulletList', children: items('An item', 'Another item') },
|
|
20
|
+
{ type: 'orderedList', children: items('The first step', 'The second step') },
|
|
21
|
+
{ type: 'blockquote', inline: [t('A quote, set in from a line.')] },
|
|
22
|
+
{ type: 'codeBlock', language: 'ts', inline: [t('const item = 1 // a long line of code folds onto the next line instead of scrolling sideways')] },
|
|
23
|
+
{ type: 'horizontalRule' },
|
|
24
|
+
{
|
|
25
|
+
type: 'table',
|
|
26
|
+
children: [row('tableHeader', 'Label', 'Value'), row('tableCell', 'Item one', 'One'), row('tableCell', 'Item two', 'Two')],
|
|
27
|
+
},
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
const meta = {
|
|
31
|
+
title: 'Components/RichText',
|
|
32
|
+
component: RichText,
|
|
33
|
+
args: { blocks: EVERY_BLOCK, size: 'default' },
|
|
34
|
+
argTypes: { size: { control: 'inline-radio', options: ['default', 'small'] } },
|
|
35
|
+
decorators: [(Story) => <div className="w-[480px]"><Story /></div>],
|
|
36
|
+
} satisfies Meta<typeof RichText>
|
|
37
|
+
|
|
38
|
+
export default meta
|
|
39
|
+
type Story = StoryObj<typeof meta>
|
|
40
|
+
|
|
41
|
+
/** Every block: headings, text and its marks, both lists, a quote, code, a divider and a table. */
|
|
42
|
+
export const EveryBlock: Story = {}
|
|
43
|
+
|
|
44
|
+
/** One step down, with every heading, list, quote and table kept: an item shown inside a card. */
|
|
45
|
+
export const Small: Story = {
|
|
46
|
+
args: { size: 'small' },
|
|
47
|
+
render: (args) => (
|
|
48
|
+
<Card className="p-3">
|
|
49
|
+
<RichText {...args} />
|
|
50
|
+
</Card>
|
|
51
|
+
),
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A link written with its own words, and a bare address, which may break anywhere. Code is never read for links. */
|
|
55
|
+
export const Links: Story = {
|
|
56
|
+
args: {
|
|
57
|
+
blocks: [
|
|
58
|
+
p(t('A link with its own words: [the guide](https://example.com/guide). A bare address: https://example.com/a/long/address/that/breaks/where/it/must.')),
|
|
59
|
+
p(t('Inside code it stays text: '), t('[not a link](https://example.com)', 'code')),
|
|
60
|
+
],
|
|
61
|
+
},
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** What an app draws itself: a run that stands for an address, and words it recognises in plain text. */
|
|
65
|
+
export const WithTheAppsOwnParts: Story = {
|
|
66
|
+
args: {
|
|
67
|
+
blocks: [
|
|
68
|
+
p(
|
|
69
|
+
t('Assigned to '),
|
|
70
|
+
{ text: 'nostr:npub1example', marks: [], reference: 'nostr:npub1example' },
|
|
71
|
+
t(', see [[Item one]] for the rest.'),
|
|
72
|
+
),
|
|
73
|
+
],
|
|
74
|
+
renderReference: () => <InlineChip tone="person">Name</InlineChip>,
|
|
75
|
+
renderText: (text) =>
|
|
76
|
+
text.split(/(\[\[[^\]]+\]\])/).map((part, i) =>
|
|
77
|
+
part.startsWith('[[') ? (
|
|
78
|
+
<InlineChip key={i} icon={<IconSquareRounded size={16} stroke={1.5} />}>{part.slice(2, -2)}</InlineChip>
|
|
79
|
+
) : (
|
|
80
|
+
part
|
|
81
|
+
),
|
|
82
|
+
),
|
|
83
|
+
},
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** A block this part has no design for, from a later version: its words are drawn, never dropped. */
|
|
87
|
+
export const UnknownBlock: Story = {
|
|
88
|
+
args: {
|
|
89
|
+
blocks: [p(t('The block below is of a kind this part does not know.')), { type: 'unknown', typeName: 'later', inline: [t('Its words are still here.')] }],
|
|
90
|
+
},
|
|
91
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* What the RichText page claims, pinned: every block draws as its element; text
|
|
4
|
+
* is text, never HTML; links are found in text and never in code, and a click
|
|
5
|
+
* on one stays on it; an unknown block keeps its words; a table's first row of
|
|
6
|
+
* header cells is its header; the app's own parts go where the page says.
|
|
7
|
+
*/
|
|
8
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
9
|
+
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
|
10
|
+
import { RichText, richTextClassName, type RichTextBlock, type RichTextRun } from './RichText'
|
|
11
|
+
|
|
12
|
+
afterEach(cleanup)
|
|
13
|
+
|
|
14
|
+
const t = (text: string, ...marks: string[]): RichTextRun => ({ text, marks })
|
|
15
|
+
const p = (...inline: RichTextRun[]): RichTextBlock => ({ type: 'paragraph', inline })
|
|
16
|
+
const cell = (typeName: string, text: string): RichTextBlock => ({ type: 'unknown', typeName, children: [p(t(text))] })
|
|
17
|
+
const row = (typeName: string, ...texts: string[]): RichTextBlock => ({ type: 'unknown', typeName: 'tableRow', children: texts.map((x) => cell(typeName, x)) })
|
|
18
|
+
|
|
19
|
+
const draw = (blocks: RichTextBlock[], props = {}) => render(<RichText blocks={blocks} {...props} />).container
|
|
20
|
+
|
|
21
|
+
describe('RichText', () => {
|
|
22
|
+
it('draws each block as its element', () => {
|
|
23
|
+
const c = draw([
|
|
24
|
+
{ type: 'heading', level: 1, inline: [t('One')] },
|
|
25
|
+
{ type: 'heading', level: 2, inline: [t('Two')] },
|
|
26
|
+
{ type: 'heading', level: 3, inline: [t('Three')] },
|
|
27
|
+
p(t('Text')),
|
|
28
|
+
{ type: 'bulletList', children: [{ type: 'listItem', inline: [t('a')] }] },
|
|
29
|
+
{ type: 'orderedList', start: 3, children: [{ type: 'listItem', inline: [t('b')] }] },
|
|
30
|
+
{ type: 'blockquote', inline: [t('quoted')] },
|
|
31
|
+
{ type: 'codeBlock', inline: [t('const a = 1')] },
|
|
32
|
+
{ type: 'horizontalRule' },
|
|
33
|
+
])
|
|
34
|
+
expect(c.querySelector('h1')?.textContent).toBe('One')
|
|
35
|
+
expect(c.querySelector('h2')?.textContent).toBe('Two')
|
|
36
|
+
expect(c.querySelector('h3')?.textContent).toBe('Three')
|
|
37
|
+
expect(c.querySelector('ul li')?.textContent).toBe('a')
|
|
38
|
+
expect(c.querySelector('ol')?.getAttribute('start')).toBe('3')
|
|
39
|
+
expect(c.querySelector('blockquote')?.textContent).toBe('quoted')
|
|
40
|
+
expect(c.querySelector('pre code')?.textContent).toBe('const a = 1')
|
|
41
|
+
expect(c.querySelector('hr')).not.toBeNull()
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('draws the marks as elements, and a line break as a break', () => {
|
|
45
|
+
const c = draw([p(t('b', 'bold'), t('i', 'italic'), t('u', 'underline'), t('c', 'code'), t('one\ntwo'))])
|
|
46
|
+
expect(c.querySelector('strong')?.textContent).toBe('b')
|
|
47
|
+
expect(c.querySelector('em')?.textContent).toBe('i')
|
|
48
|
+
expect(c.querySelector('u')?.textContent).toBe('u')
|
|
49
|
+
expect(c.querySelector('p > code')?.textContent).toBe('c')
|
|
50
|
+
expect(c.querySelectorAll('br').length).toBe(1)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('sets text as text, never as HTML', () => {
|
|
54
|
+
const c = draw([p(t('<b>stays text</b><img src=x onerror=alert(1)>'))])
|
|
55
|
+
expect(c.querySelector('b')).toBeNull()
|
|
56
|
+
expect(c.querySelector('img')).toBeNull()
|
|
57
|
+
expect(c.textContent).toContain('<b>stays text</b>')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('finds a written link and a bare address, and leaves punctuation after it', () => {
|
|
61
|
+
draw([p(t('Read [the guide](https://example.com/guide) or https://example.com/a.'))])
|
|
62
|
+
const guide = screen.getByRole('link', { name: 'the guide' })
|
|
63
|
+
expect(guide.getAttribute('href')).toBe('https://example.com/guide')
|
|
64
|
+
expect(guide.getAttribute('target')).toBe('_blank')
|
|
65
|
+
const bare = screen.getByRole('link', { name: 'https://example.com/a' })
|
|
66
|
+
expect(bare.getAttribute('href')).toBe('https://example.com/a')
|
|
67
|
+
expect(bare.parentElement?.textContent).toBe('https://example.com/a.')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('never reads code for links', () => {
|
|
71
|
+
const c = draw([p(t('[not a link](https://example.com)', 'code'))])
|
|
72
|
+
expect(c.querySelector('a')).toBeNull()
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('keeps a click on a link to the link', () => {
|
|
76
|
+
const outer = vi.fn()
|
|
77
|
+
render(
|
|
78
|
+
<div onClick={outer}>
|
|
79
|
+
<RichText blocks={[p(t('https://example.com'))]} />
|
|
80
|
+
</div>,
|
|
81
|
+
)
|
|
82
|
+
fireEvent.click(screen.getByRole('link'))
|
|
83
|
+
expect(outer).not.toHaveBeenCalled()
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('hands the app only the text between links', () => {
|
|
87
|
+
const seen: string[] = []
|
|
88
|
+
draw([p(t('before https://example.com/@name after'))], { renderText: (text: string) => (seen.push(text), text) })
|
|
89
|
+
expect(seen).toEqual(['before ', ' after'])
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('lets the app draw a reference, and draws its text when it does not', () => {
|
|
93
|
+
const run: RichTextRun = { text: 'nostr:npub1x', marks: [], reference: 'nostr:npub1x' }
|
|
94
|
+
expect(draw([p(run)]).textContent).toBe('nostr:npub1x')
|
|
95
|
+
cleanup()
|
|
96
|
+
draw([p(run)], { renderReference: (reference: string) => <mark>{reference.slice(6)}</mark> })
|
|
97
|
+
expect(document.querySelector('mark')?.textContent).toBe('npub1x')
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('draws the words of a block it does not know, never dropping it', () => {
|
|
101
|
+
const c = draw([{ type: 'unknown', typeName: 'later', inline: [t('still here')], children: [p(t('and this'))] }])
|
|
102
|
+
const block = c.querySelector('[data-block-type="later"]')
|
|
103
|
+
expect(block?.textContent).toBe('still hereand this')
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('lets the app draw a block, or leave it to the part', () => {
|
|
107
|
+
const own = (block: RichTextBlock) => (block.type === 'attachment' ? <figure>own</figure> : undefined)
|
|
108
|
+
const c = draw([{ type: 'attachment' }, p(t('text'))], { renderBlock: own })
|
|
109
|
+
expect(c.querySelector('figure')?.textContent).toBe('own')
|
|
110
|
+
expect(c.querySelector('p')?.textContent).toBe('text')
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('draws a table, its first row of header cells as the header', () => {
|
|
114
|
+
const c = draw([{ type: 'table', children: [row('tableHeader', 'Label', 'Value'), row('tableCell', 'a', 'b')] }])
|
|
115
|
+
expect([...c.querySelectorAll('thead th')].map((th) => th.textContent)).toEqual(['Label', 'Value'])
|
|
116
|
+
expect([...c.querySelectorAll('tbody td')].map((td) => td.textContent)).toEqual(['a', 'b'])
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('draws a table it cannot read as its words', () => {
|
|
120
|
+
const c = draw([{ type: 'table', inline: [t('flat')] }])
|
|
121
|
+
expect(c.querySelector('table')).toBeNull()
|
|
122
|
+
expect(c.textContent).toBe('flat')
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('carries each block id', () => {
|
|
126
|
+
const c = draw([{ type: 'paragraph', id: 'b1', inline: [t('x')] }])
|
|
127
|
+
expect(c.querySelector('p')?.getAttribute('data-block-id')).toBe('b1')
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('draws nothing for no blocks', () => {
|
|
131
|
+
expect(draw([]).innerHTML).toBe('')
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('keeps the same elements at the small size', () => {
|
|
135
|
+
const blocks: RichTextBlock[] = [{ type: 'heading', level: 1, inline: [t('One')] }, { type: 'bulletList', children: [{ type: 'listItem', inline: [t('a')] }] }]
|
|
136
|
+
const c = draw(blocks, { size: 'small' })
|
|
137
|
+
expect(c.querySelector('h1')).not.toBeNull()
|
|
138
|
+
expect(c.querySelector('ul li')).not.toBeNull()
|
|
139
|
+
expect(c.firstElementChild?.className).toContain('text-caption')
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('gives an editor the same look: the classes on its box are the ones an editor gets', () => {
|
|
143
|
+
for (const size of ['default', 'small'] as const) {
|
|
144
|
+
const c = draw([p(t('x'))], { size })
|
|
145
|
+
expect(c.firstElementChild?.className).toBe(richTextClassName(size))
|
|
146
|
+
}
|
|
147
|
+
expect(richTextClassName('default', 'outline-none')).toContain('outline-none')
|
|
148
|
+
})
|
|
149
|
+
})
|