@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.
- package/README.md +2 -0
- package/dist/BlockNodes.d.ts +13 -0
- package/dist/BlockNodes.d.ts.map +1 -1
- package/dist/editor.js +90 -0
- package/dist/editor.js.map +3 -3
- package/dist/eslint/index.js +26 -0
- package/dist/eslint/index.js.map +2 -2
- package/dist/eslint/no-restyled-part.d.ts.map +1 -1
- package/package.json +1 -1
- package/registry.json +1 -1
- package/src/BlockNodes.test.tsx +98 -0
- package/src/BlockNodes.tsx +142 -0
- package/src/Form.mdx +6 -0
- package/src/eslint/no-restyled-part.test.ts +16 -0
- package/src/eslint/no-restyled-part.ts +37 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"no-restyled-part.d.ts","sourceRoot":"","sources":["../../src/eslint/no-restyled-part.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,IAAI,EAAE,KAAK,EAAE,MAAM,QAAQ,CAAA;AAGzC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AAIH,6FAA6F;AAC7F,eAAO,MAAM,SAAS,EAAE;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,EAUxD,CAAA;AAED;;;;;;GAMG;AACH,eAAO,MAAM,eAAe,QACke,CAAA;AAS9f,oGAAoG;AACpG,wBAAgB,UAAU,CAAC,KAAK,EAAE,MAAM,GAAG;IAAE,QAAQ,EAAE,MAAM,EAAE,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAajF;AAED,4CAA4C;AAC5C,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAIlD;
|
|
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
package/registry.json
CHANGED
package/src/BlockNodes.test.tsx
CHANGED
|
@@ -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({
|
package/src/BlockNodes.tsx
CHANGED
|
@@ -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') {
|