@estiva-app/ui 0.65.0 → 0.65.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@estiva-app/ui",
3
- "version": "0.65.0",
3
+ "version": "0.65.2",
4
4
  "description": "Estiva's design tokens (the contract) and a small set of primitives (a convenience) for every Estiva app.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/registry.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "kind": "package",
5
5
  "repo": "estiva-ui",
6
6
  "package": "@estiva-app/ui",
7
- "packageVersion": "0.65.0",
7
+ "packageVersion": "0.65.2",
8
8
  "exports": 140,
9
9
  "typeExports": 145,
10
10
  "files": null
@@ -1248,6 +1248,12 @@
1248
1248
  "required": false,
1249
1249
  "note": null
1250
1250
  },
1251
+ {
1252
+ "name": "pending",
1253
+ "takes": "true/false",
1254
+ "required": false,
1255
+ "note": "Opened, and its rows are not in yet: the title says open — the arrow turned, `aria-expanded`, `aria-busy` — and the panel stays shut until this is false, then slides open once, rows and all."
1256
+ },
1251
1257
  {
1252
1258
  "name": "storageKey",
1253
1259
  "takes": "text",
@@ -4378,6 +4384,12 @@
4378
4384
  "required": false,
4379
4385
  "note": "Its rows are being read: a skeleton in their place."
4380
4386
  },
4387
+ {
4388
+ "name": "pending",
4389
+ "takes": "true/false",
4390
+ "required": false,
4391
+ "note": "Opened, and its rows are being read: the arrow turns and the group stays shut until they are in, then opens once — `CollapsibleSection`'s."
4392
+ },
4381
4393
  {
4382
4394
  "name": "storageKey",
4383
4395
  "takes": "text",
@@ -477,3 +477,99 @@ describe('what it draws', () => {
477
477
  expect(container.querySelector('h1')?.getAttribute('data-block-id')).toBe('h1')
478
478
  })
479
479
  })
