@estiva-app/ui 0.49.0 → 0.50.1

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.
@@ -1 +1 @@
1
- {"version":3,"file":"no-restyled-part.d.ts","sourceRoot":"","sources":["../../src/eslint/no-restyled-part.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"no-restyled-part.d.ts","sourceRoot":"","sources":["../../src/eslint/no-restyled-part.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@estiva-app/ui",
3
- "version": "0.49.0",
3
+ "version": "0.50.1",
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.49.0",
7
+ "packageVersion": "0.50.1",
8
8
  "exports": 104,
9
9
  "typeExports": 113,
10
10
  "files": null
@@ -83,6 +83,104 @@ describe('what the editor hands back', () => {
83
83
  })
84
84
  })
85
85
 
86
+ describe('Enter inside an anchored block — MAN-10', () => {
87
+ const TWO = {
88
+ type: 'doc',
89
+ content: [
90
+ { type: 'paragraph', attrs: { blockId: 'p0' }, content: [{ type: 'text', text: 'intro' }] },
91
+ { type: 'paragraph', attrs: { blockId: 'p1' }, content: [{ type: 'text', text: 'anchored' }] },
92
+ ],
93
+ }
94
+ // Where "anchored" starts: "intro" is 1 + 5 + 1, then 1 opens the paragraph.
95
+ const START = 8
96
+ const blocks = () => {
97
+ const out: { id: unknown; text: string }[] = []
98
+ editor!.state.doc.forEach((node) => out.push({ id: node.attrs.blockId, text: node.textContent }))
99
+ return out
100
+ }
101
+
102
+ it('keeps the id on the text when Enter is pressed at the start and the new line is typed into', () => {
103
+ // The usual reason to press Enter there: to write a paragraph above. By the
104
+ // time it saves both halves hold text, so only the editor can tell them apart.
105
+ editor = new Editor({ extensions: EXTENSIONS, content: TWO })
106
+ editor.commands.setTextSelection(START)
107
+ editor.commands.keyboardShortcut('Enter')
108
+ editor.chain().setTextSelection(START).insertContent('new line above').run()
109
+
110
+ const [intro, above, anchored] = blocks()
111
+ expect(intro).toEqual({ id: 'p0', text: 'intro' })
112
+ expect(anchored).toEqual({ id: 'p1', text: 'anchored' })
113
+ expect(above.text).toBe('new line above')
114
+ expect(above.id).toMatch(/^[0-9a-f]{12}$/)
115
+ })
116
+
117
+ it('keeps the id on the first half when Enter is pressed in the middle', () => {
118
+ editor = new Editor({ extensions: EXTENSIONS, content: TWO })
119
+ editor.commands.setTextSelection(START + 3)
120
+ editor.commands.keyboardShortcut('Enter')
121
+ const [, first, second] = blocks()
122
+ expect(first).toEqual({ id: 'p1', text: 'anc' })
123
+ expect(second.id).not.toBe('p1')
124
+ })
125
+
126
+ it('keeps the id on the text when Enter is pressed at the end', () => {
127
+ editor = new Editor({ extensions: EXTENSIONS, content: TWO })
128
+ editor.commands.setTextSelection(START + 'anchored'.length)
129
+ editor.commands.keyboardShortcut('Enter')
130
+ const [, first, second] = blocks()
131
+ expect(first).toEqual({ id: 'p1', text: 'anchored' })
132
+ expect(second.id).not.toBe('p1')
133
+ })
134
+
135
+ it('does the same for a list item', () => {
136
+ editor = new Editor({ extensions: EXTENSIONS, content: DOCUMENT })
137
+ // The start of the list item's text: the last block, inside list, item and paragraph.
138
+ let at = 0
139
+ editor.state.doc.descendants((node, pos) => {
140
+ if (node.type.name === 'listItem') at = pos + 2
141
+ })
142
+ editor.commands.setTextSelection(at)
143
+ editor.commands.keyboardShortcut('Enter')
144
+ const items = (editor.getJSON() as Json).content?.[4].content ?? []
145
+ expect(items).toHaveLength(2)
146
+ expect(items[1].attrs?.blockId).toBe('i1')
147
+ expect(items[0].attrs?.blockId).not.toBe('i1')
148
+ })
149
+
150
+ it('leaves the id on the original when a copy is pasted above it', () => {
151
+ // Both copies hold the same text; only the change says which one was there.
152
+ editor = new Editor({ extensions: EXTENSIONS, content: TWO })
153
+ editor.commands.insertContentAt(START - 1, {
154
+ type: 'paragraph',
155
+ attrs: { blockId: 'p1' },
156
+ content: [{ type: 'text', text: 'anchored' }],
157
+ })
158
+ const [, pasted, original] = blocks()
159
+ expect(original).toEqual({ id: 'p1', text: 'anchored' })
160
+ expect(pasted.text).toBe('anchored')
161
+ expect(pasted.id).not.toBe('p1')
162
+ })
163
+
164
+ it('never takes an unknown block’s id, which the save hands back untouched', () => {
165
+ editor = new Editor({ extensions: EXTENSIONS, content: DOCUMENT })
166
+ editor.commands.insertContentAt(0, { type: 'paragraph', attrs: { blockId: 'w1' }, content: [{ type: 'text', text: 'same id' }] })
167
+ const [first, , , unknown] = blocks()
168
+ expect(unknown.id).toBe('w1')
169
+ expect(first.id).not.toBe('w1')
170
+ })
171
+
172
+ it('takes the fresh id back with an undo of the split', () => {
173
+ editor = new Editor({ extensions: EXTENSIONS, content: TWO })
174
+ editor.commands.setTextSelection(START)
175
+ editor.commands.keyboardShortcut('Enter')
176
+ editor.commands.undo()
177
+ expect(blocks()).toEqual([
178
+ { id: 'p0', text: 'intro' },
179
+ { id: 'p1', text: 'anchored' },
180
+ ])
181
+ })
182
+ })
183
+
86
184
  describe('what it draws', () => {
87
185
  function WithAnEditor({ view }: { view: ((props: ReferenceViewProps) => React.ReactNode) | null }) {
88
186
  const e = useEditor({
@@ -1,5 +1,8 @@
1
1
  import type { ComponentType } from 'react'
2
2
  import { Extension, Node } from '@tiptap/core'
3
+ import type { Node as ProseMirrorNode, Slice } from '@tiptap/pm/model'
4
+ import { Plugin, PluginKey, type Transaction } from '@tiptap/pm/state'
5
+ import { Mapping } from '@tiptap/pm/transform'
3
6
  import { NodeViewWrapper, ReactNodeViewRenderer, type ReactNodeViewProps } from '@tiptap/react'
4
7
 
5
8
  /*
@@ -41,6 +44,115 @@ const BLOCK_TYPES = [
41
44
  // comment anchored to it stops resolving.
42
45
  'attachment',
43
46
  ]
47
+ const BLOCK_TYPE_SET = new Set(BLOCK_TYPES)
48
+
49
+ /** protocol's `newBlockId`: six random bytes as hex. */
50
+ function newBlockId(): string {
51
+ return Array.from(crypto.getRandomValues(new Uint8Array(6)), (b) => b.toString(16).padStart(2, '0')).join('')
52
+ }
53
+
54
+ /** Whether anything typed or inserted is inside — protocol's `holdsText`. An empty paragraph is not; a lone line break is. */
55
+ function holdsText(node: ProseMirrorNode): boolean {
56
+ let found = false
57
+ node.descendants((child) => {
58
+ if (found) return false
59
+ if ((child.isText && child.text !== '') || child.type.name === 'hardBreak' || child.type.name === 'reference') found = true
60
+ return !found
61
+ })
62
+ return found
63
+ }
64
+
65
+ interface Claim {
66
+ pos: number
67
+ node: ProseMirrorNode
68
+ /** An unknown block: the translation hands its source back untouched, so re-minting it here would be undone on save. */
69
+ fixed: boolean
70
+ }
71
+
72
+ /** Every block that claims an id, by id, in document order. */
73
+ function claimsIn(doc: ProseMirrorNode): Map<string, Claim[]> {
74
+ const claims = new Map<string, Claim[]>()
75
+ doc.descendants((node, pos) => {
76
+ const fixed = node.type.name === 'unknownBlock'
77
+ if (!fixed && !BLOCK_TYPE_SET.has(node.type.name)) return
78
+ const id = node.attrs.blockId
79
+ if (typeof id !== 'string' || id === '') return
80
+ const copies = claims.get(id)
81
+ if (copies) copies.push({ pos, node, fixed })
82
+ else claims.set(id, [{ pos, node, fixed }])
83
+ })
84
+ return claims
85
+ }
86
+
87
+ /**
88
+ * The copy the block that was there before carried on into: where the start
89
+ * of its content landed. Enter at the very start pushes that content into the
90
+ * lower half; a split anywhere else leaves it in the upper; a pasted copy,
91
+ * above or below, never moves it out of the original.
92
+ */
93
+ function carriedOn(copies: Claim[], before: Claim, mapping: Mapping): Claim | undefined {
94
+ if (before.node.isLeaf) {
95
+ const at = mapping.map(before.pos, 1)
96
+ return copies.find((c) => c.pos === at)
97
+ }
98
+ // The start of its text, inside the paragraph a list item or quote wraps it in.
99
+ let start = before.pos + 1
100
+ if (!before.node.isTextblock) {
101
+ let found = false
102
+ before.node.descendants((node, pos) => {
103
+ if (found) return false
104
+ if (node.isTextblock) {
105
+ start = before.pos + 1 + pos + 1
106
+ found = true
107
+ }
108
+ return !found
109
+ })
110
+ }
111
+ const at = mapping.map(start, 1)
112
+ return copies.find((c) => c.pos < at && at < c.pos + c.node.nodeSize)
113
+ }
114
+
115
+ /**
116
+ * Where a block claims an id another block keeps — see {@link BlockId}. An
117
+ * unknown block always keeps its id; otherwise the copy the original carried
118
+ * on into, and failing that protocol's rule: the first that holds text, or
119
+ * the first.
120
+ */
121
+ function repeatedBlockIds(doc: ProseMirrorNode, before: ProseMirrorNode, mapping: Mapping): number[] {
122
+ const claims = claimsIn(doc)
123
+ let previous: Map<string, Claim[]> | undefined
124
+ const losers: number[] = []
125
+ for (const [id, copies] of claims) {
126
+ if (copies.length < 2) continue
127
+ previous ??= claimsIn(before)
128
+ const original = previous.get(id)?.[0]
129
+ const owner =
130
+ copies.find((c) => c.fixed) ??
131
+ (original && carriedOn(copies, original, mapping)) ??
132
+ copies.find((c) => holdsText(c.node)) ??
133
+ copies[0]
134
+ for (const copy of copies) if (copy !== owner && !copy.fixed) losers.push(copy.pos)
135
+ }
136
+ return losers
137
+ }
138
+
139
+ /**
140
+ * Whether a change could have made a repeat: only a step that brings in
141
+ * blocks can. Typing, deleting and marks bring in none, so most keystrokes
142
+ * skip the walk.
143
+ */
144
+ function bringsInBlocks(transactions: readonly Transaction[]): boolean {
145
+ return transactions.some((tr) =>
146
+ tr.steps.some((step) => {
147
+ const slice = (step as { slice?: Slice }).slice
148
+ let blocks = false
149
+ slice?.content.forEach((node) => {
150
+ if (node.isBlock) blocks = true
151
+ })
152
+ return blocks
153
+ }),
154
+ )
155
+ }
44
156
 
45
157
  /**
46
158
  * `id` on every block, carried as the `blockId` attribute.
@@ -52,9 +164,39 @@ const BLOCK_TYPES = [
52
164
  *
53
165
  * Rendered as `data-block-id`, which is what `RichText` already emits, so the
54
166
  * reading and the editing surface address a block the same way.
167
+ *
168
+ * **A split gives the new half its own id, the moment it happens** (MAN-10).
169
+ * ProseMirror copies a split node's attrs to both halves, so Enter leaves two
170
+ * blocks claiming one id. Enter at the very start of a paragraph puts the
171
+ * copy *above* it — and once something is typed there, nothing at save time
172
+ * can tell which half was the original, so protocol's `fromEditorDocument`
173
+ * gave the id, and every comment anchored to it, to the new line. Settled
174
+ * here instead, in the same transaction, where the change itself says which
175
+ * copy the original carried on into — so a copy pasted above a block does
176
+ * not take its id either. The others get fresh ids. `fromEditorDocument`
177
+ * keeps its own rule (the copy that holds text) as the backstop for an
178
+ * editor without this extension; where the change says nothing, this falls
179
+ * back to that rule too.
55
180
  */
56
181
  export const BlockId = Extension.create({
57
182
  name: 'blockId',
183
+ addProseMirrorPlugins() {
184
+ return [
185
+ new Plugin({
186
+ key: new PluginKey('blockIdSplit'),
187
+ appendTransaction: (transactions, old, state) => {
188
+ if (!bringsInBlocks(transactions)) return null
189
+ const mapping = new Mapping()
190
+ for (const tr of transactions) mapping.appendMapping(tr.mapping)
191
+ const repeats = repeatedBlockIds(state.doc, old.doc, mapping)
192
+ if (repeats.length === 0) return null
193
+ const tr = state.tr
194
+ for (const pos of repeats) tr.setNodeAttribute(pos, 'blockId', newBlockId())
195
+ return tr
196
+ },
197
+ }),
198
+ ]
199
+ },
58
200
  addGlobalAttributes() {
59
201
  return [
60
202
  {
package/src/Form.mdx CHANGED
@@ -63,6 +63,11 @@ import { Button, Field, Form, TextInput } from '@estiva-app/ui'
63
63
  September), so every form reads the same. Pass no layout of your own: the
64
64
  gate lint refuses a gap, a direction or a grid on a `Form`. `className`
65
65
  still places the form itself: its space around, its size.
66
+ - **Words before the fields go inside the form**, as one block (a title and
67
+ its sentence, 8px apart): the form then puts the same 24px between them
68
+ and the first field. A box of your own around the words and the form, with
69
+ its own gap, sets that space instead, and the gate lint refuses it
70
+ (Katerina, 1 October: Archive read 12px where Move read 24px).
66
71
  - **`layout="free"`** is for a form that is not a list of fields: a field and
67
72
  its button, a composer. It places nothing, and `className` lays it out.
68
73
  - The form draws no box, and neither does the `<fieldset>` inside it.
@@ -77,6 +82,7 @@ It does these by itself. Write none of them around it.
77
82
  | Leaves Enter alone where it means something else: a Textarea, a list, a people picker | exceptions of your own | |
78
83
  | Does nothing while it is sending: no second send, and nothing inside takes focus | a busy flag on every control | |
79
84
  | Stacks its fields in one column, 24px apart | a gap, a direction or a grid on the form | `no-restyled-part` |
85
+ | Puts 24px between the words before the fields and the first one | a spaced box around the words and the form | `no-restyled-part` |
80
86
 
81
87
  ## Keys
82
88
 
@@ -77,6 +77,10 @@ tester.run('no-restyled-part', noRestyledPart, {
77
77
  { name: 'Form placed, its fields its own', filename: app, code: ui('Form', ' <Form onSubmit={send} className="min-h-0 flex-1 px-5">x</Form>') },
78
78
  { name: 'a free Form lays itself out', filename: app, code: ui('Form', ' <Form onSubmit={send} layout="free" className="flex items-center gap-2">x</Form>') },
79
79
  { name: 'a Form whose layout the rule cannot read passes', filename: app, code: ui('Form', ' <Form onSubmit={send} layout={how} className="flex gap-2">x</Form>') },
80
+ { name: "Peek's MoveDialog: the sentences one block inside the Form", filename: app, code: ui('Form', ' <Form onSubmit={send}><div className="flex flex-col gap-2"><p>Notes</p><p>Everyone who can read it now still can.</p></div>x</Form>') },
81
+ { name: 'a spaced box with only the Form in it spaces nothing', filename: app, code: ui('Form', ' <div className="flex flex-col gap-3"><Form onSubmit={send}>x</Form></div>') },
82
+ { name: 'a box beside the Form with no spacing of its own', filename: app, code: ui('Form', ' <div className="flex flex-col"><p>Hides it.</p><Form onSubmit={send}>x</Form></div>') },
83
+ { name: 'a free Form in a spaced row is laid out by its row', filename: app, code: ui('Form', ' <div className="flex items-center gap-2"><p>Name</p><Form onSubmit={send} layout="free">x</Form></div>') },
80
84
  // escapes
81
85
  { name: 'escaped, with its reason', filename: app, code: ui('Link', ' // @estiva-escape(no-restyled-part): the whole row is the link, and no part does that yet\n <Link href="/x" className="after:absolute after:inset-0">x</Link>') },
82
86
  // the package itself
@@ -189,6 +193,18 @@ tester.run('no-restyled-part', noRestyledPart, {
189
193
  code: "import { Link, type LinkProps } from '@estiva-app/ui'\nfunction RouterLink({ href, ...props }: LinkProps) {\n return <Link href={href} {...props} />\n}\nexport function Probe() {\n return <RouterLink href=\"/x\" className=\"text-h5\">x</RouterLink>\n}\n",
190
194
  errors: [{ message: restyled('`text-h5`', '`RouterLink` (it hands its props to `Link`)', ' Use its `variant`, `truncate` or `cover`.') }],
191
195
  },
196
+ {
197
+ name: "Peek's and Ship's ArchiveDialog at 0.49.0: sentences and the Form in a gap-3 box, 12px above the field",
198
+ filename: app,
199
+ code: ui('Form', ' <div className="flex flex-col gap-3 text-body-2 text-text-primary"><p>This hides the topic.</p><p>Nothing is deleted.</p><Form id="f" onSubmit={send}>x</Form></div>'),
200
+ errors: [{ message: '`gap-3` on the box around `Form` spaces the form from what is beside it, so the gap above its first field is not the form’s 24px. Put what is beside it inside the `Form`, as one block before the fields, and drop the box: the Form puts 24px between them (Katerina, 1 October; MoveDialog in Peek does this).' }],
201
+ },
202
+ {
203
+ name: "a DialogShell's body spaced around the Form and a sentence",
204
+ filename: app,
205
+ code: ui('DialogShell, Form', ' <DialogShell title="Note" onClose={send} bodyClassName="flex flex-col space-y-4"><p>Say why.</p><Form onSubmit={send}>x</Form></DialogShell>'),
206
+ errors: [{ message: '`space-y-4` on the box around `Form` spaces the form from what is beside it, so the gap above its first field is not the form’s 24px. Put what is beside it inside the `Form`, as one block before the fields, and drop the box: the Form puts 24px between them (Katerina, 1 October; MoveDialog in Peek does this).' }],
207
+ },
192
208
  {
193
209
  name: 'a Form laid out by hand',
194
210
  filename: app,
@@ -105,6 +105,12 @@ const isFieldsLayout = (token: string) => {
105
105
  return !variants.some((variant) => REACHING_VARIANT.test(variant)) && FIELDS_LAYOUT.test(utility)
106
106
  }
107
107
 
108
+ /** Room between a box's children, down the page or across it: what spaces a Form from its neighbours. */
109
+ const isSpacing = (token: string) => {
110
+ const { variants, utility } = splitClass(token)
111
+ return !variants.some((variant) => REACHING_VARIANT.test(variant)) && /^(?:gap|gap-x|gap-y|space-x|space-y)-/.test(utility)
112
+ }
113
+
108
114
  const isPadding = (token: string) => {
109
115
  const { variants, utility } = splitClass(token)
110
116
  return !variants.some((variant) => REACHING_VARIANT.test(variant)) && /^p[xytrblse]?-/.test(utility)
@@ -623,6 +629,8 @@ export const noRestyledPart: Rule.RuleModule = {
623
629
  '{{classes}} pads {{where}}. EmptyState takes no padding (Katerina, 14 September): its room comes from the box its rows live in, so put the padding on that box.',
624
630
  formLayout:
625
631
  '{{classes}} lays out the fields of {{where}}. A Form places its own fields, 24px apart (Katerina, 28 September), so every form reads the same: remove it. A form that is not a list of fields (a field and its button, a composer) says `layout="free"` and lays itself out.',
632
+ formSpacedFromOutside:
633
+ '{{classes}} on the box around `Form` spaces the form from what is beside it, so the gap above its first field is not the form’s 24px. Put what is beside it inside the `Form`, as one block before the fields, and drop the box: the Form puts 24px between them (Katerina, 1 October; MoveDialog in Peek does this).',
626
634
  ...ESCAPE_MESSAGES,
627
635
  },
628
636
  },
@@ -685,6 +693,34 @@ export const noRestyledPart: Rule.RuleModule = {
685
693
  context.report({ loc: at.loc as NonNullable<Rule.Node['loc']>, messageId: 'restyledStyle', data: { keys: code([...new Set(keys)]), where, use } })
686
694
  }
687
695
 
696
+ /**
697
+ * A Form with something beside it, in a box that spaces them: the gap
698
+ * above the first field is then the box's, not the Form's 24px (Peek's and
699
+ * Ship's ArchiveDialog, 1 October: 12px). Read on the box's own class
700
+ * props, `className` or a part's `bodyClassName`; a box with only the Form
701
+ * in it spaces nothing.
702
+ */
703
+ const checkSpacedFromOutside = (opening: Node) => {
704
+ const form = opening.parent as Node | undefined
705
+ const box = form?.parent as Node | undefined
706
+ if (form?.type !== 'JSXElement' || box?.type !== 'JSXElement') return
707
+ const beside = children(box, 'children').filter((node) => {
708
+ if (!node || node === form) return false
709
+ if (node.type === 'JSXText') return String(node.value).trim() !== ''
710
+ if (node.type === 'JSXExpressionContainer') return child(node, 'expression')?.type !== 'JSXEmptyExpression'
711
+ return true
712
+ })
713
+ if (!beside.length) return
714
+ const boxOpening = child(box, 'openingElement') as Node
715
+ const spacing = children(boxOpening, 'attributes').flatMap((attribute) => {
716
+ if (attribute?.type !== 'JSXAttribute') return []
717
+ const prop = nameOf(child(attribute, 'name'))
718
+ return prop === 'className' || prop === 'bodyClassName' ? classesOf(child(attribute, 'value'), scopeOf).filter(isSpacing) : []
719
+ })
720
+ if (!spacing.length || escaped(opening)) return
721
+ context.report({ loc: opening.loc as NonNullable<Rule.Node['loc']>, messageId: 'formSpacedFromOutside', data: { classes: code([...new Set(spacing)]) } })
722
+ }
723
+
688
724
  return {
689
725
  Program(program) {
690
726
  locals = moduleParts(file, program as unknown as Node, parser, new Set([file])).locals
@@ -693,6 +729,7 @@ export const noRestyledPart: Rule.RuleModule = {
693
729
  const element = ruleNode as unknown as Node
694
730
  const binding = locals && bindingOfTag(child(element, 'name'), locals)
695
731
  if (!binding) return
732
+ if (binding.part === 'Form' && binding.props === 'all' && placesItsFields(element)) checkSpacedFromOutside(element)
696
733
  for (const attribute of children(element, 'attributes')) {
697
734
  if (!attribute) continue
698
735
  if (attribute.type === 'JSXAttribute') {