@estiva-app/ui 0.65.2 → 0.66.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.
@@ -433,14 +433,23 @@ export interface SlashCommand {
433
433
  keywords?: readonly string[]
434
434
  /** Runs once the `/query` that summoned the list has been removed. */
435
435
  run: (editor: Editor) => void
436
+ /**
437
+ * The extensions (by name) the row needs: an editor without one of them is
438
+ * not offered the row, so no row makes something the editor cannot hold.
439
+ */
440
+ needs?: readonly string[]
436
441
  }
437
442
 
443
+ /** Whether an editor has every extension a row needs. */
444
+ const offers = (editor: Editor, command: SlashCommand) =>
445
+ !command.needs || command.needs.every((name) => editor.extensionManager.extensions.some((extension) => extension.name === name))
446
+
438
447
  export interface SlashSection {
439
448
  label: string
440
449
  commands: readonly SlashCommand[]
441
450
  }
442
451
 
443
- export type FormatId = 'paragraph' | 'heading' | 'subheading' | 'quote' | 'bulletList' | 'orderedList' | 'code'
452
+ export type FormatId = 'paragraph' | 'heading' | 'subheading' | 'quote' | 'bulletList' | 'orderedList' | 'code' | 'checklist' | 'note' | 'warning'
444
453
 
445
454
  export interface FormatDef {
446
455
  id: FormatId
@@ -466,6 +475,19 @@ export const FORMATS: readonly FormatDef[] = [
466
475
  { id: 'orderedList', label: 'Numbered list', trigger: '1.' },
467
476
  ]
468
477
 
478
+ /**
479
+ * What only a document holds — a checklist item and a callout (RIC-19) — so
480
+ * only a block editor's `/` offers them (`formatSection('block')`): a
481
+ * message's text has no spelling for either. They need the `Checklist` and
482
+ * `Callout` extensions. Note and Warning are the two callouts offered (Miky,
483
+ * 2026-10-13); the other tones are read and kept, not offered.
484
+ */
485
+ const DOCUMENT_FORMATS: readonly FormatDef[] = [
486
+ { id: 'checklist', label: 'Checklist', trigger: '[]' },
487
+ { id: 'note', label: 'Note' },
488
+ { id: 'warning', label: 'Warning' },
489
+ ]
490
+
469
491
  /** Code as a mark inside the line, or as a block of its own. */
470
492
  export type CodeFormat = 'inline' | 'block'
471
493
 
@@ -501,19 +523,50 @@ export function applyFormat(editor: Editor, id: FormatId, code: CodeFormat = 'in
501
523
  return run('toggleOrderedList')
502
524
  case 'code':
503
525
  return run(code === 'block' ? 'toggleCodeBlock' : 'toggleCode')
526
+ case 'checklist': {
527
+ // In a list, that item becomes one; elsewhere, the line becomes a list of one.
528
+ if (!editor.isActive('listItem') && !run('toggleBulletList')) return false
529
+ // An item already a checklist item keeps its tick.
530
+ if (typeof editor.getAttributes('listItem').checked === 'boolean') return true
531
+ return editor.chain().updateAttributes('listItem', { checked: false }).run()
532
+ }
533
+ case 'note':
534
+ case 'warning': {
535
+ if (!editor.isActive('blockquote') && !run('toggleBlockquote')) return false
536
+ return editor.chain().updateAttributes('blockquote', { tone: id }).run()
537
+ }
504
538
  default:
505
539
  return false
506
540
  }
507
541
  }
508
542
 
