@estiva-app/ui 0.39.0 → 0.41.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/Chip.d.ts +10 -2
- package/dist/Chip.d.ts.map +1 -1
- package/dist/Link.d.ts +15 -1
- package/dist/Link.d.ts.map +1 -1
- package/dist/RichText.d.ts +78 -0
- package/dist/RichText.d.ts.map +1 -0
- package/dist/eslint/index.js +2 -1
- 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 +25 -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 +365 -220
- package/dist/index.js.map +4 -4
- package/package.json +1 -1
- package/registry.json +117 -7
- package/src/Chip.mdx +13 -2
- package/src/Chip.stories.tsx +2 -0
- package/src/Chip.test.tsx +36 -0
- package/src/Chip.tsx +17 -5
- package/src/Link.mdx +32 -0
- package/src/Link.stories.tsx +49 -0
- package/src/Link.test.tsx +15 -0
- package/src/Link.tsx +23 -2
- 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/index.test.ts +1 -1
- package/src/eslint/no-restyled-part.test.ts +5 -5
- package/src/eslint/no-restyled-part.ts +2 -1
- package/src/gates/app-checks.ts +21 -0
- package/src/gates/create-app.test.ts +7 -2
- 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
|
@@ -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
|
+
})
|
package/src/RichText.tsx
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import type { MouseEvent, ReactNode } from 'react'
|
|
2
|
+
import { cn } from './cn'
|
|
3
|
+
import { Link } from './Link'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Formatted text, drawn: headings, paragraphs, lists, a quote, code, a divider,
|
|
7
|
+
* a table and links.
|
|
8
|
+
*
|
|
9
|
+
* Both apps drew the same thing twice, from the same source — the text tree the
|
|
10
|
+
* protocol package builds (`toRenderTree` → `RenderBlock`, `RenderInline`) —
|
|
11
|
+
* and the two had drifted: headings, list markers, quotes and code differed,
|
|
12
|
+
* one app had tables and the other dropped them, one had links and the other
|
|
13
|
+
* printed the brackets (UIG-30). The look here is Katerina's picks of
|
|
14
|
+
* 28 September, one per difference, most of them Peek's.
|
|
15
|
+
*
|
|
16
|
+
* **It takes the tree, not the text.** The caller parses (the format comes from
|
|
17
|
+
* the item's tag, never from the body) and hands the blocks in. So this package
|
|
18
|
+
* does not depend on the protocol, and a caller can leave out a block it draws
|
|
19
|
+
* elsewhere before passing the rest. The types below are the tree's shape; the
|
|
20
|
+
* protocol's own types fit them.
|
|
21
|
+
*
|
|
22
|
+
* **Safe by construction.** The tree has nothing left to parse, and every
|
|
23
|
+
* string here reaches the page as a React child, so it is text. Nothing is set
|
|
24
|
+
* as HTML.
|
|
25
|
+
*
|
|
26
|
+
* **What a word points at stays the app's.** A person, an item from another
|
|
27
|
+
* app, a name the app recognises in plain text: `renderReference`,
|
|
28
|
+
* `renderText` and `renderBlock` are where each app draws its own.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/** A run of text and the marks on it — the protocol's `RenderInline`. */
|
|
32
|
+
export interface RichTextRun {
|
|
33
|
+
text: string
|
|
34
|
+
marks: readonly string[]
|
|
35
|
+
/** A `nostr:` address this run stands for. `text` is the address itself. */
|
|
36
|
+
reference?: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A block of the tree — the protocol's `RenderBlock`. */
|
|
40
|
+
export interface RichTextBlock {
|
|
41
|
+
type: string
|
|
42
|
+
/** What the document called a block the tree does not know. */
|
|
43
|
+
typeName?: string
|
|
44
|
+
/** The block's address, when it has one. Drawn as `data-block-id`. */
|
|
45
|
+
id?: string
|
|
46
|
+
level?: 1 | 2 | 3
|
|
47
|
+
language?: string
|
|
48
|
+
/** A numbered list's first number. */
|
|
49
|
+
start?: number
|
|
50
|
+
attrs?: Record<string, unknown>
|
|
51
|
+
inline?: readonly RichTextRun[]
|
|
52
|
+
children?: readonly RichTextBlock[]
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type RichTextSize = 'default' | 'small'
|
|
56
|
+
|
|
57
|
+
export interface RichTextProps {
|
|
58
|
+
/** The tree, already parsed. */
|
|
59
|
+
blocks: readonly RichTextBlock[]
|
|
60
|
+
/**
|
|
61
|
+
* Default `default`, the size of text you read. `small` is the same text one
|
|
62
|
+
* step down, with every heading, list, quote and table kept: an item shown
|
|
63
|
+
* inside a card.
|
|
64
|
+
*/
|
|
65
|
+
size?: RichTextSize
|
|
66
|
+
/**
|
|
67
|
+
* Draws plain text: what is left of a run once its links are drawn. Where an
|
|
68
|
+
* app turns words it recognises into chips. By default the text as it is.
|
|
69
|
+
*/
|
|
70
|
+
renderText?: (text: string) => ReactNode
|
|
71
|
+
/** Draws a run that stands for an address. By default its text. */
|
|
72
|
+
renderReference?: (reference: string, run: RichTextRun) => ReactNode
|
|
73
|
+
/**
|
|
74
|
+
* Draws a block this part has no design for (an attachment, a widget). Return
|
|
75
|
+
* `undefined` to leave it to the part, which draws its words.
|
|
76
|
+
*/
|
|
77
|
+
renderBlock?: (block: RichTextBlock) => ReactNode | undefined
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The whole look, as classes on the box around the text: the elements inside
|
|
82
|
+
* carry none. That is so an editor can wear the same classes on its own box and
|
|
83
|
+
* look the same as what it writes — ProseMirror draws the same elements, with a
|
|
84
|
+
* paragraph inside each list item, quote and table cell (the `> p` lines).
|
|
85
|
+
*/
|
|
86
|
+
const RICH_TEXT_CLASSES = {
|
|
87
|
+
base: 'flex flex-col gap-1 break-words text-text-secondary',
|
|
88
|
+
/* A heading binds to what follows it: 12px above (8 and the 4px gap), 8px below (4 and the gap); two headings in a row, 12px apart. Level 3 keeps the text's colour, one step lower. */
|
|
89
|
+
headings: '[&>: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',
|
|
90
|
+
lists: '[&_:is(ul,ol)]:pl-5 [&_li>p]:m-0 [&_li]:break-words [&_li]:marker:text-text-muted [&_ol]:list-decimal [&_ul]:list-disc',
|
|
91
|
+
// eslint-disable-next-line token-spacing/no-restricted-classes -- @estiva-escape: the quote line is Peek's, 3px, between the ramp's 2 and 4
|
|
92
|
+
quote: '[&_blockquote>p]:m-0 [&_blockquote]:border-l-[3px] [&_blockquote]:border-border-strong [&_blockquote]:pl-2.5',
|
|
93
|
+
// eslint-disable-next-line token-values/no-restricted-classes -- @estiva-escape: code is 0.9 of the text around it, so it scales with a heading and with `small`; a named size is fixed
|
|
94
|
+
code: '[&_: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',
|
|
95
|
+
// eslint-disable-next-line token-values/no-restricted-classes -- @estiva-escape: code is 0.9 of the text around it; the code inside the block is the block's own size, not 0.9 of it again
|
|
96
|
+
codeBlock: '[&_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',
|
|
97
|
+
divider: '[&_hr]:border-border-default',
|
|
98
|
+
table: '[&_: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',
|
|
99
|
+
marks: '[&_em]:italic [&_strong]:font-semibold [&_u]:underline [&_u]:underline-offset-2',
|
|
100
|
+
default: 'text-body-2 [&>:is(h2,h3)]:text-body-2 [&>:is(h2,h3)]:font-semibold [&>h1]:text-h4',
|
|
101
|
+
small: 'text-caption leading-snug [&>:is(h2,h3)]:text-caption [&>:is(h2,h3)]:font-semibold [&>h1]:text-body-2 [&>h1]:font-semibold',
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* RichText's look, for a box you draw yourself: an editor's, so that what is
|
|
106
|
+
* being written looks the same as what is read. Put it on the editor's root.
|
|
107
|
+
*/
|
|
108
|
+
export function richTextClassName(size: RichTextSize = 'default', className?: string) {
|
|
109
|
+
const { base, headings, lists, quote, code, codeBlock, divider, table, marks } = RICH_TEXT_CLASSES
|
|
110
|
+
return cn(base, headings, lists, quote, code, codeBlock, divider, table, marks, RICH_TEXT_CLASSES[size], className)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* A link written as `[words](address)`, then a bare address. The written form
|
|
115
|
+
* first, so its address is not found a second time on its own.
|
|
116
|
+
*/
|
|
117
|
+
const LINK_SPLIT = /(\[[^[\]\n]+\]\((?:https?:\/\/|mailto:)[^\s)]+\)|https?:\/\/[^\s]+)/g
|
|
118
|
+
const WRITTEN_LINK = /^\[([^[\]\n]+)\]\(((?:https?:\/\/|mailto:)[^\s)]+)\)$/
|
|
119
|
+
/** Punctuation after a bare address ends the sentence, not the address. */
|
|
120
|
+
const BARE_LINK = /^(https?:\/\/\S+?)([.,;:!?)\]]*)$/
|
|
121
|
+
|
|
122
|
+
/** A link in text is not also a click on what the text sits in, such as a card. */
|
|
123
|
+
const keepToLink = (event: MouseEvent) => event.stopPropagation()
|
|
124
|
+
|
|
125
|
+
function textWithLinks(text: string, renderText: (text: string) => ReactNode): ReactNode {
|
|
126
|
+
const parts = text.split(LINK_SPLIT)
|
|
127
|
+
if (parts.length === 1) return renderText(text)
|
|
128
|
+
return parts.map((part, i) => {
|
|
129
|
+
const written = part.match(WRITTEN_LINK)
|
|
130
|
+
if (written) {
|
|
131
|
+
return (
|
|
132
|
+
<span key={i} className="break-words">
|
|
133
|
+
<Link href={written[2]} external onClick={keepToLink}>{written[1]}</Link>
|
|
134
|
+
</span>
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
const bare = i % 2 === 1 ? part.match(BARE_LINK) : null
|
|
138
|
+
if (bare) {
|
|
139
|
+
return (
|
|
140
|
+
// An address has no spaces to wrap at, so it may break anywhere.
|
|
141
|
+
<span key={i} className="break-all">
|
|
142
|
+
<Link href={bare[1]} external onClick={keepToLink}>{bare[1]}</Link>
|
|
143
|
+
{bare[2]}
|
|
144
|
+
</span>
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
return part ? <span key={i}>{renderText(part)}</span> : null
|
|
148
|
+
})
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
interface Draw {
|
|
152
|
+
renderText: (text: string) => ReactNode
|
|
153
|
+
renderReference: (reference: string, run: RichTextRun) => ReactNode
|
|
154
|
+
renderBlock?: (block: RichTextBlock) => ReactNode | undefined
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function Runs({ runs, draw }: { runs?: readonly RichTextRun[]; draw: Draw }) {
|
|
158
|
+
return (
|
|
159
|
+
<>
|
|
160
|
+
{(runs ?? []).map((run, i) => {
|
|
161
|
+
if (run.reference) return <span key={i}>{draw.renderReference(run.reference, run)}</span>
|
|
162
|
+
// Code carries no other mark, and its words are never read for links.
|
|
163
|
+
if (run.marks.includes('code')) {
|
|
164
|
+
return <code key={i}>{run.text}</code>
|
|
165
|
+
}
|
|
166
|
+
// A line break inside a run is a line break.
|
|
167
|
+
let node: ReactNode = run.text.split('\n').map((line, j) => (
|
|
168
|
+
<span key={j}>
|
|
169
|
+
{j > 0 && <br />}
|
|
170
|
+
{textWithLinks(line, draw.renderText)}
|
|
171
|
+
</span>
|
|
172
|
+
))
|
|
173
|
+
if (run.marks.includes('underline')) node = <u>{node}</u>
|
|
174
|
+
if (run.marks.includes('italic')) node = <em>{node}</em>
|
|
175
|
+
if (run.marks.includes('bold')) node = <strong>{node}</strong>
|
|
176
|
+
return <span key={i}>{node}</span>
|
|
177
|
+
})}
|
|
178
|
+
</>
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const named = (block: RichTextBlock) => block.typeName ?? block.type
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* A table's rows and cells are named by whatever wrote the document; these are
|
|
186
|
+
* the editor's names. A first row of header cells is the header. Returns null
|
|
187
|
+
* for a nesting it does not recognise, which is then drawn as words.
|
|
188
|
+
*/
|
|
189
|
+
function Table({ block, draw }: { block: RichTextBlock; draw: Draw }) {
|
|
190
|
+
const rows = (block.children ?? []).filter((row) => named(row) === 'tableRow')
|
|
191
|
+
if (!rows.length) return null
|
|
192
|
+
const cells = (row: RichTextBlock) =>
|
|
193
|
+
(row.children ?? []).map((cell, i) => {
|
|
194
|
+
const header = named(cell) === 'tableHeader'
|
|
195
|
+
const Cell = header ? 'th' : 'td'
|
|
196
|
+
const { colspan, rowspan } = (cell.attrs ?? {}) as { colspan?: number; rowspan?: number }
|
|
197
|
+
return (
|
|
198
|
+
<Cell
|
|
199
|
+
key={cell.id ?? i}
|
|
200
|
+
data-block-id={cell.id}
|
|
201
|
+
colSpan={colspan && colspan > 1 ? colspan : undefined}
|
|
202
|
+
rowSpan={rowspan && rowspan > 1 ? rowspan : undefined}
|
|
203
|
+
>
|
|
204
|
+
{(cell.children ?? []).map((child, j) => <Runs key={j} runs={child.inline} draw={draw} />)}
|
|
205
|
+
<Runs runs={cell.inline} draw={draw} />
|
|
206
|
+
</Cell>
|
|
207
|
+
)
|
|
208
|
+
})
|
|
209
|
+
const first = rows[0].children ?? []
|
|
210
|
+
const headerRow = first.length > 0 && first.every((cell) => named(cell) === 'tableHeader')
|
|
211
|
+
return (
|
|
212
|
+
<table data-block-id={block.id}>
|
|
213
|
+
{headerRow && (
|
|
214
|
+
<thead>
|
|
215
|
+
<tr data-block-id={rows[0].id}>{cells(rows[0])}</tr>
|
|
216
|
+
</thead>
|
|
217
|
+
)}
|
|
218
|
+
<tbody>
|
|
219
|
+
{(headerRow ? rows.slice(1) : rows).map((row, i) => (
|
|
220
|
+
<tr key={row.id ?? i} data-block-id={row.id}>{cells(row)}</tr>
|
|
221
|
+
))}
|
|
222
|
+
</tbody>
|
|
223
|
+
</table>
|
|
224
|
+
)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function Block({ block, draw }: { block: RichTextBlock; draw: Draw }): ReactNode {
|
|
228
|
+
const own = draw.renderBlock?.(block)
|
|
229
|
+
if (own !== undefined) return own
|
|
230
|
+
const runs = <Runs runs={block.inline} draw={draw} />
|
|
231
|
+
switch (block.type) {
|
|
232
|
+
case 'heading': {
|
|
233
|
+
if (block.level === 1) return <h1 data-block-id={block.id}>{runs}</h1>
|
|
234
|
+
if (block.level === 3) return <h3 data-block-id={block.id}>{runs}</h3>
|
|
235
|
+
return <h2 data-block-id={block.id}>{runs}</h2>
|
|
236
|
+
}
|
|
237
|
+
case 'paragraph':
|
|
238
|
+
return <p data-block-id={block.id}>{runs}</p>
|
|
239
|
+
case 'bulletList':
|
|
240
|
+
case 'orderedList': {
|
|
241
|
+
const items = (block.children ?? []).map((item, i) => (
|
|
242
|
+
<li key={item.id ?? i} data-block-id={item.id}>
|
|
243
|
+
<Runs runs={item.inline} draw={draw} />
|
|
244
|
+
</li>
|
|
245
|
+
))
|
|
246
|
+
return block.type === 'bulletList'
|
|
247
|
+
? <ul data-block-id={block.id}>{items}</ul>
|
|
248
|
+
: <ol data-block-id={block.id} start={block.start}>{items}</ol>
|
|
249
|
+
}
|
|
250
|
+
case 'blockquote':
|
|
251
|
+
return <blockquote data-block-id={block.id}>{runs}</blockquote>
|
|
252
|
+
case 'codeBlock':
|
|
253
|
+
// Literal: no marks, no links, no chips. A long line wraps; nothing scrolls sideways.
|
|
254
|
+
return (
|
|
255
|
+
<pre data-block-id={block.id} data-language={block.language}>
|
|
256
|
+
<code>{(block.inline ?? []).map((run) => run.text).join('')}</code>
|
|
257
|
+
</pre>
|
|
258
|
+
)
|
|
259
|
+
case 'horizontalRule':
|
|
260
|
+
return <hr data-block-id={block.id} />
|
|
261
|
+
case 'table': {
|
|
262
|
+
const table = <Table block={block} draw={draw} />
|
|
263
|
+
if ((block.children ?? []).some((row) => named(row) === 'tableRow')) return table
|
|
264
|
+
break
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
/*
|
|
268
|
+
Anything else — a type this part has no design for, or one published after
|
|
269
|
+
it was written — is drawn as its words, never dropped: a block that
|
|
270
|
+
vanishes looks like one you are not allowed to see.
|
|
271
|
+
*/
|
|
272
|
+
return (
|
|
273
|
+
<div data-block-id={block.id} data-block-type={named(block)}>
|
|
274
|
+
{runs}
|
|
275
|
+
{(block.children ?? []).map((child, i) => <Block key={child.id ?? i} block={child} draw={draw} />)}
|
|
276
|
+
</div>
|
|
277
|
+
)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export function RichText({
|
|
281
|
+
blocks,
|
|
282
|
+
size = 'default',
|
|
283
|
+
renderText = (text) => text,
|
|
284
|
+
renderReference = (_reference, run) => run.text,
|
|
285
|
+
renderBlock,
|
|
286
|
+
}: RichTextProps) {
|
|
287
|
+
if (blocks.length === 0) return null
|
|
288
|
+
const draw: Draw = { renderText, renderReference, renderBlock }
|
|
289
|
+
return (
|
|
290
|
+
<div data-rich-text className={richTextClassName(size)}>
|
|
291
|
+
{blocks.map((block, i) => <Block key={block.id ?? i} block={block} draw={draw} />)}
|
|
292
|
+
</div>
|
|
293
|
+
)
|
|
294
|
+
}
|
package/src/eslint/index.test.ts
CHANGED
|
@@ -139,7 +139,7 @@ describe('an app lint with configs.recommended', () => {
|
|
|
139
139
|
[
|
|
140
140
|
'estiva/no-restyled-part',
|
|
141
141
|
2,
|
|
142
|
-
'`text-h2` on `Link` changes how it looks. A part of @estiva-app/ui is placed from outside, never restyled: only space, size, flex and grid, and position pass in. Use its `variant` or `
|
|
142
|
+
'`text-h2` on `Link` changes how it looks. A part of @estiva-app/ui is placed from outside, never restyled: only space, size, flex and grid, and position pass in. Use its `variant`, `truncate` or `cover`. A look for what is around it goes on your own element around it.',
|
|
143
143
|
],
|
|
144
144
|
])
|
|
145
145
|
})
|
|
@@ -115,7 +115,7 @@ tester.run('no-restyled-part', noRestyledPart, {
|
|
|
115
115
|
name: 'a look behind a variant, and an extra layer',
|
|
116
116
|
filename: app,
|
|
117
117
|
code: ui('Link', ' <Link href="/x" className="signal:hover:bg-success-muted after:absolute [&>*]:shrink-0">x</Link>'),
|
|
118
|
-
errors: [{ message: restyled('`signal:hover:bg-success-muted` `after:absolute` `[&>*]:shrink-0`', '`Link`', ' Use its `variant` or `
|
|
118
|
+
errors: [{ message: restyled('`signal:hover:bg-success-muted` `after:absolute` `[&>*]:shrink-0`', '`Link`', ' Use its `variant`, `truncate` or `cover`.') }],
|
|
119
119
|
},
|
|
120
120
|
{
|
|
121
121
|
name: 'an inner box’s class prop',
|
|
@@ -184,7 +184,7 @@ tester.run('no-restyled-part', noRestyledPart, {
|
|
|
184
184
|
name: 'a wrapper declared in the same file',
|
|
185
185
|
filename: app,
|
|
186
186
|
code: "import { Link, type LinkProps } from '@estiva-app/ui'\nfunction RouterLink({ href, ...props }: LinkProps) {\n return <Link href={href} {...props} />\n}\nexport function Probe() {\n return <RouterLink href=\"/x\" className=\"text-h5\">x</RouterLink>\n}\n",
|
|
187
|
-
errors: [{ message: restyled('`text-h5`', '`RouterLink` (it hands its props to `Link`)', ' Use its `variant` or `
|
|
187
|
+
errors: [{ message: restyled('`text-h5`', '`RouterLink` (it hands its props to `Link`)', ' Use its `variant`, `truncate` or `cover`.') }],
|
|
188
188
|
},
|
|
189
189
|
{
|
|
190
190
|
name: 'EmptyState padded directly',
|
|
@@ -196,19 +196,19 @@ tester.run('no-restyled-part', noRestyledPart, {
|
|
|
196
196
|
name: 'an escape with no reason is reported, and so is the class',
|
|
197
197
|
filename: app,
|
|
198
198
|
code: ui('Link', ' // @estiva-escape(no-restyled-part):\n <Link href="/x" className="text-h2">x</Link>'),
|
|
199
|
-
errors: [{ messageId: 'escapeWithoutReason' }, { message: restyled('`text-h2`', '`Link`', ' Use its `variant` or `
|
|
199
|
+
errors: [{ messageId: 'escapeWithoutReason' }, { message: restyled('`text-h2`', '`Link`', ' Use its `variant`, `truncate` or `cover`.') }],
|
|
200
200
|
},
|
|
201
201
|
{
|
|
202
202
|
name: 'inside the package: a part imported from a sibling',
|
|
203
203
|
filename: pkg,
|
|
204
204
|
code: local('Link', './Link', ' <Link href="/x" className="rounded-lg border">x</Link>'),
|
|
205
|
-
errors: [{ message: restyled('`rounded-lg` `border`', '`Link`', ' Use its `variant` or `
|
|
205
|
+
errors: [{ message: restyled('`rounded-lg` `border`', '`Link`', ' Use its `variant`, `truncate` or `cover`.') }],
|
|
206
206
|
},
|
|
207
207
|
{
|
|
208
208
|
name: 'inside the package: a part used in the file that declares it',
|
|
209
209
|
filename: pkg,
|
|
210
210
|
code: 'export function Link() {\n return null\n}\nexport function Probe() {\n return <Link className="text-caption" />\n}\n',
|
|
211
|
-
errors: [{ message: restyled('`text-caption`', '`Link`', ' Use its `variant` or `
|
|
211
|
+
errors: [{ message: restyled('`text-caption`', '`Link`', ' Use its `variant`, `truncate` or `cover`.') }],
|
|
212
212
|
},
|
|
213
213
|
],
|
|
214
214
|
})
|
|
@@ -119,7 +119,7 @@ export const PART_LOOK_PROPS: Record<string, string[]> = {
|
|
|
119
119
|
IconButton: ['variant', 'pressed', 'glow'],
|
|
120
120
|
InputChip: ['truncate'],
|
|
121
121
|
Kbd: ['tone'],
|
|
122
|
-
Link: ['variant', 'truncate'],
|
|
122
|
+
Link: ['variant', 'truncate', 'cover'],
|
|
123
123
|
MenuItem: ['size', 'selected', 'destructive'],
|
|
124
124
|
MenuSub: ['selected'],
|
|
125
125
|
NavItem: ['active'],
|
|
@@ -129,6 +129,7 @@ export const PART_LOOK_PROPS: Record<string, string[]> = {
|
|
|
129
129
|
RailItem: ['active'],
|
|
130
130
|
Reaction: ['pressed'],
|
|
131
131
|
ReactionPicker: ['surface'],
|
|
132
|
+
RichText: ['size'],
|
|
132
133
|
SectionHeader: ['hover'],
|
|
133
134
|
SectionLabel: ['tone', 'truncate'],
|
|
134
135
|
Select: ['size'],
|
package/src/gates/app-checks.ts
CHANGED
|
@@ -254,8 +254,29 @@ export function appChecks(h: GateHelpers, { app = '.', page, chain = { ref: 'UIG
|
|
|
254
254
|
return probe(code, 'none', undefined, 'src/components/__gates_probe__.test.tsx', config)()
|
|
255
255
|
}), 'a test file is not checked for hand-written values, while source is') },
|
|
256
256
|
]),
|
|
257
|
+
// Built 28 September: both apps drew the protocol's text tree by hand, and drifted apart.
|
|
258
|
+
// A file that reads the protocol and draws a quote or a numbered list itself is a third copy.
|
|
257
259
|
ticket('UIG-30', [
|
|
258
260
|
{ what: 'the installed package has RichText', run: () => h.contains(installed, /\bRichText\b/, 'the installed @estiva-app/ui exports RichText') },
|
|
261
|
+
{ what: "no file draws the protocol's text tree by hand", run: () => {
|
|
262
|
+
const sources = h.listFiles(at('src'), (n) => /\.tsx$/.test(n) && !/\.(test|stories)\.tsx$/.test(n))
|
|
263
|
+
const own = sources.filter((f) => { const text = h.read(f); return /from ['"]@estiva-app\/protocol['"]/.test(text) && /<(?:blockquote|ol)\b/.test(text) })
|
|
264
|
+
return own.length ? h.FAIL(`draws the text tree itself (${own.join(', ')}): pass the tree to RichText`) : h.PASS('formatted text is drawn by RichText only')
|
|
265
|
+
} },
|
|
266
|
+
]),
|
|
267
|
+
// UIG-33, Katerina's picks of 28 September: a row that is one link is `Link` with `cover`, and a
|
|
268
|
+
// chip that is a link is `Chip` with `href`. Before, both apps stretched a link over its row by
|
|
269
|
+
// hand (`after:absolute`) and rounded a Link round a chip, each under an escape.
|
|
270
|
+
ticket('UIG-33', [
|
|
271
|
+
{ what: 'the installed package has Link cover and Chip href', run: all(h, [
|
|
272
|
+
() => h.contains(at('node_modules/@estiva-app/ui/dist/Link.d.ts'), /\bcover\?: boolean/, 'the installed Link takes cover'),
|
|
273
|
+
() => h.contains(at('node_modules/@estiva-app/ui/dist/Chip.d.ts'), /\bhref\?: string/, 'the installed Chip takes href'),
|
|
274
|
+
], 'the installed Link takes cover and Chip takes href') },
|
|
275
|
+
{ what: 'no link is stretched over its row by hand', run: () => {
|
|
276
|
+
const sources = h.listFiles(at('src'), (n) => /\.tsx$/.test(n) && !/\.(test|stories)\.tsx$/.test(n))
|
|
277
|
+
const own = sources.filter((f) => /<(?:Link|a)\b[^>]*\bafter:(?:absolute|inset-0)\b/.test(h.read(f)))
|
|
278
|
+
return own.length ? h.FAIL(`stretches a link by hand (${own.join(', ')}): give the Link cover`) : h.PASS('every whole-row link is Link with cover')
|
|
279
|
+
} },
|
|
259
280
|
]),
|
|
260
281
|
// Katerina's ruling of 17 September (docs/GATES.md §23): one copy, in the package. An app made
|
|
261
282
|
// by create-estiva-app passes this from its first commit; Peek and Ship do once UIG-32 lands.
|
|
@@ -194,8 +194,13 @@ describe('a made app, under the checks every app runs', () => {
|
|
|
194
194
|
const real = helpers(dir)
|
|
195
195
|
// "The installed package exports Link" reads its declarations, which CI's
|
|
196
196
|
// test step does not build (`pretest` bundles only): the same exports are read
|
|
197
|
-
// from their source when the declarations are not there.
|
|
198
|
-
|
|
197
|
+
// from their source when the declarations are not there. A part's own props
|
|
198
|
+
// (UIG-33: `Link.d.ts`) are read from its source file the same way.
|
|
199
|
+
const exportsFrom = (rel: string) => {
|
|
200
|
+
const declared = rel.replace(/\\/g, '/').match(/dist\/(\w+)\.d\.ts$/)
|
|
201
|
+
if (!declared || real.exists(rel)) return rel
|
|
202
|
+
return declared[1] === 'index' ? join(process.cwd(), 'src', 'index.ts') : join(process.cwd(), 'src', `${declared[1]}.tsx`)
|
|
203
|
+
}
|
|
199
204
|
const h: GateHelpers = {
|
|
200
205
|
...real,
|
|
201
206
|
contains: (rel, needle, label) => real.contains(exportsFrom(rel), needle, label),
|
package/src/index.ts
CHANGED
|
@@ -46,6 +46,7 @@ export {
|
|
|
46
46
|
} from './InlineChip'
|
|
47
47
|
export { Lightbox, type LightboxProps } from './Lightbox'
|
|
48
48
|
export { Link, type LinkProps, type LinkVariant } from './Link'
|
|
49
|
+
export { RichText, richTextClassName, type RichTextProps, type RichTextBlock, type RichTextRun, type RichTextSize } from './RichText'
|
|
49
50
|
export { IconButton, type IconButtonProps, type IconButtonVariant } from './IconButton'
|
|
50
51
|
export { Kbd, type KbdProps } from './Kbd'
|
|
51
52
|
export { IdentityMenu, IdentityPanel, type Identity, type IdentityMenuProps, type IdentityPanelProps } from './IdentityMenu'
|
|
@@ -45,17 +45,17 @@ describe('the registry builds', () => {
|
|
|
45
45
|
expect(registry.entries.map((one) => one.name).sort()).toEqual(values.map((one) => one.name).sort())
|
|
46
46
|
})
|
|
47
47
|
|
|
48
|
-
it('counts
|
|
48
|
+
it('counts 90 names over 59 files: 82 components, 7 helpers and 1 hook (RichText and richTextClassName joined on 28 September)', () => {
|
|
49
49
|
// The reconciliation GATES.md §24 explains. If this changes, that changes.
|
|
50
50
|
const kinds = registry.entries.reduce<Record<string, number>>((all, one) => ({ ...all, [one.kind]: (all[one.kind] ?? 0) + 1 }), {})
|
|
51
|
-
expect(kinds).toEqual({ component:
|
|
52
|
-
expect(new Set(registry.entries.map((one) => one.sourceFile)).size).toBe(
|
|
51
|
+
expect(kinds).toEqual({ component: 82, helper: 7, hook: 1 })
|
|
52
|
+
expect(new Set(registry.entries.map((one) => one.sourceFile)).size).toBe(59)
|
|
53
53
|
})
|
|
54
54
|
|
|
55
55
|
it('gives every entry a purpose, from its own page or from the comment above it', () => {
|
|
56
56
|
expect(registry.entries.filter((one) => one.purpose.trim() === '')).toEqual([])
|
|
57
|
-
expect(registry.entries.filter((one) => one.purposeFrom === 'page').length).toBe(
|
|
58
|
-
expect(registry.entries.filter((one) => one.purposeFrom === 'comment').length).toBe(
|
|
57
|
+
expect(registry.entries.filter((one) => one.purposeFrom === 'page').length).toBe(59)
|
|
58
|
+
expect(registry.entries.filter((one) => one.purposeFrom === 'comment').length).toBe(31)
|
|
59
59
|
})
|
|
60
60
|
|
|
61
61
|
it('takes a name with a page of its own from the page, and a name without one from the code', () => {
|
package/stories/Choosing.mdx
CHANGED
|
@@ -87,6 +87,7 @@ fork freely, owe nothing back, mention it on the package's ticket.
|
|
|
87
87
|
| A link — in text, on a title, beside a note, or a whole box | **Link** — `text`, `quiet`, `underlined`, `plain`; `external` for a new tab |
|
|
88
88
|
| A document or an image attached to something, posted or waiting to be sent | **AttachmentCard** — the file decides the shape; `pending` in a composer |
|
|
89
89
|
| A person or a thing named inside a sentence | **InlineChip** — one line high, level with the words; `href` makes it a link |
|
|
90
|
+
| Formatted text: headings, lists, a quote, code, a table, links | **RichText** — pass the parsed tree; `small` inside a card |
|
|
90
91
|
| The key that does this too | **Kbd** — or the `shortcut` prop on **MenuItem**, **Tooltip** and **SearchInput** |
|
|
91
92
|
|
|
92
93
|
## Feedback
|
package/tailwind-preset.js
CHANGED
|
@@ -153,6 +153,7 @@ export default {
|
|
|
153
153
|
'bg-tooltip': 'var(--bg-tooltip)',
|
|
154
154
|
'bg-tooltip-key': 'var(--bg-tooltip-key)',
|
|
155
155
|
'bg-field': 'var(--bg-field)',
|
|
156
|
+
'bg-code': 'var(--bg-code)',
|
|
156
157
|
// text
|
|
157
158
|
'text-primary': 'var(--text-primary)',
|
|
158
159
|
'text-secondary': 'var(--text-secondary)',
|