@estiva-app/ui 0.65.1 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@estiva-app/ui",
3
- "version": "0.65.1",
3
+ "version": "0.65.3",
4
4
  "description": "Estiva's design tokens (the contract) and a small set of primitives (a convenience) for every Estiva app.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/registry.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "kind": "package",
5
5
  "repo": "estiva-ui",
6
6
  "package": "@estiva-app/ui",
7
- "packageVersion": "0.65.1",
7
+ "packageVersion": "0.65.3",
8
8
  "exports": 140,
9
9
  "typeExports": 145,
10
10
  "files": null
@@ -477,3 +477,99 @@ describe('what it draws', () => {
477
477
  expect(container.querySelector('h1')?.getAttribute('data-block-id')).toBe('h1')
478
478
  })
479
479
  })
480
+
481
+ describe('Tab on a list item — MAN-13', () => {
482
+ /** A key as a person presses it: through the editor's own key handling, not a command. */
483
+ const press = (key: string, shiftKey = false) =>
484
+ editor!.view.someProp('handleKeyDown', (f) => f(editor!.view, new KeyboardEvent('keydown', { key, shiftKey })))
485
+ const LIST = {
486
+ type: 'doc',
487
+ content: [
488
+ {
489
+ type: 'bulletList',
490
+ attrs: { blockId: 'l1' },
491
+ content: [
492
+ { type: 'listItem', attrs: { blockId: 'i1' }, content: [{ type: 'paragraph', content: [{ type: 'text', text: 'intro' }] }] },
493
+ { type: 'listItem', attrs: { blockId: 'i2' }, content: [{ type: 'paragraph', content: [{ type: 'text', text: 'after' }] }] },
494
+ ],
495
+ },
496
+ ],
497
+ }
498
+ /** The end of "after": 1 opens the list, 2 the first item, 3 its paragraph, then "intro", two closes, two opens. */
499
+ const END_OF_AFTER = 1 + 1 + 1 + 5 + 2 + 2 + 5
500
+
501
+ it('puts the item in a list inside the one above, keeping its id, and gives that list none', () => {
502
+ editor = new Editor({ extensions: EXTENSIONS, content: LIST })
503
+ editor.commands.setTextSelection(END_OF_AFTER)
504
+ expect(press('Tab')).toBe(true)
505
+ const list = (editor.getJSON() as Json).content![0]
506
+ expect(list.content!.map((n) => n.attrs?.blockId)).toEqual(['i1'])
507
+ const inner = list.content![0].content![1]
508
+ expect(inner.type).toBe('bulletList')
509
+ expect(inner.attrs?.blockId ?? null).toBeNull()
510
+ expect(inner.content![0].attrs?.blockId).toBe('i2')
511
+ expect(JSON.stringify(inner)).toContain('after')
512
+ })
513
+
514
+ it('names an item made by Enter inside the inner list, so a save does not re-mint it each time', () => {
515
+ editor = new Editor({ extensions: EXTENSIONS, content: LIST })
516
+ editor.commands.setTextSelection(END_OF_AFTER)
517
+ press('Tab')
518
+ press('Enter')
519
+ editor.commands.insertContent('third')
520
+ const inner = (editor.getJSON() as Json).content![0].content![0].content![1]
521
+ const ids = inner.content!.map((n) => n.attrs?.blockId)
522
+ expect(ids[0]).toBe('i2')
523
+ expect(typeof ids[1]).toBe('string')
524
+ expect(ids[1]).not.toBe('i2')
525
+ expect(inner.attrs?.blockId ?? null).toBeNull()
526
+ })
527
+
528
+ it('names an item in an inner list that arrives without an id, as it does one in the outer list', async () => {
529
+ const nested = structuredClone(LIST)
530
+ const [first, second] = nested.content[0].content
531
+ ;(first.content as unknown[]).push({ type: 'bulletList', content: [{ ...second, attrs: {} }] })
532
+ nested.content[0].content = [first]
533
+ editor = new Editor({ extensions: EXTENSIONS, content: nested })
534
+ // The editor's `create` comes a tick after it mounts.
535
+ await new Promise((resolve) => setTimeout(resolve, 0))
536
+ const inner = (editor.getJSON() as Json).content![0].content![0].content![1]
537
+ expect(typeof inner.content![0].attrs?.blockId).toBe('string')
538
+ expect(inner.attrs?.blockId ?? null).toBeNull()
539
+ })
540
+
541
+ it('brings it back out with Shift+Tab, id and text kept', () => {
542
+ editor = new Editor({ extensions: EXTENSIONS, content: LIST })
543
+ editor.commands.setTextSelection(END_OF_AFTER)
544
+ press('Tab')
545
+ press('Tab', true)
546
+ const list = (editor.getJSON() as Json).content![0]
547
+ expect(list.content!.map((n) => n.attrs?.blockId)).toEqual(['i1', 'i2'])
548
+ expect(list.content![0].content).toHaveLength(1)
549
+ })
550
+
551
+ it('clears an id an inner list arrives with, since the save writes no block for it', () => {
552
+ const nested = structuredClone(LIST)
553
+ const [first, second] = nested.content[0].content
554
+ ;(first.content as unknown[]).push({ type: 'bulletList', attrs: { blockId: 'stray' }, content: [second] })
555
+ nested.content[0].content = [first]
556
+ editor = new Editor({ extensions: EXTENSIONS, content: nested })
557
+ // Any change that brings in a block runs the check; Enter at the end of "after" is one.
558
+ editor.commands.setTextSelection(END_OF_AFTER)
559
+ press('Enter')
560
+ expect(JSON.stringify(editor.getJSON())).not.toContain('stray')
561
+ })
562
+
563
+ it('never lets an inner list keep a real block\'s id: the block keeps it', () => {
564
+ const nested = structuredClone(LIST)
565
+ const [first, second] = nested.content[0].content
566
+ ;(first.content as unknown[]).push({ type: 'bulletList', attrs: { blockId: 'i1' }, content: [second] })
567
+ nested.content[0].content = [first]
568
+ editor = new Editor({ extensions: EXTENSIONS, content: nested })
569
+ editor.commands.setTextSelection(END_OF_AFTER)
570
+ press('Enter')
571
+ const item = (editor.getJSON() as Json).content![0].content![0]
572
+ expect(item.attrs?.blockId).toBe('i1')
573
+ expect(item.content![1].attrs?.blockId ?? null).toBeNull()
574
+ })
575
+ })
@@ -72,9 +72,12 @@ interface Claim {
72
72
  /** Every block that claims an id, by id, in document order. */
73
73
  function claimsIn(doc: ProseMirrorNode): Map<string, Claim[]> {
74
74
  const claims = new Map<string, Claim[]>()
75
- doc.descendants((node, pos) => {
75
+ doc.descendants((node, pos, parent) => {
76
76
  const fixed = node.type.name === 'unknownBlock'
77
77
  if (!fixed && !BLOCK_TYPE_SET.has(node.type.name)) return
78
+ // A list inside a list item is no block (MAN-13): an id it arrived with is
79
+ // cleared, never a claim that could take a real block's.
80
+ if (parent?.type.name === 'listItem' && LISTS.has(node.type.name)) return
78
81
  const id = node.attrs.blockId
79
82
  if (typeof id !== 'string' || id === '') return
80
83
  const copies = claims.get(id)
@@ -151,6 +154,22 @@ const BLOCK_CONTAINERS = new Set(['bulletList', 'orderedList', 'table', 'tableRo
151
154
  /** The wrappers the save flattens: their paragraphs become inline runs of theirs. */
152
155
  const FLATTENED = new Set(['listItem', 'blockquote'])
153
156
 
157
+ /**
158
+ * The lists Tab puts inside a list item (MAN-13). §13.3 keeps a list flat and
159
+ * marks an item's depth, so the save walks into such a list for its items and
160
+ * writes no block for the list itself: its items get ids, it gets none.
161
+ */
162
+ const LISTS = new Set(['bulletList', 'orderedList'])
163
+
164
+ /** The lists inside a list item, with where each starts. */
165
+ function innerLists(item: ProseMirrorNode, pos: number): { pos: number; node: ProseMirrorNode }[] {
166
+ const out: { pos: number; node: ProseMirrorNode }[] = []
167
+ item.forEach((child, offset) => {
168
+ if (LISTS.has(child.type.name)) out.push({ pos: pos + 1 + offset, node: child })
169
+ })
170
+ return out
171
+ }
172
+
154
173
  const idOf = (node: ProseMirrorNode): string | undefined => {
155
174
  const id = node.attrs.blockId
156
175
  return typeof id === 'string' && id !== '' ? id : undefined
@@ -163,6 +182,9 @@ function addressedBlocks(doc: ProseMirrorNode): { pos: number; node: ProseMirror
163
182
  if (!BLOCK_TYPE_SET.has(node.type.name)) return
164
183
  out.push({ pos, node })
165
184
  if (BLOCK_CONTAINERS.has(node.type.name)) node.forEach((child, offset) => visit(child, pos + 1 + offset))
185
+ if (node.type.name === 'listItem') {
186
+ for (const list of innerLists(node, pos)) list.node.forEach((child, offset) => visit(child, list.pos + 1 + offset))
187
+ }
166
188
  }
167
189
  doc.forEach((node, offset) => visit(node, offset))
168
190
  return out
@@ -175,13 +197,17 @@ function unnamedBlocks(doc: ProseMirrorNode): number[] {
175
197
  .map((b) => b.pos)
176
198
  }
177
199
 
178
- /** Where a paragraph inside a list item or a quote claims an id — which the save drops. */
200
+ /**
201
+ * Where a paragraph inside a list item or a quote claims an id, or a list
202
+ * inside a list item does — both of which the save drops.
203
+ */
179
204
  function hiddenIds(doc: ProseMirrorNode): number[] {
180
205
  const out: number[] = []
181
206
  doc.descendants((node, pos) => {
182
207
  if (!FLATTENED.has(node.type.name)) return
183
208
  node.forEach((child, offset) => {
184
- if (child.isTextblock && idOf(child) !== undefined) out.push(pos + 1 + offset)
209
+ const inner = child.isTextblock || (node.type.name === 'listItem' && LISTS.has(child.type.name))
210
+ if (inner && idOf(child) !== undefined) out.push(pos + 1 + offset)
185
211
  })
186
212
  })
187
213
  return out
@@ -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: {
@@ -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 items = (block.children ?? []).map((item, i) => (
255
- <li key={item.id ?? i} data-block-id={item.id}>
256
- <Runs runs={item.inline} draw={draw} />
257
- </li>
258
- ))
259
- return block.type === 'bulletList'
260
- ? <ul data-block-id={block.id}>{items}</ul>
261
- : <ol data-block-id={block.id} start={block.start}>{items}</ol>
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>