509
- /** The Format section every app's `/` opens with. */
543
+ /** Words a row is also found by, beyond its id. */
544
+ const KEYWORDS: Partial<Record<FormatId, string[]>> = {
545
+ checklist: ['todo', 'task'],
546
+ note: ['callout'],
547
+ warning: ['callout'],
548
+ }
549
+
550
+ /** The extension a row's block needs: without it the row would make a plain bullet or a plain quote (Katerina, review of #170). */
551
+ const NEEDS: Partial<Record<FormatId, string[]>> = {
552
+ checklist: ['checklist'],
553
+ note: ['callout'],
554
+ warning: ['callout'],
555
+ }
556
+
557
+ /**
558
+ * The Format section every app's `/` opens with. A block editor's also offers
559
+ * a checklist and the callouts — only where the editor has the `Checklist` and
560
+ * `Callout` extensions (`SlashCommand.needs`).
561
+ */
510
562
  export function formatSection(code: CodeFormat = 'inline'): SlashSection {
511
563
  return {
512
564
  label: 'Format',
513
- commands: FORMATS.map((format) => ({
565
+ commands: (code === 'block' ? [...FORMATS, ...DOCUMENT_FORMATS] : FORMATS).map((format) => ({
514
566
  key: `format-${format.id}`,
515
567
  row: { label: format.label, shortcut: (code === 'block' && format.blockTrigger) || format.trigger },
516
- keywords: [format.id],
568
+ keywords: [format.id, ...(KEYWORDS[format.id] ?? [])],
569
+ ...(NEEDS[format.id] ? { needs: NEEDS[format.id] } : {}),
517
570
  run: (editor) => {
518
571
  applyFormat(editor, format.id, code)
519
572
  },
@@ -565,7 +618,10 @@ export const SlashCommands = Extension.create<SlashCommandsOptions>({
565
618
  char: '/',
566
619
  startOfLine: false,
567
620
  pluginKey: new PluginKey('slashCommands'),
568
- items: ({ query }) => filterSlashSections(sections, query).flatMap((section, at) => section.commands.map((command) => ({ ...command, section: at }))),
621
+ items: ({ query, editor }) =>
622
+ filterSlashSections(sections, query).flatMap((section, at) =>
623
+ section.commands.filter((command) => offers(editor, command)).map((command) => ({ ...command, section: at })),
624
+ ),
569
625
  command: ({ editor, range, props }) => {
570
626
  editor.chain().focus().deleteRange(range).run()
571
627
  props.run(editor)
package/src/RichText.mdx CHANGED
@@ -58,6 +58,14 @@ import { InlineChip, RichText } from '@estiva-app/ui'
58
58
  sideways.
59
59
  - Each block carries its `id` as `data-block-id`, for an app that points at
60
60
  one block.
61
+ - A list item with `checked` is a checklist item, drawn with a box where its
62
+ bullet or number was; a ticked one's words, links and code are grey and struck
63
+ through. It still counts in a numbered list. Give `onTick` where the reader
64
+ may change the text and the box ticks with one click, handing you the block
65
+ id and the new state to save; without it the box is a picture, and says done
66
+ or not to a screen reader. Each box is named by its item's words. A quote with
67
+ a `tone` is a callout, on its tone's wash; a tone it does not know is drawn as
68
+ `note` (`calloutLook`).
61
69
  - **An editor wears the same look.** Put `richTextClassName()` (or
62
70
  `richTextClassName('small')`) on the editor's root, so what is being written
63
71
  looks exactly like what is read. It styles the elements inside the box,
@@ -70,6 +78,10 @@ import { richTextClassName } from '@estiva-app/ui'
70
78
  const editorAttributes = { class: richTextClassName('default', 'outline-none') }
71
79
  ```
72
80
 
81
+ <Canvas of={RichTextStories.ChecklistAndCallouts} />
82
+
83
+ <Canvas of={RichTextStories.NumberedAndNested} />
84
+
73
85
  ## What it owns
74
86
 
75
87
  Nothing. It only draws. Clicks and keys are yours.
@@ -1,3 +1,4 @@
1
+ import { useState } from 'react'
1
2
  import type { Meta, StoryObj } from '@storybook/react-vite'
2
3
  import { IconSquareRounded } from '@tabler/icons-react'
3
4
  import { RichText, type RichTextBlock, type RichTextRun } from './RichText'
@@ -84,6 +85,33 @@ export const WithTheAppsOwnParts: Story = {
84
85
  },
85
86
  }
86
87
 
88
+ /** Items indented under the one above, two levels deep, in both lists: each level is the type of the list it sits in. */
89
+ export const IndentedItems: Story = {
90
+ args: {
91
+ blocks: [
92
+ {
93
+ type: 'bulletList',
94
+ children: [
95
+ { type: 'listItem', inline: [t('Item one')] },
96
+ { type: 'listItem', indent: 1, inline: [t('Item one, under it')] },
97
+ { type: 'listItem', indent: 2, inline: [t('A level further in')] },
98
+ { type: 'listItem', indent: 1, inline: [t('Back one level')] },
99
+ { type: 'listItem', inline: [t('Item two')] },
100
+ ],
101
+ },
102
+ {
103
+ type: 'orderedList',
104
+ children: [
105
+ { type: 'listItem', inline: [t('The first step')] },
106
+ { type: 'listItem', indent: 1, inline: [t('Each level counts from one')] },
107
+ { type: 'listItem', indent: 1, inline: [t('Then two')] },
108
+ { type: 'listItem', inline: [t('The second step')] },
109
+ ],
110
+ },
111
+ ],
112
+ },
113
+ }
114
+
87
115
  /** A block this part has no design for, from a later version: its words are drawn, never dropped. */
88
116
  export const UnknownBlock: Story = {
89
117
  args: {
@@ -113,3 +141,52 @@ export const CodeInALanguage: Story = {
113
141
  ],
114
142
  },
115
143
  }
144
+
145
+ const task = (text: string, checked?: boolean): RichTextBlock => ({ type: 'listItem', checked, inline: [t(text)] })
146
+ const callout = (tone: string, text: string): RichTextBlock => ({ type: 'blockquote', tone, inline: [t(text)] })
147
+
148
+ /** A checklist, among a plain list's items, and each callout tone after a plain quote (RIC-19). The boxes tick: `onTick` is given. */
149
+ export const ChecklistAndCallouts: Story = {
150
+ render: (args) => {
151
+ const [ticked, setTicked] = useState<Record<string, boolean>>({ c1: true })
152
+ const list = ['c1', 'c2', 'c3'].map((id, i) => ({ ...task(['Item one', 'Item two', 'Item three'][i], ticked[id] ?? false), id }))
153
+ return (
154
+ <RichText
155
+ {...args}
156
+ onTick={(id, checked) => setTicked((was) => ({ ...was, [id]: checked }))}
157
+ blocks={[
158
+ { type: 'bulletList', children: [...list, task('A plain item keeps its bullet')] },
159
+ { type: 'blockquote', inline: [t('A plain quote.')] },
160
+ callout('note', 'Note, the default callout. A tone this part does not know is drawn as this one.'),
161
+ callout('tip', 'Tip.'),
162
+ callout('important', 'Important.'),
163
+ callout('warning', 'Warning.'),
164
+ callout('caution', 'Caution.'),
165
+ ]}
166
+ />
167
+ )
168
+ },
169
+ }
170
+
171
+ const ticked = (text: string, checked: boolean | undefined, indent = 0): RichTextBlock => ({ ...task(text, checked), ...(indent ? { indent } : {}) })
172
+
173
+ /**
174
+ * A numbered list counts its checklist items: the plain item after two of them
175
+ * is 3. A sub-item sits under a ticked item, not struck through (Katerina,
176
+ * review of #170), inside the ticked item's words as the editor draws it.
177
+ */
178
+ export const NumberedAndNested: Story = {
179
+ args: {
180
+ blocks: [
181
+ { type: 'orderedList', children: [ticked('Item one', true), ticked('Item two', false), ticked('Item three, a plain item', undefined)] },
182
+ {
183
+ type: 'bulletList',
184
+ children: [
185
+ ticked('Item one', true),
186
+ ticked('A sub-item under it', false, 1),
187
+ { type: 'listItem', checked: true, inline: [t('A ticked item with '), t('[a link](https://example.com)'), t(' and '), t('code', 'code')] },
188
+ ],
189
+ },
190
+ ],
191
+ },
192
+ }
@@ -146,4 +146,95 @@ describe('RichText', () => {
146
146
  }
147
147
  expect(richTextClassName('default', 'outline-none')).toContain('outline-none')
148
148
  })
149
+
150
+ const CHECKLIST: RichTextBlock[] = [
151
+ {
152
+ type: 'bulletList',
153
+ id: 'l1',
154
+ children: [
155
+ { type: 'listItem', id: 'i1', checked: true, inline: [{ text: 'ship', marks: [] }] },
156
+ { type: 'listItem', id: 'i2', checked: false, inline: [{ text: 'verify', marks: [] }] },
157
+ { type: 'listItem', id: 'i3', inline: [{ text: 'plain', marks: [] }] },
158
+ ],
159
+ },
160
+ { type: 'blockquote', id: 'q1', tone: 'warning', inline: [{ text: 'careful', marks: [] }] },
161
+ { type: 'blockquote', id: 'q2', tone: 'danger', inline: [{ text: 'unknown', marks: [] }] },
162
+ ]
163
+
164
+ it('draws a checklist item with its box, and a plain item without one (RIC-19)', () => {
165
+ const { container } = render(<RichText blocks={CHECKLIST} />)
166
+ expect([...container.querySelectorAll('li')].map((li) => li.getAttribute('data-checked'))).toEqual(['true', 'false', null])
167
+ // Without onTick the box is a picture: nothing to tab to or click, but a screen reader still hears done or not, by the item's words.
168
+ const boxes = screen.getAllByRole('checkbox')
169
+ expect(boxes.map((b) => [b.getAttribute('aria-label'), b.getAttribute('aria-checked'), b.getAttribute('aria-readonly'), b.tabIndex])).toEqual([
170
+ ['ship', 'true', 'true', -1],
171
+ ['verify', 'false', 'true', -1],
172
+ ])
173
+ })
174
+
175
+ it('ticks an item with one click when the app may change the text, each box named by its item', () => {
176
+ const onTick = vi.fn()
177
+ render(<RichText blocks={CHECKLIST} onTick={onTick} />)
178
+ const boxes = [screen.getByRole('checkbox', { name: 'ship' }), screen.getByRole('checkbox', { name: 'verify' })]
179
+ expect(boxes.map((b) => b.getAttribute('aria-checked'))).toEqual(['true', 'false'])
180
+ expect(screen.getAllByRole('checkbox')).toHaveLength(2)
181
+ fireEvent.click(boxes[1])
182
+ expect(onTick).toHaveBeenCalledWith('i2', true)
183
+ })
184
+
185
+ it('draws a callout in its tone, and one it does not know as a note', () => {
186
+ const { container } = render(<RichText blocks={CHECKLIST} />)
187
+ expect([...container.querySelectorAll('blockquote')].map((q) => [q.getAttribute('data-tone'), q.getAttribute('data-callout')])).toEqual([
188
+ ['warning', 'warning'],
189
+ ['danger', 'note'],
190
+ ])
191
+ })
192
+
193
+ it('draws an empty tone as a note, as the editor does (review of #170)', () => {
194
+ const { container } = render(<RichText blocks={[{ type: 'blockquote', tone: '', inline: [t('empty')] }]} />)
195
+ expect(container.querySelector('blockquote')!.getAttribute('data-callout')).toBe('note')
196
+ })
197
+ })
198
+
199
+ describe('an indented list item — MAN-13', () => {
200
+ const item = (id: string, text: string, indent?: number): RichTextBlock => ({ type: 'listItem', id, inline: [t(text)], ...(indent ? { indent } : {}) })
201
+
202
+ it('draws an item inside the one above, in a list of the same type with no id', () => {
203
+ const { container } = render(
204
+ <RichText blocks={[{ type: 'orderedList', id: 'l1', children: [item('i1', 'intro'), item('i2', 'after', 1), item('i3', 'deeper', 2), item('i4', 'back')] }]} />,
205
+ )
206
+ const outer = container.querySelector('ol[data-block-id="l1"]')!
207
+ expect([...outer.children].map((li) => li.getAttribute('data-block-id'))).toEqual(['i1', 'i4'])
208
+ const inner = outer.querySelector(':scope > li > ol')!
209
+ expect(inner.hasAttribute('data-block-id')).toBe(false)
210
+ expect(inner.querySelector(':scope > li')?.getAttribute('data-block-id')).toBe('i2')
211
+ expect(inner.querySelector(':scope > li > ol > li')?.textContent).toBe('deeper')
212
+ })
213
+
214
+ it('never draws an item more than one level below the one before it', () => {
215
+ const { container } = render(<RichText blocks={[{ type: 'bulletList', children: [item('a', 'first', 2), item('b', 'next', 3)] }]} />)
216
+ const outer = container.querySelector('ul')!
217
+ expect([...outer.children].map((li) => li.getAttribute('data-block-id'))).toEqual(['a'])
218
+ expect(outer.querySelector(':scope > li > ul > li')?.getAttribute('data-block-id')).toBe('b')
219
+ })
220
+
221
+ it('never nests deeper than 8 levels (SPEC §13.3)', () => {
222
+ const deep = Array.from({ length: 20 }, (_, i) => item(`d${i}`, `level ${i}`, i))
223
+ const { container } = render(<RichText blocks={[{ type: 'bulletList', children: deep }]} />)
224
+ expect(container.querySelectorAll('ul').length).toBe(9)
225
+ expect(container.querySelector('[data-block-id="d19"]')!.parentElement).toBe(container.querySelector('[data-block-id="d8"]')!.parentElement)
226
+ })
227
+
228
+ it('draws an item with an indent that is not a whole number above 0 at the left', () => {
229
+ const { container } = render(<RichText blocks={[{ type: 'bulletList', children: [item('a', 'one'), { ...item('b', 'two'), indent: -1 }, { ...item('c', 'three'), indent: Number.NaN }]}]} />)
230
+ expect([...container.querySelector('ul')!.children].map((li) => li.getAttribute('data-block-id'))).toEqual(['a', 'b', 'c'])
231
+ })
232
+
233
+ it('draws an item indented under a checklist item inside its words, after them (RIC-19)', () => {
234
+ const { container } = render(<RichText blocks={[{ type: 'bulletList', children: [{ ...item('a', 'done'), checked: true }, item('b', 'under it', 1)] }]} />)
235
+ const sub = container.querySelector('[data-block-id="b"]')!
236
+ expect(sub.closest('li[data-checked]')?.getAttribute('data-block-id')).toBe('a')
237
+ expect(sub.parentElement!.parentElement!.matches('li[data-checked="true"] > div')).toBe(true)
238
+ expect(sub.hasAttribute('data-checked')).toBe(false)
239
+ })
149
240
  })
package/src/RichText.tsx CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { MouseEvent, ReactNode } from 'react'
2
+ import { Checkbox } from './Checkbox'
2
3
  import { cn } from './cn'
3
4
  import { Link } from './Link'
4
5
  import { codeColours } from './codeColours'
@@ -49,6 +50,12 @@ export interface RichTextBlock {
49
50
  language?: string
50
51
  /** A numbered list's first number. */
51
52
  start?: number
53
+ /** How far a list item is indented under the items above it. A list's items stay flat; this draws them nested. */
54
+ indent?: number
55
+ /** A checklist item's tick: `true` or `false`, absent on a plain item. */
56
+ checked?: boolean
57
+ /** A callout's tone (`note`, `tip`, `important`, `warning`, `caution`), absent on a plain quote. */
58
+ tone?: string
52
59
  attrs?: Record<string, unknown>
53
60
  inline?: readonly RichTextRun[]
54
61
  children?: readonly RichTextBlock[]
@@ -77,6 +84,12 @@ export interface RichTextProps {
77
84
  * `undefined` to leave it to the part, which draws its words.
78
85
  */
79
86
  renderBlock?: (block: RichTextBlock) => ReactNode | undefined
87
+ /**
88
+ * Ticks or unticks a checklist item, by its block id: its box is then a
89
+ * control, one click where the text is read. Without it the box is a picture
90
+ * of the state — for someone who may not change the text.
91
+ */
92
+ onTick?: (blockId: string, checked: boolean) => void
80
93
  }
81
94
 
82
95
  /**
@@ -95,6 +108,13 @@ const RICH_TEXT_CLASSES = {
95
108
  /* The line in the muted text grey, not the border grey: a border grey was 1.3 : 1 in Leaf (Katerina, 9 October). */
96
109
  // 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
97
110
  quote: '[&_blockquote>p]:m-0 [&_blockquote]:border-l-[3px] [&_blockquote]:border-text-muted [&_blockquote]:pl-2.5',
111
+ /*
112
+ A checklist item: the box where the bullet was, in the list's gutter, and the words where they were (Katerina, review of #170). The item stays a list item, so a numbered list still counts it; the box sits beside the row and adds nothing to its height, the same in the editor and here.
113
+ A ticked item's words grey and struck through, its links and code too (Katerina); the lists under it are not.
114
+ */
115
+ checklist: '[&_li[data-checked]]:relative [&_li[data-checked]]:list-none [&_[data-checklist-box]]:absolute [&_[data-checklist-box]]:-left-5 [&_[data-checklist-box]]:top-0.5 [&_[data-checklist-box]]:flex [&_li[data-checked=true]>div>p]:text-text-muted [&_li[data-checked=true]>div>p]:line-through [&_li[data-checked=true]>div>p_a]:text-text-muted [&_li[data-checked=true]>div>p_a]:decoration-text-muted [&_li[data-checked=true]>div>p_code]:border-transparent [&_li[data-checked=true]>div>p_code]:bg-transparent [&_li[data-checked=true]>div>p_code]:text-text-muted',
116
+ /* A callout: the quote's line in its tone's colour, on its tone's wash (RIC-19, to Katerina's review). `data-callout` is the tone as drawn — an unknown one is `note`. */
117
+ callout: '[&_blockquote[data-callout]]:rounded-sm [&_blockquote[data-callout]]:py-2 [&_blockquote[data-callout]]:pr-3 [&_blockquote[data-callout=note]]:border-info-default [&_blockquote[data-callout=note]]:bg-info-muted [&_blockquote[data-callout=tip]]:border-success-default [&_blockquote[data-callout=tip]]:bg-success-muted [&_blockquote[data-callout=important]]:border-accent-primary [&_blockquote[data-callout=important]]:bg-accent-muted [&_blockquote[data-callout=warning]]:border-warning-default [&_blockquote[data-callout=warning]]:bg-warning-muted [&_blockquote[data-callout=caution]]:border-error-default [&_blockquote[data-callout=caution]]:bg-error-muted',
98
118
  /* Code in a sentence: the code fill and a thin border, in the heading colour (Katerina, 9 October: option B; the amber of 0.64.0 was too much). */
99
119
  // eslint-disable-next-line token-values/no-restricted-classes -- @estiva-escape: code is 0.85 of the text around it, so it scales with a heading and with `small`; a named size is fixed
100
120
  code: '[&_:not(pre)>code]:rounded-sm [&_:not(pre)>code]:bg-bg-code [&_:not(pre)>code]:px-1 [&_:not(pre)>code]:py-0.5 [&_:not(pre)>code]:border [&_:not(pre)>code]:border-border-default [&_code]:font-mono [&_code]:text-[0.85em] [&_code]:text-text-primary',
@@ -119,8 +139,8 @@ const RICH_TEXT_CLASSES = {
119
139
  * being written looks the same as what is read. Put it on the editor's root.
120
140
  */
121
141
  export function richTextClassName(size: RichTextSize = 'default', className?: string) {
122
- const { base, headings, lists, quote, code, codeBlock, syntax, divider, table, marks } = RICH_TEXT_CLASSES
123
- return cn(base, headings, lists, quote, code, codeBlock, syntax, divider, table, marks, RICH_TEXT_CLASSES[size], className)
142
+ const { base, headings, lists, quote, checklist, callout, code, codeBlock, syntax, divider, table, marks } = RICH_TEXT_CLASSES
143
+ return cn(base, headings, lists, quote, checklist, callout, code, codeBlock, syntax, divider, table, marks, RICH_TEXT_CLASSES[size], className)
124
144
  }
125
145
 
126
146
  /**
@@ -165,6 +185,16 @@ interface Draw {
165
185
  renderText: (text: string) => ReactNode
166
186
  renderReference: (reference: string, run: RichTextRun) => ReactNode
167
187
  renderBlock?: (block: RichTextBlock) => ReactNode | undefined
188
+ onTick?: (blockId: string, checked: boolean) => void
189
+ }
190
+
191
+ /** SPEC §13.3's callout tones — GitHub's alert kinds. */
192
+ const CALLOUT_TONES: ReadonlySet<string> = new Set(['note', 'tip', 'important', 'warning', 'caution'])
193
+
194
+ /** The tone a callout is drawn in: its own, or `note` for one this part does not know (§13.3). */
195
+ export function calloutLook(tone: string): string {
196
+ const named = tone.toLowerCase()
197
+ return CALLOUT_TONES.has(named) ? named : 'note'
168
198
  }
169
199
 
170
200
  function Runs({ runs, draw }: { runs?: readonly RichTextRun[]; draw: Draw }) {
@@ -237,6 +267,68 @@ function Table({ block, draw }: { block: RichTextBlock; draw: Draw }) {
237
267
  )
238
268
  }
239
269
 
270
+ /** An item's words, for its box's name: a box says which item it ticks (Katerina, review of #170). */
271
+ const wordsOf = (item: RichTextBlock) => (item.inline ?? []).map((run) => run.text).join('').trim()
272
+
273
+ /**
274
+ * One list item. A checklist item takes the editor's shape (`Checklist`): the
275
+ * box, then the words in a `div`, and `below` — the items indented under it —
276
+ * inside that `div` after them, as the editor draws them.
277
+ */
278
+ function ListItem({ item, draw, below }: { item: RichTextBlock; draw: Draw; below?: ReactNode }) {
279
+ if (typeof item.checked !== 'boolean') {
280
+ return (
281
+ <li data-block-id={item.id}>
282
+ <Runs runs={item.inline} draw={draw} />
283
+ {below}
284
+ </li>
285
+ )
286
+ }
287
+ const { onTick } = draw
288
+ const id = item.id
289
+ const name = wordsOf(item) || 'Item'
290
+ const control = onTick && id ? (next: boolean) => onTick(id, next) : undefined
291
+ return (
292
+ <li data-block-id={id} data-checked={String(item.checked)}>
293
+ {/* Without `onTick` the box is a picture, so the box around it says done or not: a strike-through is not read out. */}
294
+ <span data-checklist-box="" {...(control ? {} : { role: 'checkbox', 'aria-checked': item.checked, 'aria-readonly': true, 'aria-label': name })}>
295
+ <Checkbox checked={item.checked} onChange={control} aria-label={name} />
296
+ </span>
297
+ <div>
298
+ <p>
299
+ <Runs runs={item.inline} draw={draw} />
300
+ </p>
301
+ {below}
302
+ </div>
303
+ </li>
304
+ )
305
+ }
306
+
307
+ interface NestedItem {
308
+ item: RichTextBlock
309
+ key: number
310
+ below: NestedItem[]
311
+ }
312
+
313
+ const MAX_DEPTH = 8
314
+
315
+ /** A list's flat items, each under the last item one level up — never more than one level below the item before. */
316
+ function nestItems(items: readonly RichTextBlock[]): NestedItem[] {
317
+ const top: NestedItem[] = []
318
+ // The last item seen at each depth.
319
+ const path: NestedItem[] = []
320
+ items.forEach((item, key) => {
321
+ const asked = Number.isInteger(item.indent) && item.indent! > 0 ? item.indent! : 0
322
+ // SPEC §13.3: never deeper than 8, so a hostile list cannot nest thousands deep.
323
+ const depth = Math.min(asked, path.length, MAX_DEPTH)
324
+ path.length = depth
325
+ const node: NestedItem = { item, key, below: [] }
326
+ ;(depth === 0 ? top : path[depth - 1].below).push(node)
327
+ path.push(node)
328
+ })
329
+ return top
330
+ }
331
+
240
332
  function Block({ block, draw }: { block: RichTextBlock; draw: Draw }): ReactNode {
241
333
  const own = draw.renderBlock?.(block)
242
334
  if (own !== undefined) return own
@@ -251,17 +343,28 @@ function Block({ block, draw }: { block: RichTextBlock; draw: Draw }): ReactNode
251
343
  return <p data-block-id={block.id}>{runs}</p>
252
344
  case 'bulletList':
253
345
  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>
346
+ const ordered = block.type === 'orderedList'
347
+ const items = (nodes: readonly NestedItem[]): ReactNode[] =>
348
+ nodes.map(({ item, key, below }) => (
349
+ <ListItem
350
+ key={item.id ?? key}
351
+ item={item}
352
+ draw={draw}
353
+ // The list an indented item sits in is drawn, not stored: it has no id, and the type of the list around it.
354
+ below={below.length > 0 && (ordered ? <ol>{items(below)}</ol> : <ul>{items(below)}</ul>)}
355
+ />
356
+ ))
357
+ const top = items(nestItems(block.children ?? []))
358
+ return ordered
359
+ ? <ol data-block-id={block.id} start={block.start}>{top}</ol>
360
+ : <ul data-block-id={block.id}>{top}</ul>
262
361
  }
263
362
  case 'blockquote':
264
- return <blockquote data-block-id={block.id}>{runs}</blockquote>
363
+ return (
364
+ <blockquote data-block-id={block.id} data-tone={block.tone} data-callout={typeof block.tone === 'string' ? calloutLook(block.tone) : undefined}>
365
+ {runs}
366
+ </blockquote>
367
+ )
265
368
  case 'codeBlock':
266
369
  // Literal: no marks, no links, no chips. A long line wraps; nothing scrolls sideways.
267
370
  // In a language highlight.js knows, each word takes its colour; in any other, the text as it is.
@@ -297,9 +400,10 @@ export function RichText({
297
400
  renderText = (text) => text,
298
401
  renderReference = (_reference, run) => run.text,
299
402
  renderBlock,
403
+ onTick,
300
404
  }: RichTextProps) {
301
405
  if (blocks.length === 0) return null
302
- const draw: Draw = { renderText, renderReference, renderBlock }
406
+ const draw: Draw = { renderText, renderReference, renderBlock, onTick }
303
407
  return (
304
408
  <div data-rich-text className={richTextClassName(size)}>
305
409
  {blocks.map((block, i) => <Block key={block.id ?? i} block={block} draw={draw} />)}
package/src/editor.ts CHANGED
@@ -43,6 +43,9 @@ export {
43
43
  } from './ComposerTriggers'
44
44
  export {
45
45
  BlockId,
46
+ Checklist,
47
+ Callout,
48
+ isChecklistTick,
46
49
  UnknownBlock,
47
50
  ReferenceNode,
48
51
  AttachmentNode,
package/src/index.ts CHANGED
@@ -52,7 +52,7 @@ export {
52
52
  } from './InlineChip'
53
53
  export { Lightbox, type LightboxProps } from './Lightbox'
54
54
  export { Link, type LinkProps, type LinkVariant } from './Link'
55
- export { RichText, richTextClassName, type RichTextProps, type RichTextBlock, type RichTextRun, type RichTextSize } from './RichText'
55
+ export { RichText, richTextClassName, calloutLook, type RichTextProps, type RichTextBlock, type RichTextRun, type RichTextSize } from './RichText'
56
56
  export { IconButton, type IconButtonProps, type IconButtonVariant } from './IconButton'
57
57
  export { Kbd, type KbdProps } from './Kbd'
58
58
  export { KindIcon, thingKindOf, type KindIconProps, type ThingKind } from './KindIcon'
@@ -26,7 +26,7 @@ import { SCHEMA_VERSION, validateRegistry, type Registry, type RegistryEntry } f
26
26
  const root = process.cwd()
27
27
  const registry = buildRegistry({ root })
28
28
  /** Editor extensions: named like components by the catalogue, and not React components. */
29
- const EDITOR_EXTENSIONS = ['KeptSelection', 'BlockId', 'UnknownBlock', 'ReferenceNode', 'AttachmentNode', 'PersonMention', 'UrgentPersonMention', 'CaptionedReference', 'ReferenceTrigger', 'SlashCommands', 'PastedImages', 'CodeBlockColours']
29
+ const EDITOR_EXTENSIONS = ['KeptSelection', 'BlockId', 'UnknownBlock', 'ReferenceNode', 'AttachmentNode', 'PersonMention', 'UrgentPersonMention', 'CaptionedReference', 'ReferenceTrigger', 'SlashCommands', 'PastedImages', 'CodeBlockColours', 'Checklist', 'Callout']
30
30
  const entry = (name: string): RegistryEntry => {
31
31
  const found = registry.entries.find((candidate) => candidate.name === name)
32
32
  if (!found) throw new Error(`no entry for ${name}`)
@@ -48,20 +48,20 @@ describe('the registry builds', () => {
48
48
  expect(registry.entries.map((one) => one.name).sort()).toEqual(values.map((one) => one.name).sort())
49
49
  })
50
50
 
51
- it('counts 140 names over 76 files: 107 components, 32 helpers and 1 hook (NavTree, NavTreeSection, the composer triggers and UnreadDot joined on 2 October; BlockHandle and EditorBlockHandle (RIC-18) on 2 October, moveBlock and blockDropCursor, the drop in the gutter, on 5 October, MembersPill and MembersDialog, moved from Peek (SHI-31), on 6 October, ShowMore on 7 October, AppProvider on 8 October, and KindIcon, thingKindOf and PastedImages with its two helpers, moved from Peek, on 8 October; CodeBlockColours, the coloured code block of the editor, on 9 October)', () => {
51
+ it('counts 144 names over 76 files: 109 components, 34 helpers and 1 hook (NavTree, NavTreeSection, the composer triggers and UnreadDot joined on 2 October; BlockHandle and EditorBlockHandle (RIC-18) on 2 October, moveBlock and blockDropCursor, the drop in the gutter, on 5 October, MembersPill and MembersDialog, moved from Peek (SHI-31), on 6 October, ShowMore on 7 October, AppProvider on 8 October, and KindIcon, thingKindOf and PastedImages with its two helpers, moved from Peek, on 8 October; CodeBlockColours, the coloured code block of the editor, on 9 October; Checklist, Callout, isChecklistTick and calloutLook (RIC-19) on 13 October)', () => {
52
52
  // The reconciliation GATES.md §24 explains. If this changes, that changes.
53
53
  // The `EDITOR_EXTENSIONS` count as components by their names; they are
54
54
  // editor extensions, documented on SelectionToolbar's, BlockNodes' and
55
55
  // ComposerTriggers' pages.
56
56
  const kinds = registry.entries.reduce<Record<string, number>>((all, one) => ({ ...all, [one.kind]: (all[one.kind] ?? 0) + 1 }), {})
57
- expect(kinds).toEqual({ component: 107, helper: 32, hook: 1 })
57
+ expect(kinds).toEqual({ component: 109, helper: 34, hook: 1 })
58
58
  expect(new Set(registry.entries.map((one) => one.sourceFile)).size).toBe(76)
59
59
  })
60
60
 
61
61
  it('gives every entry a purpose, from its own page or from the comment above it', () => {
62
62
  expect(registry.entries.filter((one) => one.purpose.trim() === '')).toEqual([])
63
63
  expect(registry.entries.filter((one) => one.purposeFrom === 'page').length).toBe(72)
64
- expect(registry.entries.filter((one) => one.purposeFrom === 'comment').length).toBe(68)
64
+ expect(registry.entries.filter((one) => one.purposeFrom === 'comment').length).toBe(72)
65
65
  })
66
66
 
67
67
  it('takes a name with a page of its own from the page, and a name without one from the code', () => {