@estiva-app/ui 0.65.3 → 0.67.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.
Files changed (43) hide show
  1. package/dist/BlockNodes.d.ts +33 -0
  2. package/dist/BlockNodes.d.ts.map +1 -1
  3. package/dist/ComposerTriggers.d.ts +11 -2
  4. package/dist/ComposerTriggers.d.ts.map +1 -1
  5. package/dist/EditorTableControls.d.ts +38 -0
  6. package/dist/EditorTableControls.d.ts.map +1 -0
  7. package/dist/NavItem.d.ts +11 -1
  8. package/dist/NavItem.d.ts.map +1 -1
  9. package/dist/RichText.d.ts +13 -1
  10. package/dist/RichText.d.ts.map +1 -1
  11. package/dist/editor.d.ts +2 -1
  12. package/dist/editor.d.ts.map +1 -1
  13. package/dist/editor.js +363 -18
  14. package/dist/editor.js.map +4 -4
  15. package/dist/index.d.ts +1 -1
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +71 -23
  18. package/dist/index.js.map +3 -3
  19. package/package.json +2 -1
  20. package/registry.json +209 -4
  21. package/src/BlockNodes.mdx +32 -1
  22. package/src/BlockNodes.stories.tsx +29 -1
  23. package/src/BlockNodes.test.tsx +141 -2
  24. package/src/BlockNodes.tsx +227 -4
  25. package/src/ComposerTriggers.test.tsx +23 -0
  26. package/src/ComposerTriggers.tsx +61 -5
  27. package/src/EditorTableControls.mdx +78 -0
  28. package/src/EditorTableControls.stories.tsx +63 -0
  29. package/src/EditorTableControls.test.tsx +288 -0
  30. package/src/EditorTableControls.tsx +255 -0
  31. package/src/NavItem.mdx +10 -0
  32. package/src/NavItem.stories.tsx +23 -1
  33. package/src/NavItem.test.tsx +36 -0
  34. package/src/NavItem.tsx +40 -16
  35. package/src/RichText.mdx +12 -0
  36. package/src/RichText.stories.tsx +50 -0
  37. package/src/RichText.test.tsx +56 -0
  38. package/src/RichText.tsx +83 -10
  39. package/src/editor.ts +4 -0
  40. package/src/index.ts +1 -1
  41. package/src/registry/registry.test.ts +7 -7
  42. package/src/registry/siblings.test.ts +2 -2
  43. package/stories/Choosing.mdx +1 -0
package/src/NavItem.tsx CHANGED
@@ -6,6 +6,7 @@ import { cn } from './cn'
6
6
  import { COLLAPSIBLE_PANEL_CLASSES, COUNT_TEXT_CLASSES, SIDEBAR_ROW_CLASSES, SIDEBAR_ROW_TEXT_CLASSES } from './looks'
7
7
  import { IconButton } from './IconButton'
8
8
  import { Menu } from './Menu'
9
+ import type { SectionAction } from './SectionHeader'
9
10
  import { UnreadDot } from './UnreadDot'
10
11
  import { WithTooltip } from './Tooltip'
11
12
 
@@ -33,6 +34,13 @@ import { WithTooltip } from './Tooltip'
33
34
  * link, like the ⋮. The rows slide as `CollapsibleSection`'s do, indented
34
35
  * past the icon so their icons start under this row's label.
35
36
  *
37
+ * `action` is one button of its own beside the ⋮ — any icon, with its tooltip
38
+ * (Katerina, 14 October: Peek's Open work keeps its ✕ "Remove from list" in
39
+ * sight, not inside a menu). The same shape as a `SectionHeader`'s actions,
40
+ * and drawn the same way: an `IconButton` that shows with the ⋮, in the
41
+ * count's place, before the ⋮ when there is one. One, never a row of them: a
42
+ * second thing to do goes in the menu.
43
+ *
36
44
  * `hint` is shown only while the row is pointed at or focused, left of the
37
45
  * count — a neutral `Chip`, typically. It takes its own room while it shows,
38
46
  * so the label ends with "…" before it instead of running under it.
@@ -60,6 +68,8 @@ export interface NavItemProps extends Omit<ComponentPropsWithoutRef<'a'>, 'href'
60
68
  menu?: ReactNode
