@estiva-app/ui 0.48.0 → 0.50.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/README.md +8 -0
- package/dist/DialogShell.d.ts.map +1 -1
- package/dist/eslint/index.d.ts +2 -0
- package/dist/eslint/index.d.ts.map +1 -1
- package/dist/eslint/index.js +110 -25
- package/dist/eslint/index.js.map +4 -4
- package/dist/eslint/no-field-outside-a-form.d.ts +35 -0
- package/dist/eslint/no-field-outside-a-form.d.ts.map +1 -0
- package/dist/eslint/no-restyled-part.d.ts.map +1 -1
- package/dist/eslint/rule-ids.d.ts +1 -1
- package/dist/eslint/rule-ids.d.ts.map +1 -1
- package/dist/gates/{chunk-DZVDOJGV.js → chunk-FIBZLYQI.js} +3 -2
- package/dist/gates/{chunk-DZVDOJGV.js.map → chunk-FIBZLYQI.js.map} +2 -2
- package/dist/gates/create-app.js +1 -1
- package/dist/gates/index.js +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +2 -2
- package/package.json +1 -1
- package/registry.json +1 -1
- package/src/DialogShell.stories.tsx +18 -0
- package/src/DialogShell.tsx +4 -2
- package/src/Form.mdx +10 -0
- package/src/eslint/index.test.ts +6 -4
- package/src/eslint/index.ts +4 -0
- package/src/eslint/no-field-outside-a-form.test.ts +63 -0
- package/src/eslint/no-field-outside-a-form.ts +92 -0
- package/src/eslint/no-restyled-part.test.ts +16 -0
- package/src/eslint/no-restyled-part.ts +37 -0
- package/src/eslint/rule-ids.ts +1 -0
- package/src/gates/create-app.test.ts +1 -1
- package/src/gates/gates.test.ts +1 -1
package/package.json
CHANGED
package/registry.json
CHANGED
|
@@ -53,6 +53,24 @@ export const Default: Story = {
|
|
|
53
53
|
},
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
/**
|
|
57
|
+
* **A long title keeps to its one line** and ends in "…", so the bar stays
|
|
58
|
+
* 48px and the ✕ stays where it is. A title that names something a person
|
|
59
|
+
* typed — a file's name in "Archive “…”?" — is the one that gets long.
|
|
60
|
+
*/
|
|
61
|
+
export const ALongTitle: Story = {
|
|
62
|
+
args: {
|
|
63
|
+
title: 'Archive “Interesting Claude-generated docs - Renamed 4, with notes from the second review”?',
|
|
64
|
+
footer: (
|
|
65
|
+
<>
|
|
66
|
+
<Button variant="muted">Cancel</Button>
|
|
67
|
+
<Button variant="primary">Archive topic</Button>
|
|
68
|
+
</>
|
|
69
|
+
),
|
|
70
|
+
children: <p className="text-body-2 text-text-primary">This hides the topic from every list.</p>,
|
|
71
|
+
},
|
|
72
|
+
}
|
|
73
|
+
|
|
56
74
|
/**
|
|
57
75
|
* **`headerContent` replaces the title text** — a back button beside it, a
|
|
58
76
|
* count after it — and the ✕ stays. The dialog is then named by its `title`
|
package/src/DialogShell.tsx
CHANGED
|
@@ -115,11 +115,13 @@ export function DialogShell({ title, onClose, headerContent, footer, children, b
|
|
|
115
115
|
className="bg-bg-elevated border border-border-subtle rounded-lg shadow-lg pointer-events-auto flex flex-col overflow-hidden outline-none"
|
|
116
116
|
style={{ width }}
|
|
117
117
|
>
|
|
118
|
-
{/* Header: the package's one header bar, with the dialog's own title in it (UIG-22).
|
|
118
|
+
{/* Header: the package's one header bar, with the dialog's own title in it (UIG-22).
|
|
119
|
+
The title keeps to its one line and ends in "…": a long name wrapped to
|
|
120
|
+
two lines in the 48px bar and lost its top (Katerina, 1 October). */}
|
|
119
121
|
<ContainerHeader
|
|
120
122
|
title={
|
|
121
123
|
headerContent ?? (
|
|
122
|
-
<Parts.Title className="text-h4 text-text-primary" render={<span />}>
|
|
124
|
+
<Parts.Title className="block truncate text-h4 text-text-primary" render={<span />}>
|
|
123
125
|
{title}
|
|
124
126
|
</Parts.Title>
|
|
125
127
|
)
|
package/src/Form.mdx
CHANGED
|
@@ -22,6 +22,10 @@ is switched off at once.
|
|
|
22
22
|
- One field that saves itself as you leave it → **EditableText**.
|
|
23
23
|
- A field that filters a list as you type → **SearchInput**; nothing is sent.
|
|
24
24
|
|
|
25
|
+
A `Field`, `TextInput` or `Textarea` with no `Form` around it is refused by
|
|
26
|
+
the gate (`estiva/no-field-outside-a-form`). A question with a field in it is
|
|
27
|
+
**DialogShell** with a form, never **ConfirmDialog**.
|
|
28
|
+
|
|
25
29
|
## How
|
|
26
30
|
|
|
27
31
|
```tsx
|
|
@@ -59,6 +63,11 @@ import { Button, Field, Form, TextInput } from '@estiva-app/ui'
|
|
|
59
63
|
September), so every form reads the same. Pass no layout of your own: the
|
|
60
64
|
gate lint refuses a gap, a direction or a grid on a `Form`. `className`
|
|
61
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).
|
|
62
71
|
- **`layout="free"`** is for a form that is not a list of fields: a field and
|
|
63
72
|
its button, a composer. It places nothing, and `className` lays it out.
|
|
64
73
|
- The form draws no box, and neither does the `<fieldset>` inside it.
|
|
@@ -73,6 +82,7 @@ It does these by itself. Write none of them around it.
|
|
|
73
82
|
| Leaves Enter alone where it means something else: a Textarea, a list, a people picker | exceptions of your own | |
|
|
74
83
|
| Does nothing while it is sending: no second send, and nothing inside takes focus | a busy flag on every control | |
|
|
75
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` |
|
|
76
86
|
|
|
77
87
|
## Keys
|
|
78
88
|
|
package/src/eslint/index.test.ts
CHANGED
|
@@ -41,6 +41,7 @@ describe('the plugin object', () => {
|
|
|
41
41
|
'no-handmade-empty-state',
|
|
42
42
|
'no-native-title',
|
|
43
43
|
'no-copied-look',
|
|
44
|
+
'no-field-outside-a-form',
|
|
44
45
|
'raw-element-outside-a-wrapper',
|
|
45
46
|
'no-hand-rolled-behaviour',
|
|
46
47
|
'component-has-a-page',
|
|
@@ -58,12 +59,12 @@ describe('the plugin object', () => {
|
|
|
58
59
|
* change it deliberately, here.
|
|
59
60
|
*/
|
|
60
61
|
it('gives an app only the app rules, as errors, under estiva/', () => {
|
|
61
|
-
const blocking = { 'estiva/no-raw-element': 'error', 'estiva/no-rebuilt-behaviour': 'error', 'estiva/no-restyled-part': 'error', 'estiva/no-handmade-header': 'error', 'estiva/no-handmade-empty-state': 'error', 'estiva/no-native-title': 'error' }
|
|
62
|
+
const blocking = { 'estiva/no-raw-element': 'error', 'estiva/no-rebuilt-behaviour': 'error', 'estiva/no-restyled-part': 'error', 'estiva/no-handmade-header': 'error', 'estiva/no-handmade-empty-state': 'error', 'estiva/no-native-title': 'error', 'estiva/no-field-outside-a-form': 'error' }
|
|
62
63
|
for (const config of [estiva.configs.recommended, estiva.configs.strict]) expect(config.plugins?.[PLUGIN_KEY]).toBe(estiva)
|
|
63
64
|
// The copied look warns in recommended (Katerina, 13 September), and strict makes it an error.
|
|
64
65
|
expect(estiva.configs.recommended.rules).toEqual({ ...blocking, 'estiva/no-copied-look': 'warn' })
|
|
65
66
|
expect(estiva.configs.strict.rules).toEqual({ ...blocking, 'estiva/no-copied-look': 'error' })
|
|
66
|
-
expect(APP_RULE_IDS).toEqual(['estiva/no-raw-element', 'estiva/no-rebuilt-behaviour', 'estiva/no-restyled-part', 'estiva/no-handmade-header', 'estiva/no-handmade-empty-state', 'estiva/no-native-title', 'estiva/no-copied-look'])
|
|
67
|
+
expect(APP_RULE_IDS).toEqual(['estiva/no-raw-element', 'estiva/no-rebuilt-behaviour', 'estiva/no-restyled-part', 'estiva/no-handmade-header', 'estiva/no-handmade-empty-state', 'estiva/no-native-title', 'estiva/no-copied-look', 'estiva/no-field-outside-a-form'])
|
|
67
68
|
})
|
|
68
69
|
|
|
69
70
|
/**
|
|
@@ -154,8 +155,8 @@ describe('countGates', () => {
|
|
|
154
155
|
it('counts an error, and an escape only when the lint reports escapes', async () => {
|
|
155
156
|
const code = component(' <div>\n <button>x</button>\n {/* @estiva-escape(no-raw-element): a preview drawn from its own palette */}\n <button>y</button>\n </div>')
|
|
156
157
|
const none = { errors: 0, warnings: 0, escapes: 0 }
|
|
157
|
-
expect(countGates(await lint(code)).rules).toEqual({ 'estiva/no-raw-element': { errors: 1, warnings: 0, escapes: 0 }, 'estiva/no-rebuilt-behaviour': none, 'estiva/no-restyled-part': none, 'estiva/no-handmade-header': none, 'estiva/no-handmade-empty-state': none, 'estiva/no-native-title': none, 'estiva/no-copied-look': none })
|
|
158
|
-
expect(countGates(await lint(code, [countMode])).rules).toEqual({ 'estiva/no-raw-element': { errors: 1, warnings: 0, escapes: 1 }, 'estiva/no-rebuilt-behaviour': none, 'estiva/no-restyled-part': none, 'estiva/no-handmade-header': none, 'estiva/no-handmade-empty-state': none, 'estiva/no-native-title': none, 'estiva/no-copied-look': none })
|
|
158
|
+
expect(countGates(await lint(code)).rules).toEqual({ 'estiva/no-raw-element': { errors: 1, warnings: 0, escapes: 0 }, 'estiva/no-rebuilt-behaviour': none, 'estiva/no-restyled-part': none, 'estiva/no-handmade-header': none, 'estiva/no-handmade-empty-state': none, 'estiva/no-native-title': none, 'estiva/no-copied-look': none, 'estiva/no-field-outside-a-form': none })
|
|
159
|
+
expect(countGates(await lint(code, [countMode])).rules).toEqual({ 'estiva/no-raw-element': { errors: 1, warnings: 0, escapes: 1 }, 'estiva/no-rebuilt-behaviour': none, 'estiva/no-restyled-part': none, 'estiva/no-handmade-header': none, 'estiva/no-handmade-empty-state': none, 'estiva/no-native-title': none, 'estiva/no-copied-look': none, 'estiva/no-field-outside-a-form': none })
|
|
159
160
|
})
|
|
160
161
|
|
|
161
162
|
it('lists a report an eslint-disable silenced, and counts it as neither an error nor an escape', async () => {
|
|
@@ -180,6 +181,7 @@ describe('countGates', () => {
|
|
|
180
181
|
'estiva/no-handmade-empty-state': { errors: 0, warnings: 0, escapes: 0 },
|
|
181
182
|
'estiva/no-native-title': { errors: 0, warnings: 0, escapes: 0 },
|
|
182
183
|
'estiva/no-copied-look': { errors: 0, warnings: 0, escapes: 0 },
|
|
184
|
+
'estiva/no-field-outside-a-form': { errors: 0, warnings: 0, escapes: 0 },
|
|
183
185
|
},
|
|
184
186
|
disabled: [],
|
|
185
187
|
})
|
package/src/eslint/index.ts
CHANGED
|
@@ -23,6 +23,7 @@ import { noHandRolledBehaviour } from './no-hand-rolled-behaviour'
|
|
|
23
23
|
import { noHandmadeEmptyState } from './no-handmade-empty-state'
|
|
24
24
|
import { noHandmadeHeader } from './no-handmade-header'
|
|
25
25
|
import { noCopiedLook } from './no-copied-look'
|
|
26
|
+
import { noFieldOutsideAForm } from './no-field-outside-a-form'
|
|
26
27
|
import { noNativeTitle } from './no-native-title'
|
|
27
28
|
import { noRawElement } from './no-raw-element'
|
|
28
29
|
import { noRebuiltBehaviour } from './no-rebuilt-behaviour'
|
|
@@ -35,6 +36,7 @@ export { OWNED_BEHAVIOURS, type OwnedBehaviour } from './no-rebuilt-behaviour'
|
|
|
35
36
|
export { PART_LOOK_PROPS, PLACEMENT } from './no-restyled-part'
|
|
36
37
|
export { PARTS_WITH_A_TITLE } from './no-native-title'
|
|
37
38
|
export { MIN_COVER, MIN_SHARED } from './looks-of'
|
|
39
|
+
export { FIELD_PARTS } from './no-field-outside-a-form'
|
|
38
40
|
|
|
39
41
|
const { version } = createRequire(import.meta.url)('../../package.json') as { version: string }
|
|
40
42
|
|
|
@@ -56,6 +58,7 @@ const appRules = {
|
|
|
56
58
|
'no-handmade-empty-state': noHandmadeEmptyState,
|
|
57
59
|
'no-native-title': noNativeTitle,
|
|
58
60
|
'no-copied-look': noCopiedLook,
|
|
61
|
+
'no-field-outside-a-form': noFieldOutsideAForm,
|
|
59
62
|
} satisfies Record<AppRuleName, unknown>
|
|
60
63
|
|
|
61
64
|
/**
|
|
@@ -119,6 +122,7 @@ plugin.configs.recommended = {
|
|
|
119
122
|
[`${PLUGIN_KEY}/no-native-title`]: 'error',
|
|
120
123
|
// A warning, never an error (Katerina, 13 September): likeness is a judgement.
|
|
121
124
|
[`${PLUGIN_KEY}/no-copied-look`]: 'warn',
|
|
125
|
+
[`${PLUGIN_KEY}/no-field-outside-a-form`]: 'error',
|
|
122
126
|
},
|
|
123
127
|
}
|
|
124
128
|
plugin.configs.strict = {
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { dirname, join } from 'node:path'
|
|
4
|
+
import { RuleTester } from 'eslint'
|
|
5
|
+
import { parser } from 'typescript-eslint'
|
|
6
|
+
import { describe, it } from 'vitest'
|
|
7
|
+
import { noFieldOutsideAForm } from './no-field-outside-a-form'
|
|
8
|
+
|
|
9
|
+
RuleTester.describe = describe
|
|
10
|
+
RuleTester.it = it
|
|
11
|
+
RuleTester.itOnly = it.only
|
|
12
|
+
|
|
13
|
+
const tester = new RuleTester({
|
|
14
|
+
languageOptions: { parser, parserOptions: { ecmaFeatures: { jsx: true } } },
|
|
15
|
+
linterOptions: { reportUnusedDisableDirectives: 'off' },
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
/** An app on disk, so the app's re-exports are followed to their part, as Peek's are. */
|
|
19
|
+
const root = mkdtempSync(join(tmpdir(), 'no-field-outside-a-form-'))
|
|
20
|
+
const write = (path: string, text: string) => {
|
|
21
|
+
mkdirSync(dirname(join(root, path)), { recursive: true })
|
|
22
|
+
writeFileSync(join(root, path), text)
|
|
23
|
+
}
|
|
24
|
+
write('app/package.json', '{ "name": "app" }')
|
|
25
|
+
write('app/src/components/ui/Field.tsx', "export { Field } from '@estiva-app/ui'\n")
|
|
26
|
+
write('app/src/components/ui/Textarea.tsx', "export { Textarea } from '@estiva-app/ui'\n")
|
|
27
|
+
write('app/src/components/ui/ConfirmDialog.tsx', "export { ConfirmDialog } from '@estiva-app/ui'\n")
|
|
28
|
+
const app = join(root, 'app/src/Probe.tsx')
|
|
29
|
+
|
|
30
|
+
const ui = (...parts: string[]) => `import { ${parts.join(', ')} } from '@estiva-app/ui'\n`
|
|
31
|
+
const component = (body: string, imports = '') => `${imports}export function Probe({ text, set }: { text: string; set: (v: string) => void }) {\n return (\n${body}\n )\n}\n`
|
|
32
|
+
const loose = (part: string) => [{ messageId: 'loose', data: { part } }]
|
|
33
|
+
|
|
34
|
+
tester.run('no-field-outside-a-form', noFieldOutsideAForm, {
|
|
35
|
+
valid: [
|
|
36
|
+
{
|
|
37
|
+
name: "Peek's ResolveDialog: a DialogShell with a Form",
|
|
38
|
+
code: component('<DialogShell title="Resolve" onClose={() => {}}>\n<Form onSubmit={() => {}}>\n<Field label="Message"><Textarea value={text} onChange={(e) => set(e.target.value)} /></Field>\n</Form>\n</DialogShell>', ui('DialogShell', 'Form', 'Field', 'Textarea')),
|
|
39
|
+
},
|
|
40
|
+
{ name: 'a one-line field with its button', code: component('<Form onSubmit={() => {}}><TextInput value={text} onChange={(e) => set(e.target.value)} /></Form>', ui('Form', 'TextInput')) },
|
|
41
|
+
{ name: 'fields drawn from a list, inside the form', code: component("<Form onSubmit={() => {}}>{['a', 'b'].map((k) => <Field key={k} label={k}><TextInput value={text} /></Field>)}</Form>", ui('Form', 'Field', 'TextInput')) },
|
|
42
|
+
{ name: "the palette's form level is a Form (UIG-29)", code: component('<CommandPaletteForm submitLabel="Create" onSubmit={() => {}}><Field label="Title"><TextInput value={text} /></Field></CommandPaletteForm>', ui('CommandPaletteForm', 'Field', 'TextInput')) },
|
|
43
|
+
{ name: 'a field that saves itself as you leave it', code: component('<EditableText value={text} onCommit={set} />', ui('EditableText')) },
|
|
44
|
+
{ name: 'a field that filters a list', code: component('<SearchInput value={text} onChange={set} />', ui('SearchInput')) },
|
|
45
|
+
{
|
|
46
|
+
name: "a control handed to a Field from a function of its own: the Field is judged (Peek's ActionFormPanel)",
|
|
47
|
+
code: `${ui('Field', 'TextInput')}const control = (text: string) => <TextInput value={text} />\nexport function Probe({ text }: { text: string }) {\n return (\n<>\n{/* @estiva-escape(no-field-outside-a-form): the launcher's CommandPaletteForm holds these fields */}\n<Field label="Title">{control(text)}</Field>\n</>\n )\n}\n`,
|
|
48
|
+
},
|
|
49
|
+
{ name: "the app's own component is its own business", code: component('<ResolutionField value={text} />', "import { ResolutionField } from './ResolutionField'\n") },
|
|
50
|
+
{ name: 'an escape with its reason', code: component('<>\n{/* @estiva-escape(no-field-outside-a-form): the caller wraps this in its Form, in StartTopicDialog */}\n<Field label="Title"><TextInput value={text} /></Field>\n</>', ui('Field', 'TextInput')) },
|
|
51
|
+
],
|
|
52
|
+
invalid: [
|
|
53
|
+
{
|
|
54
|
+
name: "Peek's ArchiveDialog at peek 3c4b799: a resolution in a ConfirmDialog",
|
|
55
|
+
filename: app,
|
|
56
|
+
code: component('<ConfirmDialog title="Archive?" confirmLabel="Archive topic" onClose={() => {}} onConfirm={() => {}}>\n<p>This hides the topic.</p>\n<Field label="Resolution (optional)">\n<Textarea value={text} rows={3} onChange={(e) => set(e.target.value)} />\n</Field>\n</ConfirmDialog>', "import { ConfirmDialog } from '@/components/ui/ConfirmDialog'\nimport { Field } from '@/components/ui/Field'\nimport { Textarea } from '@/components/ui/Textarea'\n"),
|
|
57
|
+
errors: loose('Field'),
|
|
58
|
+
},
|
|
59
|
+
{ name: 'a bare text input with no form', code: component('<TextInput value={text} onChange={(e) => set(e.target.value)} />', ui('TextInput')), errors: loose('TextInput') },
|
|
60
|
+
{ name: 'a textarea in a DialogShell with no form', code: component('<DialogShell title="Note" onClose={() => {}}><Textarea value={text} /></DialogShell>', ui('DialogShell', 'Textarea')), errors: loose('Textarea') },
|
|
61
|
+
{ name: 'an escape with no reason is not an escape', code: component('<>\n{/* @estiva-escape(no-field-outside-a-form) */}\n<Field label="Title"><TextInput value={text} /></Field>\n</>', ui('Field', 'TextInput')), errors: [{ messageId: 'escapeWithoutReason' }, ...loose('Field')] },
|
|
62
|
+
],
|
|
63
|
+
})
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type { Rule } from 'eslint'
|
|
2
|
+
import { ESCAPE_MESSAGES, isEscaped } from './escape'
|
|
3
|
+
import { bindingOfTag, partsOfFile, type Node, type Parser, type PartBinding } from './no-restyled-part'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The parts a person types into to send something. `Field` is the labelled
|
|
7
|
+
* row; `TextInput` and `Textarea` are judged on their own only in a file that
|
|
8
|
+
* draws no `Field`, so a field is reported once, at its label.
|
|
9
|
+
*/
|
|
10
|
+
export const FIELD_PARTS = ['Field', 'TextInput', 'Textarea'] as const
|
|
11
|
+
|
|
12
|
+
/** The parts that are a `Form`: the package's own, and the palette's form level, which is one (UIG-29). */
|
|
13
|
+
export const FORM_PARTS = ['Form', 'CommandPaletteForm'] as const
|
|
14
|
+
|
|
15
|
+
const child = (node: Node, key: string) => node[key] as Node | null | undefined
|
|
16
|
+
const nameOf = (node: Node | null | undefined): string | undefined =>
|
|
17
|
+
node?.type === 'JSXIdentifier' || node?.type === 'Identifier' ? (node.name as string) : undefined
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A field outside a `Form` (Katerina, 1 October, after Peek's and Ship's
|
|
21
|
+
* Archive dialog: a resolution typed into a `ConfirmDialog`, sent by its
|
|
22
|
+
* button, with no form around it — Ctrl+Enter did nothing, and the field
|
|
23
|
+
* stayed open while it archived).
|
|
24
|
+
*
|
|
25
|
+
* `Form` owns what a field needs to be sent: Enter and Ctrl+Enter, `busy`
|
|
26
|
+
* switching every field off at once, and where focus goes after. A field
|
|
27
|
+
* outside one has none of it, and the dialog around it is the wrong one: a
|
|
28
|
+
* question with a field in it is a `DialogShell` with a `Form`
|
|
29
|
+
* (ConfirmDialog's page, **When not**).
|
|
30
|
+
*
|
|
31
|
+
* Judged: a `Field`, a `TextInput` or a `Textarea` of the package (or an
|
|
32
|
+
* app's re-export or wrapper of one) with no `Form` around it in the same
|
|
33
|
+
* file. Not judged, on purpose:
|
|
34
|
+
*
|
|
35
|
+
* - `EditableText` and `SearchInput`: one saves itself as you leave it, the
|
|
36
|
+
* other filters a list, and neither is sent (Form's page, **When not**);
|
|
37
|
+
* - a `TextInput` or `Textarea` in a file that draws a `Field`: the `Field`
|
|
38
|
+
* is reported, and a control handed to it from a function of its own (a
|
|
39
|
+
* `switch` over field kinds) cannot be followed to it;
|
|
40
|
+
* - the app's own components: a component that draws a field for a form its
|
|
41
|
+
* caller holds says so with an escape on the field, naming that caller.
|
|
42
|
+
*/
|
|
43
|
+
export const noFieldOutsideAForm: Rule.RuleModule = {
|
|
44
|
+
meta: {
|
|
45
|
+
type: 'problem',
|
|
46
|
+
docs: { description: 'A field with no Form around it' },
|
|
47
|
+
schema: [],
|
|
48
|
+
messages: {
|
|
49
|
+
loose:
|
|
50
|
+
'`{{part}}` has no `Form` around it, so nothing owns sending it: Enter and Ctrl+Enter, `busy`, and focus after. Put it in `Form` from @estiva-app/ui; in a dialog, that is `DialogShell` with a `Form`, never `ConfirmDialog` (Primitives/Form says how).',
|
|
51
|
+
...ESCAPE_MESSAGES,
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
create(context) {
|
|
55
|
+
const parser = (context.languageOptions?.parser ?? {}) as Parser
|
|
56
|
+
let locals: Map<string, PartBinding | 'namespace'> | undefined
|
|
57
|
+
const loose: { opening: Node; part: string }[] = []
|
|
58
|
+
let drawsAField = false
|
|
59
|
+
const partOf = (element: Node): string | undefined => {
|
|
60
|
+
const tag = child(child(element, 'openingElement') as Node, 'name')
|
|
61
|
+
const name = nameOf(tag)
|
|
62
|
+
if (!name || /^[a-z]/.test(name)) return undefined
|
|
63
|
+
const binding = locals && bindingOfTag(tag, locals)
|
|
64
|
+
return binding && binding.props === 'all' ? binding.part : undefined
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
Program(program) {
|
|
68
|
+
locals = partsOfFile(context.filename, program as unknown as Node, parser)
|
|
69
|
+
},
|
|
70
|
+
JSXElement(ruleNode: Rule.Node) {
|
|
71
|
+
const element = ruleNode as unknown as Node
|
|
72
|
+
const part = partOf(element)
|
|
73
|
+
if (!part || !(FIELD_PARTS as readonly string[]).includes(part)) return
|
|
74
|
+
if (part === 'Field') drawsAField = true
|
|
75
|
+
for (let up = element.parent as Node | undefined; up; up = up.parent as Node | undefined) {
|
|
76
|
+
if (up.type !== 'JSXElement') continue
|
|
77
|
+
const around = partOf(up)
|
|
78
|
+
if (around && (FORM_PARTS as readonly string[]).includes(around)) return
|
|
79
|
+
if (around === 'Field') return
|
|
80
|
+
}
|
|
81
|
+
loose.push({ opening: child(element, 'openingElement') as Node, part })
|
|
82
|
+
},
|
|
83
|
+
'Program:exit'() {
|
|
84
|
+
for (const { opening, part } of loose) {
|
|
85
|
+
if (part !== 'Field' && drawsAField) continue
|
|
86
|
+
if (isEscaped(context, opening)) continue
|
|
87
|
+
context.report({ loc: opening.loc as NonNullable<Rule.Node['loc']>, messageId: 'loose', data: { part } })
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
}
|
|
@@ -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') {
|
package/src/eslint/rule-ids.ts
CHANGED
|
@@ -72,7 +72,7 @@ describe('create-estiva-app', () => {
|
|
|
72
72
|
|
|
73
73
|
it('starts the count at zero and keeps the CI job named gate', () => {
|
|
74
74
|
const count = JSON.parse(files['.gates-count.json'])
|
|
75
|
-
expect(Object.values(count.rules)).toEqual([{ errors: 0, warnings: 0, escapes: 0 }, { errors: 0, warnings: 0, escapes: 0 }, { errors: 0, warnings: 0, escapes: 0 }, { errors: 0, warnings: 0, escapes: 0 }, { errors: 0, warnings: 0, escapes: 0 }, { errors: 0, warnings: 0, escapes: 0 }, { errors: 0, warnings: 0, escapes: 0 }])
|
|
75
|
+
expect(Object.values(count.rules)).toEqual([{ errors: 0, warnings: 0, escapes: 0 }, { errors: 0, warnings: 0, escapes: 0 }, { errors: 0, warnings: 0, escapes: 0 }, { errors: 0, warnings: 0, escapes: 0 }, { errors: 0, warnings: 0, escapes: 0 }, { errors: 0, warnings: 0, escapes: 0 }, { errors: 0, warnings: 0, escapes: 0 }, { errors: 0, warnings: 0, escapes: 0 }])
|
|
76
76
|
expect(files['.github/workflows/deploy.yml']).toMatch(/\n {2}gate:\n[\s\S]*run: npx estiva-gates ci/)
|
|
77
77
|
expect(files['docs/GATES-DEBT.md']).toContain('Nothing.')
|
|
78
78
|
})
|
package/src/gates/gates.test.ts
CHANGED
|
@@ -194,7 +194,7 @@ describe('writeGateCount', () => {
|
|
|
194
194
|
const written = JSON.parse(readFileSync(join(dir, '.gates-count.json'), 'utf8'))
|
|
195
195
|
expect(written).toMatchObject({ schemaVersion: 1, repo: 'probe' })
|
|
196
196
|
expect(written.rules['estiva/no-raw-element']).toEqual({ errors: 0, warnings: 0, escapes: 1 })
|
|
197
|
-
expect(Object.keys(written.rules)).toEqual(['estiva/no-raw-element', 'estiva/no-rebuilt-behaviour', 'estiva/no-restyled-part', 'estiva/no-handmade-header', 'estiva/no-handmade-empty-state', 'estiva/no-native-title', 'estiva/no-copied-look'])
|
|
197
|
+
expect(Object.keys(written.rules)).toEqual(['estiva/no-raw-element', 'estiva/no-rebuilt-behaviour', 'estiva/no-restyled-part', 'estiva/no-handmade-header', 'estiva/no-handmade-empty-state', 'estiva/no-native-title', 'estiva/no-copied-look', 'estiva/no-field-outside-a-form'])
|
|
198
198
|
|
|
199
199
|
expect((await writeGateCount({ root: dir, repo: 'probe' })).changed).toBe(false)
|
|
200
200
|
|