@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@estiva-app/ui",
3
- "version": "0.65.2",
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.2",
7
+ "packageVersion": "0.65.3",
8
8
  "exports": 140,
9
9
  "typeExports": 145,
10
10
  "files": null
@@ -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>