61
69
  /** The ⋮'s accessible name and tooltip. Default "More options for {label}". */
62
70
  menuLabel?: string
71
+ /** One button beside the ⋮, shown with it: any 16px icon, its tooltip and its handler — a `SectionHeader` action's shape. With a menu too, it sits before the ⋮. */
72
+ action?: SectionAction
63
73
  /** Shown only while the row is pointed at or focused, left of the count: a neutral `Chip`, typically. */
64
74
  hint?: ReactNode
65
75
  /** Something new here: brighter words, the label in medium, and the `UnreadDot` at the right. Say so in the row's own words for assistive tech too. */
@@ -85,6 +95,7 @@ export function NavItem({
85
95
  icon,
86
96
  menu,
87
97
  menuLabel,
98
+ action,
88
99
  hint,
89
100
  unread = false,
90
101
  urgent = false,
@@ -107,7 +118,8 @@ export function NavItem({
107
118
  }
108
119
  // The row draws beside it — the ⋮, the arrow — so the pointer can leave the
109
120
  // link for them while the row still reads as the one it is on.
110
- const besides = menu != null || hasRows
121
+ const buttons = (menu != null ? 1 : 0) + (action ? 1 : 0)
122
+ const besides = buttons > 0 || hasRows
111
123
  // What sits at the right at rest, after the count: the dot, or the badge.
112
124
  const mark = unread ? (urgent ? <UrgentMark /> : <UnreadDot />) : null
113
125
  // Under the ⋮ while it shows: the count and the mark step aside for it.
@@ -117,7 +129,7 @@ export function NavItem({
117
129
  // hidden (Katerina, 5 October).
118
130
  // A screen with no hover shows the ⋮ always (below), so what it lands on
119
131
  // steps aside always: the row reads as it does under a pointer.
120
- const stepsAside = menu != null && 'transition-opacity group-hover/nav:opacity-0 group-has-[:focus-visible]/nav:opacity-0 group-has-[[data-nav-menu]_[aria-expanded=true]]/nav:opacity-0 [@media(hover:none)]:opacity-0'
132
+ const stepsAside = buttons > 0 && 'transition-opacity group-hover/nav:opacity-0 group-has-[:focus-visible]/nav:opacity-0 group-has-[[data-nav-menu]_[aria-expanded=true]]/nav:opacity-0 [@media(hover:none)]:opacity-0'
121
133
 
122
134
  const row = (
123
135
  <a
@@ -134,7 +146,7 @@ export function NavItem({
134
146
  // look while the ⋮ is pointed at and while its menu is open. The same
135
147
  // for the arrow.
136
148
  besides && !active && 'group-hover/nav:bg-bg-hover group-hover/nav:text-text-primary',
137
- menu != null && !active && 'group-has-[[data-nav-menu]_[aria-expanded=true]]/nav:bg-bg-hover group-has-[[data-nav-menu]_[aria-expanded=true]]/nav:text-text-primary',
149
+ buttons > 0 && !active && 'group-has-[[data-nav-menu]_[aria-expanded=true]]/nav:bg-bg-hover group-has-[[data-nav-menu]_[aria-expanded=true]]/nav:text-text-primary',
138
150
  className,
139
151
  )}
140
152
  {...props}
@@ -156,6 +168,11 @@ export function NavItem({
156
168
  // Takes room only while it shows, so the label gives way to it.
157
169
  <span className="hidden shrink-0 items-center group-hover/nav:flex group-has-[:focus-visible]/nav:flex">{hint}</span>
158
170
  )}
171
+ {buttons > 1 && (
172
+ // Two buttons: the second one's room (24px and the 4px between them, the
173
+ // row's 8px gap making up the rest), kept only while they show.
174
+ <span aria-hidden="true" className="hidden w-5 shrink-0 group-hover/nav:block group-has-[:focus-visible]/nav:block group-has-[[data-nav-menu]_[aria-expanded=true]]/nav:block [@media(hover:none)]:block" />
175
+ )}
159
176
  {count ? (
160
177
  <WithTooltip label={countLabel ?? `${count} open`}>
161
178
  {/* A 16px box, centred: the slot is an icon's width, so a number
@@ -179,7 +196,7 @@ export function NavItem({
179
196
  // of the ⋮ (8px from the edge to the 16px box), one column with a
180
197
  // folder heading's dot and ⋮ (Katerina, 5 October).
181
198
  <span className={cn('-mr-1 flex shrink-0', stepsAside)}>{mark}</span>
182
- ) : menu != null && !count ? (
199
+ ) : buttons > 0 && !count ? (
183
200
  // Nothing at the right, but a menu: no room is kept at rest, so the
184
201
  // label has the row's whole width; while the ⋮ shows, its 24px slot
185
202
  // is kept, so a long label ends before it instead of running under it.
@@ -210,7 +227,7 @@ export function NavItem({
210
227
  </IconButton>
211
228
  </div>
212
229
  )}
213
- {menu != null && (
230
+ {buttons > 0 && (
214
231
  // `right-1`: the 24px button's 16px icon ends 8px from the row's
215
232
  // edge, where a count ends (px-2) — and where a SectionHeader's
216
233
  // actions and ⋮ end, so a folder's ⋮ and its rows' ⋮ are one column
@@ -219,17 +236,24 @@ export function NavItem({
219
236
  // right of the slot's centre.
220
237
  // On a screen with no hover it always shows: nothing there can point
221
238
  // at the row, and the menu has no other way in.
222
- <div data-nav-menu className={cn('absolute inset-y-0 right-1 flex items-center', 'opacity-0 transition-opacity group-hover/nav:opacity-100 group-has-[:focus-visible]/nav:opacity-100 has-[[aria-expanded=true]]:opacity-100 [@media(hover:none)]:opacity-100')}>
223
- <Menu
224
- align="right"
225
- trigger={
226
- <IconButton tooltip="More options" aria-label={name}>
227
- <IconDotsVertical size={16} stroke={1.5} />
228
- </IconButton>
229
- }
230
- >
231
- {menu}
232
- </Menu>
239
+ <div data-nav-menu className={cn('absolute inset-y-0 right-1 flex items-center gap-1', 'opacity-0 transition-opacity group-hover/nav:opacity-100 group-has-[:focus-visible]/nav:opacity-100 has-[[aria-expanded=true]]:opacity-100 [@media(hover:none)]:opacity-100')}>
240
+ {action && (
241
+ <IconButton tooltip={action.tooltip} aria-label={`${action.tooltip}: ${label}`} onClick={action.onClick}>
242
+ {action.icon}
243
+ </IconButton>
244
+ )}
245
+ {menu != null && (
246
+ <Menu
247
+ align="right"
248
+ trigger={
249
+ <IconButton tooltip="More options" aria-label={name}>
250
+ <IconDotsVertical size={16} stroke={1.5} />
251
+ </IconButton>
252
+ }
253
+ >
254
+ {menu}
255
+ </Menu>
256
+ )}
233
257
  </div>
234
258
  )}
235
259
  </div>
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',
@@ -107,7 +125,8 @@ const RICH_TEXT_CLASSES = {
107
125
  syntax: '[&_:is(.hljs-keyword,.hljs-doctag,.hljs-type,.hljs-template-tag,.hljs-template-variable)]:text-syntax-keyword [&_.hljs-title]:text-syntax-function [&_:is(.hljs-number,.hljs-literal,.hljs-attr,.hljs-attribute,.hljs-meta,.hljs-variable,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id)]:text-syntax-constant [&_:is(.hljs-string,.hljs-regexp)]:text-syntax-string [&_:is(.hljs-built_in,.hljs-symbol)]:text-syntax-variable [&_.hljs-comment]:text-syntax-comment [&_:is(.hljs-name,.hljs-quote,.hljs-selector-tag,.hljs-selector-pseudo)]:text-syntax-tag',
108
126
  /* A divider splits the text in two: 16px either side (Katerina, 9 October; 4 before). */
109
127
  divider: '[&_hr]:my-2 [&_hr]:border-border-default',
110
- table: '[&_:is(th,td)>p]:m-0 [&_:is(th,td)]:border [&_:is(th,td)]:border-border-default [&_:is(th,td)]:px-2 [&_:is(th,td)]:py-1 [&_:is(th,td)]:text-left [&_:is(th,td)]:align-top [&_table]:w-full [&_table]:border-collapse [&_th]:bg-bg-code [&_th]:font-medium [&_th]:text-text-primary',
128
+ /* A table 16px above and below, as a divider (Katerina, 14 October; 8 before): the editor's row ➕ sits in that room, not over the next line. */
129
+ table: '[&_:is(th,td)>p]:m-0 [&_:is(th,td)]:border [&_:is(th,td)]:border-border-default [&_:is(th,td)]:px-2 [&_:is(th,td)]:py-1 [&_:is(th,td)]:text-left [&_:is(th,td)]:align-top [&_table]:my-2 [&_table]:w-full [&_table]:border-collapse [&_th]:bg-bg-code [&_th]:font-medium [&_th]:text-text-primary',
111
130
  /* Bold in the heading colour, not only heavier. An underlined word draws a faint line, so it does not read as a link (Katerina, 9 October). */
112
131
  marks: '[&_em]:italic [&_strong]:font-semibold [&_strong]:text-text-primary [&_u]:underline [&_u]:decoration-text-muted [&_u]:underline-offset-2',
113
132
  /* Lines 1.5 apart (Katerina, 9 October; 1.4 before). */
@@ -121,8 +140,8 @@ const RICH_TEXT_CLASSES = {
121
140
  * being written looks the same as what is read. Put it on the editor's root.
122
141
  */
123
142
  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)
143
+ const { base, headings, lists, quote, checklist, callout, code, codeBlock, syntax, divider, table, marks } = RICH_TEXT_CLASSES
144
+ return cn(base, headings, lists, quote, checklist, callout, code, codeBlock, syntax, divider, table, marks, RICH_TEXT_CLASSES[size], className)
126
145
  }
127
146
 
128
147
  /**
@@ -167,6 +186,16 @@ interface Draw {
167
186
  renderText: (text: string) => ReactNode
168
187
  renderReference: (reference: string, run: RichTextRun) => ReactNode
169
188
  renderBlock?: (block: RichTextBlock) => ReactNode | undefined
189
+ onTick?: (blockId: string, checked: boolean) => void
190
+ }
191
+
192
+ /** SPEC §13.3's callout tones — GitHub's alert kinds. */
193
+ const CALLOUT_TONES: ReadonlySet<string> = new Set(['note', 'tip', 'important', 'warning', 'caution'])
194
+
195
+ /** The tone a callout is drawn in: its own, or `note` for one this part does not know (§13.3). */
196
+ export function calloutLook(tone: string): string {
197
+ const named = tone.toLowerCase()
198
+ return CALLOUT_TONES.has(named) ? named : 'note'
170
199
  }
171
200
 
172
201
  function Runs({ runs, draw }: { runs?: readonly RichTextRun[]; draw: Draw }) {
@@ -239,6 +268,43 @@ function Table({ block, draw }: { block: RichTextBlock; draw: Draw }) {
239
268
  )
240
269
  }
241
270
 
271
+ /** An item's words, for its box's name: a box says which item it ticks (Katerina, review of #170). */
272
+ const wordsOf = (item: RichTextBlock) => (item.inline ?? []).map((run) => run.text).join('').trim()
273
+
274
+ /**
275
+ * One list item. A checklist item takes the editor's shape (`Checklist`): the
276
+ * box, then the words in a `div`, and `below` — the items indented under it —
277
+ * inside that `div` after them, as the editor draws them.
278
+ */
279
+ function ListItem({ item, draw, below }: { item: RichTextBlock; draw: Draw; below?: ReactNode }) {
280
+ if (typeof item.checked !== 'boolean') {
281
+ return (
282
+ <li data-block-id={item.id}>
283
+ <Runs runs={item.inline} draw={draw} />
284
+ {below}
285
+ </li>
286
+ )
287
+ }
288
+ const { onTick } = draw
289
+ const id = item.id
290
+ const name = wordsOf(item) || 'Item'
291
+ const control = onTick && id ? (next: boolean) => onTick(id, next) : undefined
292
+ return (
293
+ <li data-block-id={id} data-checked={String(item.checked)}>
294
+ {/* Without `onTick` the box is a picture, so the box around it says done or not: a strike-through is not read out. */}
295
+ <span data-checklist-box="" {...(control ? {} : { role: 'checkbox', 'aria-checked': item.checked, 'aria-readonly': true, 'aria-label': name })}>
296
+ <Checkbox checked={item.checked} onChange={control} aria-label={name} />
297
+ </span>
298
+ <div>
299
+ <p>
300
+ <Runs runs={item.inline} draw={draw} />
301
+ </p>
302
+ {below}
303
+ </div>
304
+ </li>
305
+ )
306
+ }
307
+
242
308
  interface NestedItem {
243
309
  item: RichTextBlock
244
310
  key: number
@@ -281,11 +347,13 @@ function Block({ block, draw }: { block: RichTextBlock; draw: Draw }): ReactNode
281
347
  const ordered = block.type === 'orderedList'
282
348
  const items = (nodes: readonly NestedItem[]): ReactNode[] =>
283
349
  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>
350
+ <ListItem
351
+ key={item.id ?? key}
352
+ item={item}
353
+ draw={draw}
354
+ // The list an indented item sits in is drawn, not stored: it has no id, and the type of the list around it.
355
+ below={below.length > 0 && (ordered ? <ol>{items(below)}</ol> : <ul>{items(below)}</ul>)}
356
+ />
289
357
  ))
290
358
  const top = items(nestItems(block.children ?? []))
291
359
  return ordered
@@ -293,7 +361,11 @@ function Block({ block, draw }: { block: RichTextBlock; draw: Draw }): ReactNode
293
361
  : <ul data-block-id={block.id}>{top}</ul>
294
362
  }
295
363
  case 'blockquote':
296
- return <blockquote data-block-id={block.id}>{runs}</blockquote>
364
+ return (
365
+ <blockquote data-block-id={block.id} data-tone={block.tone} data-callout={typeof block.tone === 'string' ? calloutLook(block.tone) : undefined}>
366
+ {runs}
367
+ </blockquote>
368
+ )
297
369
  case 'codeBlock':
298
370
  // Literal: no marks, no links, no chips. A long line wraps; nothing scrolls sideways.
299
371
  // In a language highlight.js knows, each word takes its colour; in any other, the text as it is.
@@ -329,9 +401,10 @@ export function RichText({
329
401
  renderText = (text) => text,
330
402
  renderReference = (_reference, run) => run.text,
331
403
  renderBlock,
404
+ onTick,
332
405
  }: RichTextProps) {
333
406
  if (blocks.length === 0) return null
334
- const draw: Draw = { renderText, renderReference, renderBlock }
407
+ const draw: Draw = { renderText, renderReference, renderBlock, onTick }
335
408
  return (
336
409
  <div data-rich-text className={richTextClassName(size)}>
337
410
  {blocks.map((block, i) => <Block key={block.id ?? i} block={block} draw={draw} />)}
package/src/editor.ts CHANGED
@@ -12,6 +12,7 @@ export { PastedImages, pastedImages, pastedImageName, type PastedImagesOptions,
12
12
  export { CodeBlockColours } from './CodeBlockColours'
13
13
  export { suggestionPopup, isSuggestionActive, isSuggestionOpen, type SuggestionPopupOptions } from './suggestionPopup'
14
14
  export { EditorBlockHandle, blockDropCursor, deleteBlock, moveBlock, duplicateBlock, startBlockDrag, type EditorBlockHandleProps, type BlockTurnInto } from './EditorBlockHandle'
15
+ export { EditorTableControls, appendTableRow, appendTableColumn, deleteTableRow, deleteTableColumn, type EditorTableControlsProps } from './EditorTableControls'
15
16
  export {
16
17
  PersonMention,
17
18
  UrgentPersonMention,
@@ -43,6 +44,9 @@ export {
43
44
  } from './ComposerTriggers'
44
45
  export {
45
46
  BlockId,
47
+ Checklist,
48
+ Callout,
49
+ isChecklistTick,
46
50
  UnknownBlock,
47
51
  ReferenceNode,
48
52
  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 149 names over 77 files: 110 components, 38 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; EditorTableControls and its four actions (RIC-21) 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 })
58
- expect(new Set(registry.entries.map((one) => one.sourceFile)).size).toBe(76)
57
+ expect(kinds).toEqual({ component: 110, helper: 38, hook: 1 })
58
+ expect(new Set(registry.entries.map((one) => one.sourceFile)).size).toBe(77)
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
- expect(registry.entries.filter((one) => one.purposeFrom === 'page').length).toBe(72)
64
- expect(registry.entries.filter((one) => one.purposeFrom === 'comment').length).toBe(68)
63
+ expect(registry.entries.filter((one) => one.purposeFrom === 'page').length).toBe(73)
64
+ expect(registry.entries.filter((one) => one.purposeFrom === 'comment').length).toBe(76)
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', () => {
@@ -299,7 +299,7 @@ describe('the ids point at something', () => {
299
299
  describe('a second parser agrees', async () => {
300
300
  const docgen = await import('react-docgen').catch(() => null)
301
301
 
302
- it.skipIf(!docgen)('finds the same props on all 79 components', async () => {
302
+ it.skipIf(!docgen)('finds the same props on all 80 components', async () => {
303
303
  if (!docgen) return
304
304
  const resolver = new docgen.builtinResolvers.FindExportedDefinitionsResolver({ limit: 0 })
305
305
  const byName = new Map(registry.entries.map((one) => [one.name, one]))
@@ -26,7 +26,7 @@ function appAt(folder: string, name: string, deps: Record<string, string> = UI,
26
26
  put(`${folder}/src/${part}.tsx`, `/** The ${part.toLowerCase()} of ${name}. */\nexport function ${part}() {\n return <i />\n}\n`)
27
27
  }
28
28
  // A repository is a folder with `.git`: a directory, or a worktree's file.
29
- appAt('alpha', 'alpha-app', UI, 'Tabletop')
29
+ appAt('alpha', 'alpha-app', UI, 'Sundial')
30
30
  put('alpha/.git/HEAD', 'ref: refs/heads/main\n')
31
31
  put('beta/package.json', JSON.stringify({ name: 'beta' }))
32
32
  put('beta/.git/HEAD', 'ref: refs/heads/main\n')
@@ -75,7 +75,7 @@ describe.skipIf(!existsSync(cli))('estiva-ui find, with nothing else typed', ()
75
75
  })
76
76
 
77
77
  it('answers the same from the folder that holds them', () => {
78
- expect(find(workspace, 'tabletop')).toMatch(/^Tabletop {2}· .* · {2}alpha/m)
78
+ expect(find(workspace, 'sundial')).toMatch(/^Sundial {2}· .* · {2}alpha/m)
79
79
  expect(find(workspace, 'zzqqx')).toContain('Nothing in estiva-ui, alpha, beta matches')
80
80
  })
81
81
 
@@ -73,6 +73,7 @@ fork freely, owe nothing back, mention it on the package's ticket.
73
73
  | A composer's `@`, `!@`, `[` and `/` — mention a person, reference something, format or insert | **ComposerTriggers** — `PersonMention`, `UrgentPersonMention`, `CaptionedReference`, `ReferenceTrigger` and `SlashCommands` from `@estiva-app/ui/editor`; the app passes its people, its items and its own `/` rows |
74
74
  | An editor that saves a block document and must keep its block ids | **BlockNodes** — `BlockId`, `UnknownBlock`, `ReferenceNode` and `AttachmentNode` from `@estiva-app/ui/editor`, beside StarterKit and TableKit |
75
75
  | Move, turn, duplicate or delete one block of a document being edited | **EditorBlockHandle**, from `@estiva-app/ui/editor`, with your rows after its own |
76
+ | Add or delete a table's rows and columns in a document being edited | **EditorTableControls**, from `@estiva-app/ui/editor`, inside the EditorBlockHandle |
76
77
  | Offer a link or a comment on one block of a document being read | **BlockHandle** — your rows only, and no Tiptap loaded |
77
78
  | Anything with a title, body and footer | **DialogShell** |
78
79
  | "Are you sure?" | **ConfirmDialog** |