480
+
481
+ describe('Tab on a list item — MAN-13', () => {
482
+ /** A key as a person presses it: through the editor's own key handling, not a command. */
483
+ const press = (key: string, shiftKey = false) =>
484
+ editor!.view.someProp('handleKeyDown', (f) => f(editor!.view, new KeyboardEvent('keydown', { key, shiftKey })))
485
+ const LIST = {
486
+ type: 'doc',
487
+ content: [
488
+ {
489
+ type: 'bulletList',
490
+ attrs: { blockId: 'l1' },
491
+ content: [
492
+ { type: 'listItem', attrs: { blockId: 'i1' }, content: [{ type: 'paragraph', content: [{ type: 'text', text: 'intro' }] }] },
493
+ { type: 'listItem', attrs: { blockId: 'i2' }, content: [{ type: 'paragraph', content: [{ type: 'text', text: 'after' }] }] },
494
+ ],
495
+ },
496
+ ],
497
+ }
498
+ /** The end of "after": 1 opens the list, 2 the first item, 3 its paragraph, then "intro", two closes, two opens. */
499
+ const END_OF_AFTER = 1 + 1 + 1 + 5 + 2 + 2 + 5
500
+
501
+ it('puts the item in a list inside the one above, keeping its id, and gives that list none', () => {
502
+ editor = new Editor({ extensions: EXTENSIONS, content: LIST })
503
+ editor.commands.setTextSelection(END_OF_AFTER)
504
+ expect(press('Tab')).toBe(true)
505
+ const list = (editor.getJSON() as Json).content![0]
506
+ expect(list.content!.map((n) => n.attrs?.blockId)).toEqual(['i1'])
507
+ const inner = list.content![0].content![1]
508
+ expect(inner.type).toBe('bulletList')
509
+ expect(inner.attrs?.blockId ?? null).toBeNull()
510
+ expect(inner.content![0].attrs?.blockId).toBe('i2')
511
+ expect(JSON.stringify(inner)).toContain('after')
512
+ })
513
+
514
+ it('names an item made by Enter inside the inner list, so a save does not re-mint it each time', () => {
515
+ editor = new Editor({ extensions: EXTENSIONS, content: LIST })
516
+ editor.commands.setTextSelection(END_OF_AFTER)
517
+ press('Tab')
518
+ press('Enter')
519
+ editor.commands.insertContent('third')
520
+ const inner = (editor.getJSON() as Json).content![0].content![0].content![1]
521
+ const ids = inner.content!.map((n) => n.attrs?.blockId)
522
+ expect(ids[0]).toBe('i2')
523
+ expect(typeof ids[1]).toBe('string')
524
+ expect(ids[1]).not.toBe('i2')
525
+ expect(inner.attrs?.blockId ?? null).toBeNull()
526
+ })
527
+
528
+ it('names an item in an inner list that arrives without an id, as it does one in the outer list', async () => {
529
+ const nested = structuredClone(LIST)
530
+ const [first, second] = nested.content[0].content
531
+ ;(first.content as unknown[]).push({ type: 'bulletList', content: [{ ...second, attrs: {} }] })
532
+ nested.content[0].content = [first]
533
+ editor = new Editor({ extensions: EXTENSIONS, content: nested })
534
+ // The editor's `create` comes a tick after it mounts.
535
+ await new Promise((resolve) => setTimeout(resolve, 0))
536
+ const inner = (editor.getJSON() as Json).content![0].content![0].content![1]
537
+ expect(typeof inner.content![0].attrs?.blockId).toBe('string')
538
+ expect(inner.attrs?.blockId ?? null).toBeNull()
539
+ })
540
+
541
+ it('brings it back out with Shift+Tab, id and text kept', () => {
542
+ editor = new Editor({ extensions: EXTENSIONS, content: LIST })
543
+ editor.commands.setTextSelection(END_OF_AFTER)
544
+ press('Tab')
545
+ press('Tab', true)
546
+ const list = (editor.getJSON() as Json).content![0]
547
+ expect(list.content!.map((n) => n.attrs?.blockId)).toEqual(['i1', 'i2'])
548
+ expect(list.content![0].content).toHaveLength(1)
549
+ })
550
+
551
+ it('clears an id an inner list arrives with, since the save writes no block for it', () => {
552
+ const nested = structuredClone(LIST)
553
+ const [first, second] = nested.content[0].content
554
+ ;(first.content as unknown[]).push({ type: 'bulletList', attrs: { blockId: 'stray' }, content: [second] })
555
+ nested.content[0].content = [first]
556
+ editor = new Editor({ extensions: EXTENSIONS, content: nested })
557
+ // Any change that brings in a block runs the check; Enter at the end of "after" is one.
558
+ editor.commands.setTextSelection(END_OF_AFTER)
559
+ press('Enter')
560
+ expect(JSON.stringify(editor.getJSON())).not.toContain('stray')
561
+ })
562
+
563
+ it('never lets an inner list keep a real block\'s id: the block keeps it', () => {
564
+ const nested = structuredClone(LIST)
565
+ const [first, second] = nested.content[0].content
566
+ ;(first.content as unknown[]).push({ type: 'bulletList', attrs: { blockId: 'i1' }, content: [second] })
567
+ nested.content[0].content = [first]
568
+ editor = new Editor({ extensions: EXTENSIONS, content: nested })
569
+ editor.commands.setTextSelection(END_OF_AFTER)
570
+ press('Enter')
571
+ const item = (editor.getJSON() as Json).content![0].content![0]
572
+ expect(item.attrs?.blockId).toBe('i1')
573
+ expect(item.content![1].attrs?.blockId ?? null).toBeNull()
574
+ })
575
+ })
@@ -72,9 +72,12 @@ interface Claim {
72
72
  /** Every block that claims an id, by id, in document order. */
73
73
  function claimsIn(doc: ProseMirrorNode): Map<string, Claim[]> {
74
74
  const claims = new Map<string, Claim[]>()
75
- doc.descendants((node, pos) => {
75
+ doc.descendants((node, pos, parent) => {
76
76
  const fixed = node.type.name === 'unknownBlock'
77
77
  if (!fixed && !BLOCK_TYPE_SET.has(node.type.name)) return
78
+ // A list inside a list item is no block (MAN-13): an id it arrived with is
79
+ // cleared, never a claim that could take a real block's.
80
+ if (parent?.type.name === 'listItem' && LISTS.has(node.type.name)) return
78
81
  const id = node.attrs.blockId
79
82
  if (typeof id !== 'string' || id === '') return
80
83
  const copies = claims.get(id)
@@ -151,6 +154,22 @@ const BLOCK_CONTAINERS = new Set(['bulletList', 'orderedList', 'table', 'tableRo
151
154
  /** The wrappers the save flattens: their paragraphs become inline runs of theirs. */
152
155
  const FLATTENED = new Set(['listItem', 'blockquote'])
153
156
 
157
+ /**
158
+ * The lists Tab puts inside a list item (MAN-13). §13.3 keeps a list flat and
159
+ * marks an item's depth, so the save walks into such a list for its items and
160
+ * writes no block for the list itself: its items get ids, it gets none.
161
+ */
162
+ const LISTS = new Set(['bulletList', 'orderedList'])
163
+
164
+ /** The lists inside a list item, with where each starts. */
165
+ function innerLists(item: ProseMirrorNode, pos: number): { pos: number; node: ProseMirrorNode }[] {
166
+ const out: { pos: number; node: ProseMirrorNode }[] = []
167
+ item.forEach((child, offset) => {
168
+ if (LISTS.has(child.type.name)) out.push({ pos: pos + 1 + offset, node: child })
169
+ })
170
+ return out
171
+ }
172
+
154
173
  const idOf = (node: ProseMirrorNode): string | undefined => {
155
174
  const id = node.attrs.blockId
156
175
  return typeof id === 'string' && id !== '' ? id : undefined
@@ -163,6 +182,9 @@ function addressedBlocks(doc: ProseMirrorNode): { pos: number; node: ProseMirror
163
182
  if (!BLOCK_TYPE_SET.has(node.type.name)) return
164
183
  out.push({ pos, node })
165
184
  if (BLOCK_CONTAINERS.has(node.type.name)) node.forEach((child, offset) => visit(child, pos + 1 + offset))
185
+ if (node.type.name === 'listItem') {
186
+ for (const list of innerLists(node, pos)) list.node.forEach((child, offset) => visit(child, list.pos + 1 + offset))
187
+ }
166
188
  }
167
189
  doc.forEach((node, offset) => visit(node, offset))
168
190
  return out
@@ -175,13 +197,17 @@ function unnamedBlocks(doc: ProseMirrorNode): number[] {
175
197
  .map((b) => b.pos)
176
198
  }
177
199
 
178
- /** Where a paragraph inside a list item or a quote claims an id — which the save drops. */
200
+ /**
201
+ * Where a paragraph inside a list item or a quote claims an id, or a list
202
+ * inside a list item does — both of which the save drops.
203
+ */
179
204
  function hiddenIds(doc: ProseMirrorNode): number[] {
180
205
  const out: number[] = []
181
206
  doc.descendants((node, pos) => {
182
207
  if (!FLATTENED.has(node.type.name)) return
183
208
  node.forEach((child, offset) => {
184
- if (child.isTextblock && idOf(child) !== undefined) out.push(pos + 1 + offset)
209
+ const inner = child.isTextblock || (node.type.name === 'listItem' && LISTS.has(child.type.name))
210
+ if (inner && idOf(child) !== undefined) out.push(pos + 1 + offset)
185
211
  })
186
212
  })
187
213
  return out
@@ -20,6 +20,13 @@ toggle, and the rows under it, sliding shut and open.
20
20
  reloads: this browser remembers, under that key. The app prefixes the
21
21
  key (`app.sidebar.starred`).
22
22
  - `open` + `onOpenChange` when the app owns the state.
23
+ - `pending` when the rows are read as it opens and usually arrive quickly:
24
+ the title turns open at once (and says `aria-busy`), the panel stays shut
25
+ until `pending` is false, then opens once with every row. A skeleton that
26
+ the rows replace would move everything below it twice. Only for rows not
27
+ yet drawn: on a section already showing its rows, `pending` shuts them
28
+ until it is false. The tests pin the state (open, busy, rows hidden); the
29
+ single slide is Chrome's to show, measured in Peek (OPT-11).
23
30
  - `actions` for add, sort, filter beside the title — SectionHeader's,
24
31
  revealed while the row is hovered or keyboard-focused.
25
32
  - `look="row"` when the section is a row among rows — a folder in a
@@ -81,6 +81,36 @@ describe('CollapsibleSection', () => {
81
81
  })
82
82
  })
83
83
 
84
+ /** Opened before its rows are in (0.65.1, OPT-11): the title says open, the panel waits. */
85
+ describe('CollapsibleSection, pending', () => {
86
+ it('the title says open and busy while the rows stay shut, then they open', () => {
87
+ const { rerender } = render(<CollapsibleSection title="Section" open pending>{rows}</CollapsibleSection>)
88
+ expect(title().getAttribute('aria-expanded')).toBe('true')
89
+ expect(title().getAttribute('aria-busy')).toBe('true')
90
+ expect(screen.getByText('Item one').closest('[hidden]')).not.toBeNull()
91
+ rerender(<CollapsibleSection title="Section" open>{rows}</CollapsibleSection>)
92
+ expect(title().getAttribute('aria-expanded')).toBe('true')
93
+ expect(title().getAttribute('aria-busy')).toBeNull()
94
+ expect(screen.getByText('Item one').closest('[hidden]')).toBeNull()
95
+ })
96
+
97
+ it('a click while pending closes it', async () => {
98
+ const onOpenChange = vi.fn()
99
+ render(<CollapsibleSection title="Section" open pending onOpenChange={onOpenChange}>{rows}</CollapsibleSection>)
100
+ await userEvent.click(title())
101
+ expect(onOpenChange).toHaveBeenCalledWith(false)
102
+ })
103
+
104
+ it('a closed section is not busy, and a click opens it', async () => {
105
+ const onOpenChange = vi.fn()
106
+ render(<CollapsibleSection title="Section" open={false} pending onOpenChange={onOpenChange}>{rows}</CollapsibleSection>)
107
+ expect(title().getAttribute('aria-expanded')).toBe('false')
108
+ expect(title().getAttribute('aria-busy')).toBeNull()
109
+ await userEvent.click(title())
110
+ expect(onOpenChange).toHaveBeenCalledWith(true)
111
+ })
112
+ })
113
+
84
114
  /** What it hands its header (0.56.0): the folder's ⋮, the ⋮'s name, and the icon before the title. */
85
115
  describe('CollapsibleSection, its header', () => {
86
116
  it('passes menu and menuLabel on: the ⋮ is named by menuLabel and opens the menu', async () => {
@@ -30,6 +30,15 @@ export interface CollapsibleSectionProps {
30
30
  /** The caller owns the state. */
31
31
  open?: boolean
32
32
  onOpenChange?: (open: boolean) => void
33
+ /**
34
+ * Opened, and its rows are not in yet: the title says open — the arrow
35
+ * turned, `aria-expanded`, `aria-busy` — and the panel stays shut until
36
+ * this is false, then slides open once, rows and all. A skeleton the rows
37
+ * then replace moves everything below twice (Peek's Folders, OPT-11). A
38
+ * click while pending closes it, as on an open section. Not for a re-read:
39
+ * on a section already showing its rows it shuts them until it is false.
40
+ */
41
+ pending?: boolean
33
42
  /** Remember open or closed in this browser, under this key. The app prefixes it. */
34
43
  storageKey?: string
35
44
  /** 16px, before the title; the arrow takes its place on hover — `SectionHeader`'s. */
@@ -85,7 +94,7 @@ function writeStored(key: string | undefined, open: boolean) {
85
94
  }
86
95
  }
87
96
 
88
- export function CollapsibleSection({ title, defaultOpen = true, open: openProp, onOpenChange, storageKey, icon, trailing, actions, menu, menuLabel, showActions, look = 'heading', indent = false, children, className, contentClassName }: CollapsibleSectionProps) {
97
+ export function CollapsibleSection({ title, defaultOpen = true, open: openProp, onOpenChange, pending = false, storageKey, icon, trailing, actions, menu, menuLabel, showActions, look = 'heading', indent = false, children, className, contentClassName }: CollapsibleSectionProps) {
89
98
  const [openState, setOpenState] = useState(() => readStored(storageKey) ?? defaultOpen)
90
99
  const open = openProp ?? openState
91
100
  const setOpen = (next: boolean) => {
@@ -93,9 +102,12 @@ export function CollapsibleSection({ title, defaultOpen = true, open: openProp,
93
102
  writeStored(storageKey, next)
94
103
  onOpenChange?.(next)
95
104
  }
105
+ // Pending, Base UI holds the panel shut and so would open it on a click: the click is the title's, and the title says open.
106
+ // Only a click: find-in-page reaching into the shut panel asks to open it, and it is open.
107
+ const shown = open && !pending
96
108
  return (
97
- <Collapsible.Root open={open} onOpenChange={setOpen} className={cn('flex flex-col', className)}>
98
- <SectionHeader title={title} chevron icon={icon} isExpanded={open} trailing={trailing} actions={actions} menu={menu} menuLabel={menuLabel} showActions={showActions} look={look} className="shrink-0" render={<Collapsible.Trigger />} />
109
+ <Collapsible.Root open={shown} onOpenChange={(next, details) => setOpen(pending && details.reason === 'trigger-press' ? !open : next)} className={cn('flex flex-col', className)}>
110
+ <SectionHeader title={title} chevron icon={icon} isExpanded={open} trailing={trailing} actions={actions} menu={menu} menuLabel={menuLabel} showActions={showActions} look={look} className="shrink-0" render={<Collapsible.Trigger aria-expanded={open} aria-busy={pending && open ? true : undefined} />} />
99
111
  {/* The slide: Base UI measures the panel and writes its height to a
100
112
  variable — `auto` again once the slide ends, so rows that arrive
101
113
  later are not clipped — and the panel is 0 high on its opening frame
package/src/NavTree.mdx CHANGED
@@ -54,7 +54,10 @@ rows to any depth.
54
54
  skeleton under it once open, and `onOpenChange` says when to read them;
55
55
  `childrenMessage` is the line under it. `onIntent` on a group says the
56
56
  pointer rests on it or the keyboard reached it: read it then, so opening
57
- it draws rows.
57
+ it draws rows. A `NavTreeSection` opened while its read is in flight takes
58
+ `pending`: its arrow turns and it opens once the rows are in, in one move,
59
+ instead of a skeleton the rows then replace; keep `loading` for a read
60
+ that turns out slow.
58
61
 
59
62
  <Canvas of={NavTreeStories.ReadAsItOpens} />
60
63
 
@@ -121,6 +121,16 @@ describe('NavTree drawn by the app', () => {
121
121
  expect(screen.getByText('Item a')).not.toBeNull()
122
122
  expect(screen.getByText('Nothing in this yet.')).not.toBeNull()
123
123
  })
124
+
125
+ it('a pending NavTreeSection says open and busy, its rows shut until it is not (OPT-11)', () => {
126
+ const { rerender } = render(<NavTreeSection title="Group a" open pending><a href="#a">Item a</a></NavTreeSection>)
127
+ const heading = screen.getByRole('button', { name: 'Group a' })
128
+ expect(heading.getAttribute('aria-expanded')).toBe('true')
129
+ expect(heading.getAttribute('aria-busy')).toBe('true')
130
+ expect(screen.getByText('Item a').closest('[hidden]')).not.toBeNull()
131
+ rerender(<NavTreeSection title="Group a" open><a href="#a">Item a</a></NavTreeSection>)
132
+ expect(screen.getByText('Item a').closest('[hidden]')).toBeNull()
133
+ })
124
134
  })
125
135
 
126
136
  describe('NavTree marks its groups', () => {
package/src/NavTree.tsx CHANGED
@@ -186,6 +186,12 @@ export interface NavTreeSectionProps {
186
186
  onIntent?: () => void
187
187
  /** Its rows are being read: a skeleton in their place. */
188
188
  loading?: boolean
189
+ /**
190
+ * Opened, and its rows are being read: the arrow turns and the group stays
191
+ * shut until they are in, then opens once — `CollapsibleSection`'s. For a
192
+ * read that is usually quick; a slow one is `loading`'s skeleton.
193
+ */
194
+ pending?: boolean
189
195
  /** Remember it open or closed in this browser, under this key. */
190
196
  storageKey?: string
191
197
  /** The rows: `NavItem`s. */
@@ -199,7 +205,7 @@ export interface NavTreeSectionProps {
199
205
  * `groups`; an app whose groups each read their own data draws them itself,
200
206
  * as `NavTree`'s children.
201
207
  */
202
- export function NavTreeSection({ id, title, message, loading = false, trailing, actions, menu, icon, defaultOpen = true, open, onOpenChange, storageKey, onIntent, children }: NavTreeSectionProps) {
208
+ export function NavTreeSection({ id, title, message, loading = false, pending, trailing, actions, menu, icon, defaultOpen = true, open, onOpenChange, storageKey, onIntent, children }: NavTreeSectionProps) {
203
209
  const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
204
210
  useEffect(() => () => clearTimeout(timer.current), [])
205
211
  const intent = onIntent
@@ -225,6 +231,7 @@ export function NavTreeSection({ id, title, message, loading = false, trailing,
225
231
  defaultOpen={defaultOpen}
226
232
  open={open}
227
233
  onOpenChange={onOpenChange}
234
+ pending={pending}
228
235
  storageKey={storageKey}
229
236
  trailing={trailing}
230
237
  actions={actions}