@estiva-app/ui 0.65.2 → 0.65.3
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 +2 -0
- package/dist/RichText.d.ts.map +1 -1
- package/dist/index.js +21 -2
- package/dist/index.js.map +2 -2
- package/package.json +1 -1
- package/registry.json +1 -1
- package/src/RichText.stories.tsx +27 -0
- package/src/RichText.test.tsx +35 -0
- package/src/RichText.tsx +40 -8
package/package.json
CHANGED
package/registry.json
CHANGED
package/src/RichText.stories.tsx
CHANGED
|
@@ -84,6 +84,33 @@ export const WithTheAppsOwnParts: Story = {
|
|
|
84
84
|
},
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
/** Items indented under the one above, two levels deep, in both lists: each level is the type of the list it sits in. */
|
|
88
|
+
export const IndentedItems: Story = {
|
|
89
|
+
args: {
|
|
90
|
+
blocks: [
|
|
91
|
+
{
|
|
92
|
+
type: 'bulletList',
|
|
93
|
+
children: [
|
|
94
|
+
{ type: 'listItem', inline: [t('Item one')] },
|
|
95
|
+
{ type: 'listItem', indent: 1, inline: [t('Item one, under it')] },
|
|
96
|
+
{ type: 'listItem', indent: 2, inline: [t('A level further in')] },
|
|
97
|
+
{ type: 'listItem', indent: 1, inline: [t('Back one level')] },
|
|
98
|
+
{ type: 'listItem', inline: [t('Item two')] },
|
|
99
|
+
],
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
type: 'orderedList',
|
|
103
|
+
children: [
|
|
104
|
+
{ type: 'listItem', inline: [t('The first step')] },
|
|
105
|
+
{ type: 'listItem', indent: 1, inline: [t('Each level counts from one')] },
|
|
106
|
+
{ type: 'listItem', indent: 1, inline: [t('Then two')] },
|
|
107
|
+
{ type: 'listItem', inline: [t('The second step')] },
|
|
108
|
+
],
|
|
109
|
+
},
|
|
110
|
+
],
|
|
111
|
+
},
|
|
112
|
+
}
|
|
113
|
+
|
|
87
114
|
/** A block this part has no design for, from a later version: its words are drawn, never dropped. */
|
|
88
115
|
export const UnknownBlock: Story = {
|
|
89
116
|
args: {
|
package/src/RichText.test.tsx
CHANGED
|
@@ -147,3 +147,38 @@ describe('RichText', () => {
|
|
|
147
147
|
expect(richTextClassName('default', 'outline-none')).toContain('outline-none')
|
|
148
148
|
})
|
|
149
149
|
})
|
|
150
|
+
|
|
151
|
+
describe('an indented list item — MAN-13', () => {
|
|
152
|
+
const item = (id: string, text: string, indent?: number): RichTextBlock => ({ type: 'listItem', id, inline: [t(text)], ...(indent ? { indent } : {}) })
|
|
153
|
+
|
|
154
|
+
it('draws an item inside the one above, in a list of the same type with no id', () => {
|
|
155
|
+
const { container } = render(
|
|
156
|
+
<RichText blocks={[{ type: 'orderedList', id: 'l1', children: [item('i1', 'intro'), item('i2', 'after', 1), item('i3', 'deeper', 2), item('i4', 'back')] }]} />,
|
|
157
|
+
)
|
|
158
|
+
const outer = container.querySelector('ol[data-block-id="l1"]')!
|
|
159
|
+
expect([...outer.children].map((li) => li.getAttribute('data-block-id'))).toEqual(['i1', 'i4'])
|
|
160
|
+
const inner = outer.querySelector(':scope > li > ol')!
|
|
161
|
+
expect(inner.hasAttribute('data-block-id')).toBe(false)
|
|
162
|
+
expect(inner.querySelector(':scope > li')?.getAttribute('data-block-id')).toBe('i2')
|
|
163
|
+
expect(inner.querySelector(':scope > li > ol > li')?.textContent).toBe('deeper')
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('never draws an item more than one level below the one before it', () => {
|
|
167
|
+
const { container } = render(<RichText blocks={[{ type: 'bulletList', children: [item('a', 'first', 2), item('b', 'next', 3)] }]} />)
|
|
168
|
+
const outer = container.querySelector('ul')!
|
|
169
|
+
expect([...outer.children].map((li) => li.getAttribute('data-block-id'))).toEqual(['a'])
|
|
170
|
+
expect(outer.querySelector(':scope > li > ul > li')?.getAttribute('data-block-id')).toBe('b')
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('never nests deeper than 8 levels (SPEC §13.3)', () => {
|
|
174
|
+
const deep = Array.from({ length: 20 }, (_, i) => item(`d${i}`, `level ${i}`, i))
|
|
175
|
+
const { container } = render(<RichText blocks={[{ type: 'bulletList', children: deep }]} />)
|
|
176
|
+
expect(container.querySelectorAll('ul').length).toBe(9)
|
|
177
|
+
expect(container.querySelector('[data-block-id="d19"]')!.parentElement).toBe(container.querySelector('[data-block-id="d8"]')!.parentElement)
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it('draws an item with an indent that is not a whole number above 0 at the left', () => {
|
|
181
|
+
const { container } = render(<RichText blocks={[{ type: 'bulletList', children: [item('a', 'one'), { ...item('b', 'two'), indent: -1 }, { ...item('c', 'three'), indent: Number.NaN }]}]} />)
|
|
182
|
+
expect([...container.querySelector('ul')!.children].map((li) => li.getAttribute('data-block-id'))).toEqual(['a', 'b', 'c'])
|
|
183
|
+
})
|
|
184
|
+
})
|
package/src/RichText.tsx
CHANGED
|
@@ -49,6 +49,8 @@ export interface RichTextBlock {
|
|
|
49
49
|
language?: string
|
|
50
50
|
/** A numbered list's first number. */
|
|
51
51
|
start?: number
|
|
52
|
+
/** How far a list item is indented under the items above it. A list's items stay flat; this draws them nested. */
|
|
53
|
+
indent?: number
|
|
52
54
|
attrs?: Record<string, unknown>
|
|
53
55
|
inline?: readonly RichTextRun[]
|
|
54
56
|
children?: readonly RichTextBlock[]
|
|
@@ -237,6 +239,31 @@ function Table({ block, draw }: { block: RichTextBlock; draw: Draw }) {
|
|
|
237
239
|
)
|
|
238
240
|
}
|
|
239
241
|
|
|
242
|
+
interface NestedItem {
|
|
243
|
+
item: RichTextBlock
|
|
244
|
+
key: number
|
|
245
|
+
below: NestedItem[]
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const MAX_DEPTH = 8
|
|
249
|
+
|
|
250
|
+
/** A list's flat items, each under the last item one level up — never more than one level below the item before. */
|
|
251
|
+
function nestItems(items: readonly RichTextBlock[]): NestedItem[] {
|
|
252
|
+
const top: NestedItem[] = []
|
|
253
|
+
// The last item seen at each depth.
|
|
254
|
+
const path: NestedItem[] = []
|
|
255
|
+
items.forEach((item, key) => {
|
|
256
|
+
const asked = Number.isInteger(item.indent) && item.indent! > 0 ? item.indent! : 0
|
|
257
|
+
// SPEC §13.3: never deeper than 8, so a hostile list cannot nest thousands deep.
|
|
258
|
+
const depth = Math.min(asked, path.length, MAX_DEPTH)
|
|
259
|
+
path.length = depth
|
|
260
|
+
const node: NestedItem = { item, key, below: [] }
|
|
261
|
+
;(depth === 0 ? top : path[depth - 1].below).push(node)
|
|
262
|
+
path.push(node)
|
|
263
|
+
})
|
|
264
|
+
return top
|
|
265
|
+
}
|
|
266
|
+
|
|
240
267
|
function Block({ block, draw }: { block: RichTextBlock; draw: Draw }): ReactNode {
|
|
241
268
|
const own = draw.renderBlock?.(block)
|
|
242
269
|
if (own !== undefined) return own
|
|
@@ -251,14 +278,19 @@ function Block({ block, draw }: { block: RichTextBlock; draw: Draw }): ReactNode
|
|
|
251
278
|
return <p data-block-id={block.id}>{runs}</p>
|
|
252
279
|
case 'bulletList':
|
|
253
280
|
case 'orderedList': {
|
|
254
|
-
const
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
281
|
+
const ordered = block.type === 'orderedList'
|
|
282
|
+
const items = (nodes: readonly NestedItem[]): ReactNode[] =>
|
|
283
|
+
nodes.map(({ item, key, below }) => (
|
|
284
|
+
<li key={item.id ?? key} data-block-id={item.id}>
|
|
285
|
+
<Runs runs={item.inline} draw={draw} />
|
|
286
|
+
{/* The list an indented item sits in is drawn, not stored: it has no id, and the type of the list around it. */}
|
|
287
|
+
{below.length > 0 && (ordered ? <ol>{items(below)}</ol> : <ul>{items(below)}</ul>)}
|
|
288
|
+
</li>
|
|
289
|
+
))
|
|
290
|
+
const top = items(nestItems(block.children ?? []))
|
|
291
|
+
return ordered
|
|
292
|
+
? <ol data-block-id={block.id} start={block.start}>{top}</ol>
|
|
293
|
+
: <ul data-block-id={block.id}>{top}</ul>
|
|
262
294
|
}
|
|
263
295
|
case 'blockquote':
|
|
264
296
|
return <blockquote data-block-id={block.id}>{runs}</blockquote>
|