@estiva-app/ui 0.65.3 → 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'
@@ -140,3 +141,52 @@ export const CodeInALanguage: Story = {
140
141
  ],
141
142
  },
142
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,6 +146,54 @@ 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
+ })
149
197
  })
150
198
 
151
199
  describe('an indented list item — MAN-13', () => {
@@ -181,4 +229,12 @@ describe('an indented list item — MAN-13', () => {
181
229
  const { container } = render(<RichText blocks={[{ type: 'bulletList', children: [item('a', 'one'), { ...item('b', 'two'), indent: -1 }, { ...item('c', 'three'), indent: Number.NaN }]}]} />)
182
230
  expect([...container.querySelector('ul')!.children].map((li) => li.getAttribute('data-block-id'))).toEqual(['a', 'b', 'c'])
183
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
+ })
184
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'
@@ -51,6 +52,10 @@ export interface RichTextBlock {
51
52
  start?: number
52
53
  /** How far a list item is indented under the items above it. A list's items stay flat; this draws them nested. */
53
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
54
59
  attrs?: Record<string, unknown>
55
60
  inline?: readonly RichTextRun[]
56
61
  children?: readonly RichTextBlock[]
@@ -79,6 +84,12 @@ export interface RichTextProps {
79
84
  * `undefined` to leave it to the part, which draws its words.
80
85
  */
81
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
82
93
  }
83
94
 
84
95
  /**
@@ -97,6 +108,13 @@ const RICH_TEXT_CLASSES = {
97
108
  /* The line in the muted text grey, not the border grey: a border grey was 1.3 : 1 in Leaf (Katerina, 9 October). */
98
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
99
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',
100
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). */
101
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
102
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',
@@ -121,8 +139,8 @@ const RICH_TEXT_CLASSES = {
121
139
  * being written looks the same as what is read. Put it on the editor's root.
122
140
  */
123
141
  export function richTextClassName(size: RichTextSize = 'default', className?: string) {
124
- const { base, headings, lists, quote, code, codeBlock, syntax, divider, table, marks } = RICH_TEXT_CLASSES
125
- 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)
126
144
  }
127
145
 
128
146
  /**
@@ -167,6 +185,16 @@ interface Draw {
167
185
  renderText: (text: string) => ReactNode
168
186
  renderReference: (reference: string, run: RichTextRun) => ReactNode
169
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'
170
198
  }
171
199
 
172
200
  function Runs({ runs, draw }: { runs?: readonly RichTextRun[]; draw: Draw }) {
@@ -239,6 +267,43 @@ function Table({ block, draw }: { block: RichTextBlock; draw: Draw }) {
239
267
  )
240
268
  }
241
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
+
242
307
  interface NestedItem {
243
308
  item: RichTextBlock
244
309
  key: number
@@ -281,11 +346,13 @@ function Block({ block, draw }: { block: RichTextBlock; draw: Draw }): ReactNode
281
346
  const ordered = block.type === 'orderedList'
282
347
  const items = (nodes: readonly NestedItem[]): ReactNode[] =>
283
348
  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>
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
+ />
289
356
  ))
290
357
  const top = items(nestItems(block.children ?? []))
291
358
  return ordered
@@ -293,7 +360,11 @@ function Block({ block, draw }: { block: RichTextBlock; draw: Draw }): ReactNode
293
360
  : <ul data-block-id={block.id}>{top}</ul>
294
361
  }
295
362
  case 'blockquote':
296
- 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
+ )
297
368
  case 'codeBlock':
298
369
  // Literal: no marks, no links, no chips. A long line wraps; nothing scrolls sideways.
299
370
  // In a language highlight.js knows, each word takes its colour; in any other, the text as it is.
@@ -329,9 +400,10 @@ export function RichText({
329
400
  renderText = (text) => text,
330
401
  renderReference = (_reference, run) => run.text,
331
402
  renderBlock,
403
+ onTick,
332
404
  }: RichTextProps) {
333
405
  if (blocks.length === 0) return null
334
- const draw: Draw = { renderText, renderReference, renderBlock }
406
+ const draw: Draw = { renderText, renderReference, renderBlock, onTick }
335
407
  return (
336
408
  <div data-rich-text className={richTextClassName(size)}>
337
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', () => {