@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.
- package/dist/BlockNodes.d.ts +33 -0
- package/dist/BlockNodes.d.ts.map +1 -1
- package/dist/ComposerTriggers.d.ts +11 -2
- package/dist/ComposerTriggers.d.ts.map +1 -1
- package/dist/EditorTableControls.d.ts +38 -0
- package/dist/EditorTableControls.d.ts.map +1 -0
- package/dist/NavItem.d.ts +11 -1
- package/dist/NavItem.d.ts.map +1 -1
- package/dist/RichText.d.ts +13 -1
- package/dist/RichText.d.ts.map +1 -1
- package/dist/editor.d.ts +2 -1
- package/dist/editor.d.ts.map +1 -1
- package/dist/editor.js +363 -18
- package/dist/editor.js.map +4 -4
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +71 -23
- package/dist/index.js.map +3 -3
- package/package.json +2 -1
- package/registry.json +209 -4
- package/src/BlockNodes.mdx +32 -1
- package/src/BlockNodes.stories.tsx +29 -1
- package/src/BlockNodes.test.tsx +141 -2
- package/src/BlockNodes.tsx +227 -4
- package/src/ComposerTriggers.test.tsx +23 -0
- package/src/ComposerTriggers.tsx +61 -5
- package/src/EditorTableControls.mdx +78 -0
- package/src/EditorTableControls.stories.tsx +63 -0
- package/src/EditorTableControls.test.tsx +288 -0
- package/src/EditorTableControls.tsx +255 -0
- package/src/NavItem.mdx +10 -0
- package/src/NavItem.stories.tsx +23 -1
- package/src/NavItem.test.tsx +36 -0
- package/src/NavItem.tsx +40 -16
- package/src/RichText.mdx +12 -0
- package/src/RichText.stories.tsx +50 -0
- package/src/RichText.test.tsx +56 -0
- package/src/RichText.tsx +83 -10
- package/src/editor.ts +4 -0
- package/src/index.ts +1 -1
- package/src/registry/registry.test.ts +7 -7
- package/src/registry/siblings.test.ts +2 -2
- 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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
) :
|
|
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
|
-
{
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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.
|
package/src/RichText.stories.tsx
CHANGED
|
@@ -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
|
+
}
|
package/src/RichText.test.tsx
CHANGED
|
@@ -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
|
|
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
|
-
<
|
|
285
|
-
|
|
286
|
-
{
|
|
287
|
-
{
|
|
288
|
-
|
|
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
|
|
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
|
|
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:
|
|
58
|
-
expect(new Set(registry.entries.map((one) => one.sourceFile)).size).toBe(
|
|
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(
|
|
64
|
-
expect(registry.entries.filter((one) => one.purposeFrom === 'comment').length).toBe(
|
|
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
|
|
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, '
|
|
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, '
|
|
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
|
|
package/stories/Choosing.mdx
CHANGED
|
@@ -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** |
|