@estiva-app/ui 0.35.2 → 0.37.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/Panel.d.ts +44 -0
- package/dist/Panel.d.ts.map +1 -0
- package/dist/ScrollArea.d.ts +7 -2
- package/dist/ScrollArea.d.ts.map +1 -1
- package/dist/eslint/escape.d.ts +23 -6
- package/dist/eslint/escape.d.ts.map +1 -1
- package/dist/eslint/index.js +86 -12
- package/dist/eslint/index.js.map +3 -3
- package/dist/eslint/no-handmade-header.d.ts +5 -0
- package/dist/eslint/no-handmade-header.d.ts.map +1 -1
- package/dist/eslint/no-native-title.d.ts +1 -1
- package/dist/eslint/no-native-title.d.ts.map +1 -1
- package/dist/eslint/no-raw-element.d.ts +7 -0
- package/dist/eslint/no-raw-element.d.ts.map +1 -1
- package/dist/eslint/no-restyled-part.d.ts +8 -0
- package/dist/eslint/no-restyled-part.d.ts.map +1 -1
- package/dist/gates/app-checks.d.ts +1 -1
- package/dist/gates/app-checks.d.ts.map +1 -1
- package/dist/gates/{chunk-DRIHHXQK.js → chunk-CC6L4B5U.js} +23 -17
- package/dist/gates/chunk-CC6L4B5U.js.map +7 -0
- package/dist/gates/chunk-SYVKGOWM.js +1131 -0
- package/dist/gates/chunk-SYVKGOWM.js.map +7 -0
- package/dist/gates/ci.d.ts +18 -0
- package/dist/gates/ci.d.ts.map +1 -0
- package/dist/gates/cli.js +20 -3
- package/dist/gates/cli.js.map +2 -2
- package/dist/gates/create-app.d.ts.map +1 -1
- package/dist/gates/create-app.js +1 -1
- package/dist/gates/escapes.d.ts +23 -0
- package/dist/gates/escapes.d.ts.map +1 -0
- package/dist/gates/gate-config.d.ts.map +1 -1
- package/dist/gates/hook.d.ts +2 -0
- package/dist/gates/hook.d.ts.map +1 -1
- package/dist/gates/index.d.ts +5 -1
- package/dist/gates/index.d.ts.map +1 -1
- package/dist/gates/index.js +42 -185
- package/dist/gates/index.js.map +4 -4
- package/dist/gates/name-escapes.d.ts +14 -0
- package/dist/gates/name-escapes.d.ts.map +1 -0
- package/dist/gates/status.d.ts +3 -1
- package/dist/gates/status.d.ts.map +1 -1
- package/dist/gates/token-lint.d.ts.map +1 -1
- package/dist/gates/token-style.d.ts +21 -0
- package/dist/gates/token-style.d.ts.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +76 -49
- package/dist/index.js.map +4 -4
- package/dist/registry/{app-3ALZE6XO.js → app-YMABQII6.js} +3 -3
- package/dist/registry/app.d.ts.map +1 -1
- package/dist/registry/behaviours.d.ts +71 -0
- package/dist/registry/behaviours.d.ts.map +1 -0
- package/dist/registry/{build-NDXMVFQ5.js → build-3EANARLQ.js} +2 -2
- package/dist/registry/build.d.ts.map +1 -1
- package/dist/registry/{chunk-3WJ7ZZ7Z.js → chunk-EVJW26UR.js} +51 -4
- package/dist/registry/chunk-EVJW26UR.js.map +7 -0
- package/dist/registry/{chunk-XVII7P6G.js → chunk-SIB44CES.js} +276 -90
- package/dist/registry/chunk-SIB44CES.js.map +7 -0
- package/dist/registry/{chunk-5K2TRR4X.js → chunk-XK747STJ.js} +18 -3
- package/dist/registry/chunk-XK747STJ.js.map +7 -0
- package/dist/registry/cli.js +7 -6
- package/dist/registry/cli.js.map +2 -2
- package/dist/registry/contract.d.ts +13 -1
- package/dist/registry/contract.d.ts.map +1 -1
- package/dist/registry/index.d.ts +1 -1
- package/dist/registry/index.d.ts.map +1 -1
- package/dist/registry/index.js +11 -5
- package/package.json +4 -1
- package/registry.json +369 -19
- package/skill/estiva-ui.md +2 -1
- package/src/AppShell.mdx +2 -1
- package/src/AttachmentCard.tsx +7 -7
- package/src/Button.tsx +1 -1
- package/src/Card.tsx +2 -2
- package/src/ChipInput.mdx +2 -0
- package/src/ChipInput.tsx +1 -1
- package/src/CommandPalette.tsx +3 -3
- package/src/DialogShell.tsx +1 -1
- package/src/EmptyState.mdx +4 -3
- package/src/Lightbox.tsx +1 -1
- package/src/NavItem.tsx +1 -1
- package/src/Panel.mdx +78 -0
- package/src/Panel.stories.tsx +98 -0
- package/src/Panel.tsx +80 -0
- package/src/PersonTrigger.tsx +1 -1
- package/src/Reaction.tsx +1 -1
- package/src/ScrollArea.mdx +6 -2
- package/src/ScrollArea.stories.tsx +1 -1
- package/src/ScrollArea.tsx +9 -4
- package/src/Select.tsx +1 -1
- package/src/TopBar.mdx +1 -1
- package/src/eslint/escape.ts +43 -14
- package/src/eslint/has-a-page-and-a-story.test.ts +1 -1
- package/src/eslint/index.test.ts +2 -2
- package/src/eslint/no-copied-look.test.ts +1 -1
- package/src/eslint/no-hand-rolled-behaviour.test.ts +3 -3
- package/src/eslint/no-handmade-empty-state.test.ts +2 -2
- package/src/eslint/no-handmade-header.test.ts +11 -2
- package/src/eslint/no-handmade-header.ts +24 -2
- package/src/eslint/no-native-title.test.ts +2 -2
- package/src/eslint/no-native-title.ts +1 -1
- package/src/eslint/no-raw-element.test.ts +47 -15
- package/src/eslint/no-raw-element.ts +53 -3
- package/src/eslint/no-rebuilt-behaviour.test.ts +9 -9
- package/src/eslint/no-restyled-part.test.ts +21 -2
- package/src/eslint/no-restyled-part.ts +30 -0
- package/src/eslint/raw-element-outside-a-wrapper.test.ts +3 -3
- package/src/gates/app-checks.ts +9 -6
- package/src/gates/ci.ts +152 -0
- package/src/gates/cli.ts +19 -3
- package/src/gates/count.ts +1 -1
- package/src/gates/create-app.test.ts +2 -2
- package/src/gates/create-app.ts +22 -16
- package/src/gates/escapes.ts +111 -0
- package/src/gates/gate-config.ts +2 -0
- package/src/gates/gates.test.ts +86 -1
- package/src/gates/hook.ts +38 -4
- package/src/gates/index.ts +5 -1
- package/src/gates/name-escapes.ts +77 -0
- package/src/gates/status.ts +45 -2
- package/src/gates/token-lint.ts +11 -4
- package/src/gates/token-style.ts +79 -0
- package/src/how.test.ts +282 -0
- package/src/index.ts +1 -0
- package/src/pages.test.ts +6 -0
- package/src/registry/app.test.ts +37 -1
- package/src/registry/app.ts +52 -2
- package/src/registry/behaviours.ts +223 -0
- package/src/registry/build.ts +36 -0
- package/src/registry/cli.ts +5 -3
- package/src/registry/contract.test.ts +27 -2
- package/src/registry/contract.ts +28 -3
- package/src/registry/index.ts +1 -1
- package/src/registry/registry.test.ts +18 -4
- package/stories/Choosing.mdx +4 -3
- package/dist/gates/chunk-DBMUQAR6.js +0 -607
- package/dist/gates/chunk-DBMUQAR6.js.map +0 -7
- package/dist/gates/chunk-DRIHHXQK.js.map +0 -7
- package/dist/registry/chunk-3WJ7ZZ7Z.js.map +0 -7
- package/dist/registry/chunk-5K2TRR4X.js.map +0 -7
- package/dist/registry/chunk-XVII7P6G.js.map +0 -7
- /package/dist/registry/{app-3ALZE6XO.js.map → app-YMABQII6.js.map} +0 -0
- /package/dist/registry/{build-NDXMVFQ5.js.map → build-3EANARLQ.js.map} +0 -0
|
@@ -17,7 +17,7 @@ const tester = new RuleTester({
|
|
|
17
17
|
/** Real files of this package, so the rules are tested against what they read. */
|
|
18
18
|
const src = (name: string) => fileURLToPath(new URL(`../${name}`, import.meta.url))
|
|
19
19
|
const code = 'export function Probe() {\n return null\n}\n'
|
|
20
|
-
const escaped = '// @estiva-escape: a probe file that documents itself in its own page\nexport function Probe() {\n return null\n}\n'
|
|
20
|
+
const escaped = '// @estiva-escape(component-has-a-page, component-has-a-story): a probe file that documents itself in its own page\nexport function Probe() {\n return null\n}\n'
|
|
21
21
|
|
|
22
22
|
describe('the files these rules read', () => {
|
|
23
23
|
it('Button has a page and a story beside it, and FieldLine and MenuItem have both without a component file', () => {
|
package/src/eslint/index.test.ts
CHANGED
|
@@ -145,14 +145,14 @@ describe('an app lint with configs.recommended', () => {
|
|
|
145
145
|
})
|
|
146
146
|
|
|
147
147
|
it('passes the same element under an escape', async () => {
|
|
148
|
-
const [result] = await lint(component(' // @estiva-escape: a preview drawn from its own palette\n <button type="button">x</button>'))
|
|
148
|
+
const [result] = await lint(component(' // @estiva-escape(no-raw-element): a preview drawn from its own palette\n <button type="button">x</button>'))
|
|
149
149
|
expect(result.messages).toEqual([])
|
|
150
150
|
})
|
|
151
151
|
})
|
|
152
152
|
|
|
153
153
|
describe('countGates', () => {
|
|
154
154
|
it('counts an error, and an escape only when the lint reports escapes', async () => {
|
|
155
|
-
const code = component(' <div>\n <button>x</button>\n {/* @estiva-escape: a preview drawn from its own palette */}\n <button>y</button>\n </div>')
|
|
155
|
+
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
156
|
const none = { errors: 0, warnings: 0, escapes: 0 }
|
|
157
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
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 })
|
|
@@ -65,7 +65,7 @@ tester.run('no-copied-look', noCopiedLook, {
|
|
|
65
65
|
{
|
|
66
66
|
name: 'an escape with its reason',
|
|
67
67
|
filename: app,
|
|
68
|
-
code: probe(`<>\n {/* @estiva-escape: recorded in GATES.md, a label strip Katerina left as it is */}\n <span className="${SECTION_LABEL}">x</span>\n </>`),
|
|
68
|
+
code: probe(`<>\n {/* @estiva-escape(no-copied-look): recorded in GATES.md, a label strip Katerina left as it is */}\n <span className="${SECTION_LABEL}">x</span>\n </>`),
|
|
69
69
|
},
|
|
70
70
|
],
|
|
71
71
|
invalid: [
|
|
@@ -29,15 +29,15 @@ tester.run('no-hand-rolled-behaviour', noHandRolledBehaviour, {
|
|
|
29
29
|
},
|
|
30
30
|
{
|
|
31
31
|
name: 'the import of a portal, kept with its reason',
|
|
32
|
-
code: "// @estiva-escape: the toast keeps its own portal until migration stage 6\nimport { createPortal } from 'react-dom'\nexport const Probe = () => null\n",
|
|
32
|
+
code: "// @estiva-escape(no-hand-rolled-behaviour): the toast keeps its own portal until migration stage 6\nimport { createPortal } from 'react-dom'\nexport const Probe = () => null\n",
|
|
33
33
|
},
|
|
34
34
|
{
|
|
35
35
|
name: 'the call, kept with its reason',
|
|
36
|
-
code: 'export function probe(to: HTMLElement) {\n // @estiva-escape: the toast keeps its own portal until migration stage 6\n return createPortal(null, to)\n}\n',
|
|
36
|
+
code: 'export function probe(to: HTMLElement) {\n // @estiva-escape(no-hand-rolled-behaviour): the toast keeps its own portal until migration stage 6\n return createPortal(null, to)\n}\n',
|
|
37
37
|
},
|
|
38
38
|
{
|
|
39
39
|
name: 'a global listener kept with its reason',
|
|
40
|
-
code: 'export function probe() {\n // @estiva-escape: the frame measures the window, which no floating part owns\n window.addEventListener("resize", () => {})\n}\n',
|
|
40
|
+
code: 'export function probe() {\n // @estiva-escape(no-hand-rolled-behaviour): the frame measures the window, which no floating part owns\n window.addEventListener("resize", () => {})\n}\n',
|
|
41
41
|
},
|
|
42
42
|
],
|
|
43
43
|
invalid: [
|
|
@@ -91,7 +91,7 @@ tester.run('no-handmade-empty-state', noHandmadeEmptyState, {
|
|
|
91
91
|
{ name: 'a box holding more than the line', code: jsx('<div>{rows.length === 0 ? <div className="px-3"><p>Nothing yet.</p><Button>Add one</Button></div> : rows.map((r) => <p key={r}>{r}</p>)}</div>') },
|
|
92
92
|
{
|
|
93
93
|
name: 'an escape with its reason',
|
|
94
|
-
code: jsx('<div>{rows.length === 0 ? (\n// @estiva-escape: a printed report keeps its own empty line, set in the print style\n<p>Nothing to print.</p>\n) : rows.map((r) => <p key={r}>{r}</p>)}</div>'),
|
|
94
|
+
code: jsx('<div>{rows.length === 0 ? (\n// @estiva-escape(no-handmade-empty-state): a printed report keeps its own empty line, set in the print style\n<p>Nothing to print.</p>\n) : rows.map((r) => <p key={r}>{r}</p>)}</div>'),
|
|
95
95
|
},
|
|
96
96
|
|
|
97
97
|
// the right scope
|
|
@@ -118,7 +118,7 @@ tester.run('no-handmade-empty-state', noHandmadeEmptyState, {
|
|
|
118
118
|
},
|
|
119
119
|
{
|
|
120
120
|
name: 'an escape with no reason is not an escape',
|
|
121
|
-
code: jsx('<div>{rows.length === 0 ? (\n// @estiva-escape\n<p>Nothing to print.</p>\n) : rows.map((r) => <p key={r}>{r}</p>)}</div>'),
|
|
121
|
+
code: jsx('<div>{rows.length === 0 ? (\n// @estiva-escape(no-handmade-empty-state)\n<p>Nothing to print.</p>\n) : rows.map((r) => <p key={r}>{r}</p>)}</div>'),
|
|
122
122
|
errors: [{ messageId: 'escapeWithoutReason' }, ...handmade],
|
|
123
123
|
},
|
|
124
124
|
|
|
@@ -17,6 +17,10 @@ const handmade = [{ messageId: 'handmade' }]
|
|
|
17
17
|
|
|
18
18
|
tester.run('no-handmade-header', noHandmadeHeader, {
|
|
19
19
|
valid: [
|
|
20
|
+
// 26 September: the package's own parts import the header from their sibling file.
|
|
21
|
+
{ name: 'the header inside the package, from its own file', code: `import { ContainerHeader } from './ContainerHeader'\n${component('<ContainerHeader title="Files" />')}` },
|
|
22
|
+
{ name: 'Panel in an app', code: `import { Panel } from '@estiva-app/ui'\n${component('<Panel title="Files">x</Panel>')}` },
|
|
23
|
+
{ name: 'the bare header in an app, escaped with its reason', code: `import { ContainerHeader } from '@estiva-app/ui'\n${component('// @estiva-escape(no-handmade-header): a header over a loading skeleton that cannot scroll\n<ContainerHeader title="Files" />')}` },
|
|
20
24
|
{ name: 'the part itself', code: component('<div className="flex h-full flex-col">\n<ContainerHeader title="Files" />\n<div />\n</div>') },
|
|
21
25
|
{
|
|
22
26
|
name: "a group heading inside a list: SectionHeader's row, not a column's bar (Ship's issue groups)",
|
|
@@ -33,10 +37,15 @@ tester.run('no-handmade-header', noHandmadeHeader, {
|
|
|
33
37
|
{ name: 'a column of rows', code: component('<div className="flex h-full flex-col">\n<div className="flex flex-col px-3"><span>{title}</span></div>\n<div />\n</div>') },
|
|
34
38
|
{
|
|
35
39
|
name: 'an escape with its reason',
|
|
36
|
-
code: component('<div className="flex h-full flex-col">\n{/* @estiva-escape: a print header that must not follow the theme */}\n<div className="flex items-center px-3 py-2"><span>{title}</span></div>\n<div />\n</div>'),
|
|
40
|
+
code: component('<div className="flex h-full flex-col">\n{/* @estiva-escape(no-handmade-header): a print header that must not follow the theme */}\n<div className="flex items-center px-3 py-2"><span>{title}</span></div>\n<div />\n</div>'),
|
|
37
41
|
},
|
|
38
42
|
],
|
|
39
43
|
invalid: [
|
|
44
|
+
{
|
|
45
|
+
name: "the package's header on its own in an app: a panel made by hand, naming Panel (26 September)",
|
|
46
|
+
code: `import { ContainerHeader as Header } from '@estiva-app/ui'\n${component('<div className="flex flex-col">\n<Header title="Estiva Ship" />\n<div className="px-4">{title}</div>\n</div>')}`,
|
|
47
|
+
errors: [{ messageId: 'bare' }],
|
|
48
|
+
},
|
|
40
49
|
{
|
|
41
50
|
// The Folders page's right pane, at the commit before 3dc663b.
|
|
42
51
|
name: 'a pane that heads itself around an editable name',
|
|
@@ -82,7 +91,7 @@ tester.run('no-handmade-header', noHandmadeHeader, {
|
|
|
82
91
|
},
|
|
83
92
|
{
|
|
84
93
|
name: 'an escape with no reason escapes nothing',
|
|
85
|
-
code: component('<div className="flex h-full flex-col">\n{/* @estiva-escape: */}\n<div className="flex items-center px-3 py-2"><span>{title}</span></div>\n<div />\n</div>'),
|
|
94
|
+
code: component('<div className="flex h-full flex-col">\n{/* @estiva-escape(no-handmade-header): */}\n<div className="flex items-center px-3 py-2"><span>{title}</span></div>\n<div />\n</div>'),
|
|
86
95
|
errors: [{ messageId: 'escapeWithoutReason' }, { messageId: 'handmade' }],
|
|
87
96
|
},
|
|
88
97
|
],
|
|
@@ -121,20 +121,42 @@ const INTERACTIVE = ['onClick', 'href', 'role', 'onKeyDown', 'onPointerDown']
|
|
|
121
121
|
* What it leaves alone, on purpose: a group heading inside a list (the row one
|
|
122
122
|
* section starts with), a label strip on a card, and a row a person presses.
|
|
123
123
|
* Only a lowercase element is judged: a part is already a part.
|
|
124
|
+
*
|
|
125
|
+
* And the package's `ContainerHeader` on its own in an app (26 September): a
|
|
126
|
+
* header over a box of the app's own is a panel made by hand, and the box is
|
|
127
|
+
* what forgot to scroll — Peek's widget panel cut off a long description. The
|
|
128
|
+
* message names `Panel`, whose body always scrolls.
|
|
124
129
|
*/
|
|
125
130
|
export const noHandmadeHeader: Rule.RuleModule = {
|
|
126
131
|
meta: {
|
|
127
132
|
type: 'problem',
|
|
128
|
-
docs: { description: "A column's header bar drawn by hand where
|
|
133
|
+
docs: { description: "A column's header bar drawn by hand, or the package's header on its own in an app, where Panel is the part" },
|
|
129
134
|
schema: [],
|
|
130
135
|
messages: {
|
|
131
136
|
handmade:
|
|
132
|
-
"A column's header bar drawn by hand. Use `
|
|
137
|
+
"A column's header bar drawn by hand. Use `Panel` from @estiva-app/ui: its header (48px, the title, the column's buttons at the right edge in `actions`, a hairline under it) and a body under it that scrolls. For a list beside the page, `ListColumn`.",
|
|
138
|
+
bare:
|
|
139
|
+
"A panel put together by hand: `ContainerHeader` over a box of your own, which cuts off whatever does not fit. Use `Panel` from @estiva-app/ui: the same header (`title`, `actions`), a body that scrolls whenever it is taller, and a `footer` that stays put. For a list beside the page, `ListColumn`.",
|
|
133
140
|
...ESCAPE_MESSAGES,
|
|
134
141
|
},
|
|
135
142
|
},
|
|
136
143
|
create(context) {
|
|
144
|
+
// The package's header, imported by name in an app (Katerina, 26 September): in an
|
|
145
|
+
// app it comes with Panel or ListColumn, whose body scrolls. The package's own parts
|
|
146
|
+
// import it from their sibling file, so this never reads them.
|
|
147
|
+
const bare = new Set<string>()
|
|
137
148
|
return {
|
|
149
|
+
ImportDeclaration(node: Rule.Node) {
|
|
150
|
+
const declaration = node as unknown as { source: { value: unknown }; specifiers: { type: string; imported?: { name?: string }; local: { name: string } }[] }
|
|
151
|
+
if (declaration.source.value !== '@estiva-app/ui') return
|
|
152
|
+
for (const specifier of declaration.specifiers) if (specifier.type === 'ImportSpecifier' && specifier.imported?.name === 'ContainerHeader') bare.add(specifier.local.name)
|
|
153
|
+
},
|
|
154
|
+
JSXOpeningElement(node: Rule.Node) {
|
|
155
|
+
const element = node as unknown as JSXElement['openingElement']
|
|
156
|
+
if (element.name.type !== 'JSXIdentifier' || !bare.has(element.name.name ?? '')) return
|
|
157
|
+
if (isEscaped(context, element)) return
|
|
158
|
+
context.report({ loc: element.loc, messageId: 'bare' })
|
|
159
|
+
},
|
|
138
160
|
JSXElement(node: Rule.Node) {
|
|
139
161
|
const column = node as unknown as JSXElement
|
|
140
162
|
const kids = meaningful(column)
|
|
@@ -40,7 +40,7 @@ tester.run('no-native-title', noNativeTitle, {
|
|
|
40
40
|
{ name: "the app's own component with a title of its own (Peek's NewTopicBanner)", filename: app, code: component('<NewTopicBanner title={name} />', "import { NewTopicBanner } from '@/components/Banner'\n") },
|
|
41
41
|
{ name: "a story's title is the Storybook's, an object key", code: "const meta = { title: 'Primitives/Tooltip', component: Tooltip }\nexport default meta\n" },
|
|
42
42
|
{ name: 'a prop of another name', code: component('<span aria-label={name} data-title={name}>{name}</span>') },
|
|
43
|
-
{ name: 'an escape with its reason', code: component('<>\n{/* @estiva-escape: the print view has no pointer, and the address must survive a copy */}\n<span title={at}>{at}</span>\n</>') },
|
|
43
|
+
{ name: 'an escape with its reason', code: component('<>\n{/* @estiva-escape(no-native-title): the print view has no pointer, and the address must survive a copy */}\n<span title={at}>{at}</span>\n</>') },
|
|
44
44
|
],
|
|
45
45
|
invalid: [
|
|
46
46
|
{
|
|
@@ -54,7 +54,7 @@ tester.run('no-native-title', noNativeTitle, {
|
|
|
54
54
|
{ name: "a part that hands it on to its element: the apps' status probe", code: component('<Button title="Delete">x</Button>', "import { Button } from '@estiva-app/ui'\n"), errors: native },
|
|
55
55
|
{ name: "through the app's re-export", filename: app, code: component('<IconButton title="Close" aria-label="Close">x</IconButton>', "import { IconButton } from '@/components/ui/IconButton'\n"), errors: native },
|
|
56
56
|
{ name: "through the app's wrapper that hands its props on", filename: app, code: component('<Button title="Delete">x</Button>', "import { Button } from '@/components/ui/Button'\n"), errors: native },
|
|
57
|
-
{ name: 'an escape with no reason is not an escape', code: component('<>\n{/* @estiva-escape */}\n<span title={at}>{at}</span>\n</>'), errors: [{ messageId: 'escapeWithoutReason' }, ...native] },
|
|
57
|
+
{ name: 'an escape with no reason is not an escape', code: component('<>\n{/* @estiva-escape(no-native-title) */}\n<span title={at}>{at}</span>\n</>'), errors: [{ messageId: 'escapeWithoutReason' }, ...native] },
|
|
58
58
|
],
|
|
59
59
|
})
|
|
60
60
|
|
|
@@ -8,7 +8,7 @@ import { bindingOfTag, partsOfFile, type Node, type Parser, type PartBinding } f
|
|
|
8
8
|
* the browser shows it as its tooltip. `no-native-title.test.ts` holds this
|
|
9
9
|
* list to the catalogue: a part that gains a `title` of its own joins it.
|
|
10
10
|
*/
|
|
11
|
-
export const PARTS_WITH_A_TITLE = ['CollapsibleSection', 'ConfirmDialog', 'ContainerHeader', 'DialogShell', 'ListColumn', 'SectionHeader'] as const
|
|
11
|
+
export const PARTS_WITH_A_TITLE = ['CollapsibleSection', 'ConfirmDialog', 'ContainerHeader', 'DialogShell', 'ListColumn', 'Panel', 'SectionHeader'] as const
|
|
12
12
|
|
|
13
13
|
const child = (node: Node, key: string) => node[key] as Node | null | undefined
|
|
14
14
|
const nameOf = (node: Node | null | undefined): string | undefined =>
|
|
@@ -25,6 +25,10 @@ tester.run('no-raw-element', noRawElement, {
|
|
|
25
25
|
{ name: "the package's Button", code: component('<Button>Save</Button>') },
|
|
26
26
|
{ name: 'a member expression named button', code: component('<Foo.button>Save</Foo.button>') },
|
|
27
27
|
{ name: 'a name that starts with button', code: component('<buttonish />') },
|
|
28
|
+
// R5: a handler that only stops the click passes; a part may take one
|
|
29
|
+
{ name: 'a wrapper that only stops the click', code: component('<div onClick={(e) => e.stopPropagation()} onPointerDown={(event) => { event.stopPropagation(); event.preventDefault() }}><Button>Save</Button></div>') },
|
|
30
|
+
{ name: 'a press handler on a part', code: component('<Card onClick={open}>x</Card>') },
|
|
31
|
+
{ name: 'a clickable element, escaped with its reason', code: component(' // @estiva-escape(no-raw-element): a surface that holds headings and lists, which a button cannot hold\n <div onClick={edit}>x</div>') },
|
|
28
32
|
{
|
|
29
33
|
name: 'the elements that are not controls',
|
|
30
34
|
code: component(' <section>\n <h1>Title</h1>\n <p>Text <strong>and</strong> <em>more</em></p>\n <ul><li>One</li></ul>\n <img src="x.png" alt="" />\n <hr />\n <kbd>Enter</kbd>\n <svg><path d="M0 0" /></svg>\n <time dateTime="2026-09-16">today</time>\n </section>'),
|
|
@@ -37,31 +41,45 @@ tester.run('no-raw-element', noRawElement, {
|
|
|
37
41
|
{ name: 'controls written as false', code: component('<audio src="a.mp3" controls={false} />') },
|
|
38
42
|
{
|
|
39
43
|
name: 'a line comment escape directly above',
|
|
40
|
-
code: component(' // @estiva-escape: a preview drawn from its own palette\n <button type="button">x</button>'),
|
|
44
|
+
code: component(' // @estiva-escape(no-raw-element): a preview drawn from its own palette\n <button type="button">x</button>'),
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
name: 'a marker naming two rules keeps off each of them, written with or without the plugin name',
|
|
48
|
+
code: component(' // @estiva-escape(no-rebuilt-behaviour, estiva/no-raw-element): a preview drawn from its own palette\n <button type="button">x</button>'),
|
|
41
49
|
},
|
|
42
50
|
{
|
|
43
51
|
name: 'a JSX comment escape on the line above a child',
|
|
44
|
-
code: component(' <div>\n {/* @estiva-escape: a preview drawn from its own palette */}\n <button type="button">x</button>\n </div>'),
|
|
52
|
+
code: component(' <div>\n {/* @estiva-escape(no-raw-element): a preview drawn from its own palette */}\n <button type="button">x</button>\n </div>'),
|
|
45
53
|
},
|
|
46
54
|
{
|
|
47
55
|
name: 'a JSX comment escape over several lines',
|
|
48
|
-
code: component(' <div>\n {/*\n @estiva-escape: a preview drawn from its own palette\n */}\n <button type="button">x</button>\n </div>'),
|
|
56
|
+
code: component(' <div>\n {/*\n @estiva-escape(no-raw-element): a preview drawn from its own palette\n */}\n <button type="button">x</button>\n </div>'),
|
|
49
57
|
},
|
|
50
58
|
{
|
|
51
59
|
name: 'an escape above an opening tag that spans lines',
|
|
52
|
-
code: component(' // @estiva-escape: a preview drawn from its own palette\n <button\n type="button"\n onClick={() => {}}\n >\n x\n </button>'),
|
|
60
|
+
code: component(' // @estiva-escape(no-raw-element): a preview drawn from its own palette\n <button\n type="button"\n onClick={() => {}}\n >\n x\n </button>'),
|
|
53
61
|
},
|
|
54
62
|
{
|
|
55
63
|
name: 'exactly ten characters of reason, spaces not counted',
|
|
56
|
-
code: component(' // @estiva-escape: ab cd ef gh ij\n <button>x</button>'),
|
|
64
|
+
code: component(' // @estiva-escape(no-raw-element): ab cd ef gh ij\n <button>x</button>'),
|
|
57
65
|
},
|
|
58
66
|
{
|
|
59
67
|
name: 'an element with no part, escaped',
|
|
60
|
-
code: component(' // @estiva-escape: a map from another service, until the package has a frame\n <iframe src="https://example.com" title="Map" />'),
|
|
68
|
+
code: component(' // @estiva-escape(no-raw-element): a map from another service, until the package has a frame\n <iframe src="https://example.com" title="Map" />'),
|
|
61
69
|
},
|
|
62
70
|
],
|
|
63
71
|
invalid: [
|
|
64
72
|
{ name: 'a raw button', code: component(' <button type="button">x</button>'), errors: [{ messageId: 'raw', line: 3 }] },
|
|
73
|
+
{
|
|
74
|
+
name: 'a clickable div names Button and Card with href (R5)',
|
|
75
|
+
code: component(' <div onClick={open}>x</div>'),
|
|
76
|
+
errors: [{ message: 'A clickable <div onClick> is a button made by hand: it takes no focus and answers no key. Use `Button` from @estiva-app/ui; for a whole card that opens something, `Card` with `href`. A handler that only stops the click (`e.stopPropagation()`) is fine.' }],
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
name: 'a press that does more than stop the click, on any plain element',
|
|
80
|
+
code: component(' <li onMouseDown={(e) => { e.preventDefault(); onSearch() }}>\n <span onPointerDown={handle} />\n </li>'),
|
|
81
|
+
errors: [{ messageId: 'clickable', data: { element: '<li onMouseDown>' } }, { messageId: 'clickable', data: { element: '<span onPointerDown>' } }],
|
|
82
|
+
},
|
|
65
83
|
{
|
|
66
84
|
name: 'an opening tag that spans lines (what grep misses)',
|
|
67
85
|
code: component(' <input\n type="text"\n value={x}\n />'),
|
|
@@ -106,7 +124,7 @@ tester.run('no-raw-element', noRawElement, {
|
|
|
106
124
|
|
|
107
125
|
{
|
|
108
126
|
name: 'an escape with no reason is an error, and hides nothing',
|
|
109
|
-
code: component(' // @estiva-escape:\n <button>x</button>'),
|
|
127
|
+
code: component(' // @estiva-escape(no-raw-element):\n <button>x</button>'),
|
|
110
128
|
errors: [
|
|
111
129
|
{ messageId: 'escapeWithoutReason', line: 3 },
|
|
112
130
|
{ messageId: 'raw', line: 4 },
|
|
@@ -114,22 +132,36 @@ tester.run('no-raw-element', noRawElement, {
|
|
|
114
132
|
},
|
|
115
133
|
{
|
|
116
134
|
name: 'nine characters of reason is too short',
|
|
117
|
-
code: component(' <div>\n {/* @estiva-escape: ab cd ef gh i */}\n <button>x</button>\n </div>'),
|
|
135
|
+
code: component(' <div>\n {/* @estiva-escape(no-raw-element): ab cd ef gh i */}\n <button>x</button>\n </div>'),
|
|
118
136
|
errors: [{ messageId: 'escapeWithoutReason' }, { messageId: 'raw' }],
|
|
119
137
|
},
|
|
120
138
|
{
|
|
121
139
|
name: 'a marker with no colon and no reason',
|
|
122
|
-
code: component(' // @estiva-escape\n <button>x</button>'),
|
|
140
|
+
code: component(' // @estiva-escape(no-raw-element)\n <button>x</button>'),
|
|
123
141
|
errors: [{ messageId: 'escapeWithoutReason' }, { messageId: 'raw' }],
|
|
124
142
|
},
|
|
143
|
+
// B3 (Katerina, 25 September): a marker keeps off only the rules it names.
|
|
144
|
+
{
|
|
145
|
+
name: 'a marker that names no rule escapes nothing, and says which name to write',
|
|
146
|
+
code: component(' // @estiva-escape: a preview drawn from its own palette\n <button>x</button>'),
|
|
147
|
+
errors: [
|
|
148
|
+
{ line: 3, message: 'Name the rule this escape keeps off: `@estiva-escape(no-raw-element): <reason>`. An escape now keeps off only the rules it names.' },
|
|
149
|
+
{ messageId: 'raw', line: 4 },
|
|
150
|
+
],
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
name: 'a marker that names another rule does not keep this one off',
|
|
154
|
+
code: component(' // @estiva-escape(no-rebuilt-behaviour): a preview drawn from its own palette\n <button>x</button>'),
|
|
155
|
+
errors: [{ messageId: 'raw', line: 4 }],
|
|
156
|
+
},
|
|
125
157
|
{
|
|
126
158
|
name: 'an escape two lines above does not reach the element',
|
|
127
|
-
code: component(' // @estiva-escape: a preview drawn from its own palette\n\n <button>x</button>'),
|
|
159
|
+
code: component(' // @estiva-escape(no-raw-element): a preview drawn from its own palette\n\n <button>x</button>'),
|
|
128
160
|
errors: [{ messageId: 'raw', line: 5 }],
|
|
129
161
|
},
|
|
130
162
|
{
|
|
131
163
|
name: 'an escape above a sibling does not reach the next element',
|
|
132
|
-
code: component(' <div>\n {/* @estiva-escape: a preview drawn from its own palette */}\n <span />\n <button>x</button>\n </div>'),
|
|
164
|
+
code: component(' <div>\n {/* @estiva-escape(no-raw-element): a preview drawn from its own palette */}\n <span />\n <button>x</button>\n </div>'),
|
|
133
165
|
errors: [{ messageId: 'raw', line: 6 }],
|
|
134
166
|
},
|
|
135
167
|
{
|
|
@@ -139,23 +171,23 @@ tester.run('no-raw-element', noRawElement, {
|
|
|
139
171
|
},
|
|
140
172
|
{
|
|
141
173
|
name: 'a marker inside a directive that switches every rule off is refused',
|
|
142
|
-
code: component(' // eslint-disable-next-line -- @estiva-escape: a preview drawn from its own palette\n <button>x</button>'),
|
|
174
|
+
code: component(' // eslint-disable-next-line -- @estiva-escape(no-raw-element): a preview drawn from its own palette\n <button>x</button>'),
|
|
143
175
|
// The directive silences the element's own report; the refusal sits on the directive's line.
|
|
144
176
|
errors: [{ messageId: 'escapeInDirective', line: 3 }],
|
|
145
177
|
},
|
|
146
178
|
{
|
|
147
179
|
name: 'a marker inside a directive that names this rule is refused',
|
|
148
|
-
code: component(' // eslint-disable-next-line rule-to-test/no-raw-element -- @estiva-escape: a preview drawn from its own palette\n <button>x</button>'),
|
|
180
|
+
code: component(' // eslint-disable-next-line rule-to-test/no-raw-element -- @estiva-escape(no-raw-element): a preview drawn from its own palette\n <button>x</button>'),
|
|
149
181
|
errors: [{ messageId: 'escapeInDirective', line: 3 }],
|
|
150
182
|
},
|
|
151
183
|
{
|
|
152
184
|
name: "the token lint's note for another rule is not an escape of this one",
|
|
153
|
-
code: component(' // eslint-disable-next-line no-console -- @estiva-escape: its hand-written type waits for that\n <button>x</button>'),
|
|
185
|
+
code: component(' // eslint-disable-next-line no-console -- @estiva-escape(no-raw-element): its hand-written type waits for that\n <button>x</button>'),
|
|
154
186
|
errors: [{ messageId: 'raw', line: 4 }],
|
|
155
187
|
},
|
|
156
188
|
{
|
|
157
189
|
name: 'with reportEscapes on, a sanctioned escape is reported for the count',
|
|
158
|
-
code: component(' // @estiva-escape: a preview drawn from its own palette\n <button>x</button>'),
|
|
190
|
+
code: component(' // @estiva-escape(no-raw-element): a preview drawn from its own palette\n <button>x</button>'),
|
|
159
191
|
settings: { estiva: { reportEscapes: true } },
|
|
160
192
|
errors: [{ messageId: 'escaped', data: { reason: 'a preview drawn from its own palette' }, line: 3 }],
|
|
161
193
|
},
|
|
@@ -111,7 +111,43 @@ function isOn(element: JSXOpeningElement, name: string): boolean {
|
|
|
111
111
|
return attr !== undefined && literal(attr) !== false
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
-
type Found = { report: 'raw'; element: string; part: RawElementPart } | { report: 'noPart'; element: string } | null
|
|
114
|
+
type Found = { report: 'raw'; element: string; part: RawElementPart } | { report: 'noPart'; element: string } | { report: 'clickable'; element: string } | null
|
|
115
|
+
|
|
116
|
+
/** The pointer's press, as a handler on a plain element makes it a control (R5). */
|
|
117
|
+
const CLICK_HANDLERS = ['onClick', 'onMouseDown', 'onMouseUp', 'onPointerDown', 'onPointerUp', 'onDoubleClick']
|
|
118
|
+
|
|
119
|
+
interface Expression {
|
|
120
|
+
type: string
|
|
121
|
+
[key: string]: unknown
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** `e.stopPropagation()` or `e.preventDefault()`: a handler that only keeps the click from what is behind. */
|
|
125
|
+
function onlyStops(handler: Expression | undefined): boolean {
|
|
126
|
+
if (!handler || (handler.type !== 'ArrowFunctionExpression' && handler.type !== 'FunctionExpression')) return false
|
|
127
|
+
const body = handler.body as Expression
|
|
128
|
+
const calls = body.type === 'BlockStatement' ? (body.body as Expression[]).map((s) => (s.type === 'ExpressionStatement' ? (s.expression as Expression) : s)) : [body]
|
|
129
|
+
return (
|
|
130
|
+
calls.length > 0 &&
|
|
131
|
+
calls.every((call) => {
|
|
132
|
+
if (call.type !== 'CallExpression') return false
|
|
133
|
+
const callee = call.callee as Expression
|
|
134
|
+
const property = callee.type === 'MemberExpression' ? (callee.property as Expression) : undefined
|
|
135
|
+
return property?.type === 'Identifier' && (property.name === 'stopPropagation' || property.name === 'preventDefault')
|
|
136
|
+
})
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** A plain element made clickable by hand: the first press handler that does more than stop the click. */
|
|
141
|
+
function clickHandler(element: JSXOpeningElement): string | undefined {
|
|
142
|
+
for (const name of CLICK_HANDLERS) {
|
|
143
|
+
const attr = attribute(element, name)
|
|
144
|
+
if (!attr) continue
|
|
145
|
+
const value = attr.value
|
|
146
|
+
const handler = value?.type === 'JSXExpressionContainer' ? (value.expression as Expression | undefined) : undefined
|
|
147
|
+
if (!onlyStops(handler)) return name
|
|
148
|
+
}
|
|
149
|
+
return undefined
|
|
150
|
+
}
|
|
115
151
|
|
|
116
152
|
/** What this element is to the rule: a raw element with a part, one with none yet, or none of its business. */
|
|
117
153
|
export function classify(element: JSXOpeningElement): Found {
|
|
@@ -131,7 +167,14 @@ export function classify(element: JSXOpeningElement): Found {
|
|
|
131
167
|
return part ? { report: 'raw', element: shown, part } : { report: 'noPart', element: shown }
|
|
132
168
|
}
|
|
133
169
|
|
|
134
|
-
if (!Object.hasOwn(RAW_ELEMENT_PARTS, name))
|
|
170
|
+
if (!Object.hasOwn(RAW_ELEMENT_PARTS, name)) {
|
|
171
|
+
// A plain element with a press handler is a button drawn by hand (Katerina's ruling R5,
|
|
172
|
+
// 25 September): no focus, no keys, no look anybody keeps in step. `<option>` and
|
|
173
|
+
// `<summary>` live inside a control the rule already reads.
|
|
174
|
+
if (!/^[a-z][a-z0-9]*$/.test(name) || name === 'option' || name === 'summary') return null
|
|
175
|
+
const handler = clickHandler(element)
|
|
176
|
+
return handler ? { report: 'clickable', element: `<${name} ${handler}>` } : null
|
|
177
|
+
}
|
|
135
178
|
if ((name === 'audio' || name === 'video') && !isOn(element, 'controls')) return null
|
|
136
179
|
if (name === 'img' && !attribute(element, 'usemap') && !attribute(element, 'useMap')) return null
|
|
137
180
|
|
|
@@ -150,6 +193,10 @@ export function classify(element: JSXOpeningElement): Found {
|
|
|
150
193
|
* that says so: the part is made in the package, not by hand in an app
|
|
151
194
|
* (Katerina, 16 September).
|
|
152
195
|
*
|
|
196
|
+
* A plain element with a press handler (`<div onClick>`) is a control too, and
|
|
197
|
+
* is refused naming `Button` (R5, 25 September); a handler that only stops the
|
|
198
|
+
* click reaching what is behind passes.
|
|
199
|
+
*
|
|
153
200
|
* Only a JSX element written with a lowercase name. `<Button>`,
|
|
154
201
|
* `<Foo.button>` and `createElement('button')` are not this rule's business.
|
|
155
202
|
*/
|
|
@@ -160,6 +207,8 @@ export const noRawElement: Rule.RuleModule = {
|
|
|
160
207
|
schema: [],
|
|
161
208
|
messages: {
|
|
162
209
|
raw: 'Use `{{use}}` from @estiva-app/ui instead of a raw {{element}}.{{more}}',
|
|
210
|
+
clickable:
|
|
211
|
+
'A clickable {{element}} is a button made by hand: it takes no focus and answers no key. Use `Button` from @estiva-app/ui; for a whole card that opens something, `Card` with `href`. A handler that only stops the click (`e.stopPropagation()`) is fine.',
|
|
163
212
|
noPart: '@estiva-app/ui has no part for a raw {{element}} yet. Do not build one here: ask Katerina, and it gets made in @estiva-app/ui.',
|
|
164
213
|
...ESCAPE_MESSAGES,
|
|
165
214
|
},
|
|
@@ -172,7 +221,8 @@ export const noRawElement: Rule.RuleModule = {
|
|
|
172
221
|
const found = classify(element)
|
|
173
222
|
if (!found) return
|
|
174
223
|
if (isEscaped(context, element)) return
|
|
175
|
-
if (found.report === '
|
|
224
|
+
if (found.report === 'clickable') context.report({ loc: element.loc, messageId: 'clickable', data: { element: found.element } })
|
|
225
|
+
else if (found.report === 'noPart') context.report({ loc: element.loc, messageId: 'noPart', data: { element: found.element } })
|
|
176
226
|
else context.report({ loc: element.loc, messageId: 'raw', data: { element: found.element, use: found.part.use, more: found.part.more ?? '' } })
|
|
177
227
|
},
|
|
178
228
|
}
|
|
@@ -65,27 +65,27 @@ tester.run('no-rebuilt-behaviour', noRebuiltBehaviour, {
|
|
|
65
65
|
// escapes
|
|
66
66
|
{
|
|
67
67
|
name: 'an escape above the statement keeps a page listener',
|
|
68
|
-
code: effect(" // @estiva-escape: an inline panel, not a floating one, closes on a press outside\n document.addEventListener('mousedown', close)"),
|
|
68
|
+
code: effect(" // @estiva-escape(no-rebuilt-behaviour): an inline panel, not a floating one, closes on a press outside\n document.addEventListener('mousedown', close)"),
|
|
69
69
|
},
|
|
70
70
|
{
|
|
71
71
|
name: 'an escape above the element keeps a hand-written role',
|
|
72
|
-
code: component(' // @estiva-escape: a list the editor drives from its caret\n <MenuItem role="option" aria-selected label="Item" />'),
|
|
72
|
+
code: component(' // @estiva-escape(no-rebuilt-behaviour): a list the editor drives from its caret\n <MenuItem role="option" aria-selected label="Item" />'),
|
|
73
73
|
},
|
|
74
74
|
{
|
|
75
75
|
name: 'an escape above the object property keeps its arrow keys',
|
|
76
|
-
code: "useImperativeHandle(ref, () => ({\n // @estiva-escape: a list the editor drives from its caret\n onKeyDown: ({ event }) => {\n if (event.key === 'ArrowDown') next()\n if (event.key === 'ArrowUp') previous()\n return false\n },\n}))",
|
|
76
|
+
code: "useImperativeHandle(ref, () => ({\n // @estiva-escape(no-rebuilt-behaviour): a list the editor drives from its caret\n onKeyDown: ({ event }) => {\n if (event.key === 'ArrowDown') next()\n if (event.key === 'ArrowUp') previous()\n return false\n },\n}))",
|
|
77
77
|
},
|
|
78
78
|
{
|
|
79
79
|
name: 'an escape above the call keeps a portal',
|
|
80
|
-
code: "import { createPortal } from 'react-dom'\nfunction Viewer() {\n // @estiva-escape: becomes the package Lightbox at stage 7\n return createPortal(<div />, document.body)\n}",
|
|
80
|
+
code: "import { createPortal } from 'react-dom'\nfunction Viewer() {\n // @estiva-escape(no-rebuilt-behaviour): becomes the package Lightbox at stage 7\n return createPortal(<div />, document.body)\n}",
|
|
81
81
|
},
|
|
82
82
|
{
|
|
83
83
|
name: 'an escape above an array item keeps its class',
|
|
84
|
-
code: "const CLASSES = [\n 'flex',\n // @estiva-escape: a code block the editor draws, which nothing can wrap\n '[&_pre]:overflow-x-auto',\n]",
|
|
84
|
+
code: "const CLASSES = [\n 'flex',\n // @estiva-escape(no-rebuilt-behaviour): a code block the editor draws, which nothing can wrap\n '[&_pre]:overflow-x-auto',\n]",
|
|
85
85
|
},
|
|
86
86
|
{
|
|
87
87
|
name: 'one escape above an element covers everything found on it',
|
|
88
|
-
code: component(' // @estiva-escape: a surface that holds headings, which a button cannot\n <div role="button" tabIndex={0} className="overflow-auto" />'),
|
|
88
|
+
code: component(' // @estiva-escape(no-rebuilt-behaviour): a surface that holds headings, which a button cannot\n <div role="button" tabIndex={0} className="overflow-auto" />'),
|
|
89
89
|
},
|
|
90
90
|
],
|
|
91
91
|
invalid: [
|
|
@@ -175,17 +175,17 @@ tester.run('no-rebuilt-behaviour', noRebuiltBehaviour, {
|
|
|
175
175
|
// escapes
|
|
176
176
|
{
|
|
177
177
|
name: 'an escape with no reason hides nothing',
|
|
178
|
-
code: effect(" // @estiva-escape:\n document.addEventListener('mousedown', close)"),
|
|
178
|
+
code: effect(" // @estiva-escape(no-rebuilt-behaviour):\n document.addEventListener('mousedown', close)"),
|
|
179
179
|
errors: [{ messageId: 'escapeWithoutReason', line: 2 }, { messageId: 'press', line: 3 }],
|
|
180
180
|
},
|
|
181
181
|
{
|
|
182
182
|
name: 'an escape above one statement does not reach the next',
|
|
183
|
-
code: effect(" // @estiva-escape: an inline panel, not a floating one, closes on a press outside\n document.addEventListener('mousedown', close)\n document.addEventListener('keydown', onKey)"),
|
|
183
|
+
code: effect(" // @estiva-escape(no-rebuilt-behaviour): an inline panel, not a floating one, closes on a press outside\n document.addEventListener('mousedown', close)\n document.addEventListener('keydown', onKey)"),
|
|
184
184
|
errors: [{ messageId: 'key', line: 4 }],
|
|
185
185
|
},
|
|
186
186
|
{
|
|
187
187
|
name: 'with reportEscapes on, one escape over two findings is counted once',
|
|
188
|
-
code: component(' // @estiva-escape: a surface that holds headings, which a button cannot\n <div role="button" tabIndex={0} />'),
|
|
188
|
+
code: component(' // @estiva-escape(no-rebuilt-behaviour): a surface that holds headings, which a button cannot\n <div role="button" tabIndex={0} />'),
|
|
189
189
|
settings: { estiva: { reportEscapes: true } },
|
|
190
190
|
errors: [{ messageId: 'escaped', line: 3 }],
|
|
191
191
|
},
|
|
@@ -51,6 +51,8 @@ const local = (imports: string, from: string, body: string) => `import { ${impor
|
|
|
51
51
|
const USE_VARIANT_OR_SIZE = ' Use its `variant` or `size`.'
|
|
52
52
|
const restyled = (classes: string, where: string, use: string) =>
|
|
53
53
|
`${classes} on ${where} changes how it looks. A part of @estiva-app/ui is placed from outside, never restyled: only space, size, flex and grid, and position pass in.${use} A look for what is around it goes on your own element around it.`
|
|
54
|
+
const restyledStyle = (keys: string, where: string, use: string) =>
|
|
55
|
+
`${keys} in the \`style\` of ${where} changes how it looks. A part of @estiva-app/ui is placed from outside, never restyled, by class or by style: only space, size, flex and grid, and position pass in.${use} A look for what is around it goes on your own element around it.`
|
|
54
56
|
const noProp = (part: string) => ` \`${part}\` has no prop for this yet: ask Katerina, and it gets made in @estiva-app/ui.`
|
|
55
57
|
|
|
56
58
|
tester.run('no-restyled-part', noRestyledPart, {
|
|
@@ -66,14 +68,31 @@ tester.run('no-restyled-part', noRestyledPart, {
|
|
|
66
68
|
{ name: 'a raw element is not a part', filename: app, code: ui('Button', ' <div className="bg-bg-surface text-h2 rounded-lg" />') },
|
|
67
69
|
{ name: 'an app component of the same name is not a part', filename: app, code: local('Button', './components/Button', ' <Button className="text-h2" />') },
|
|
68
70
|
{ name: 'a style prop is not a class prop', filename: app, code: ui('Button', ' <Button style={{ width: 20 }} data-class="text-h2">x</Button>') },
|
|
71
|
+
// style on a part: placement keys pass (B6)
|
|
72
|
+
{ name: 'style: space, size, position and a move', filename: app, code: ui('Card', " <Card style={{ marginTop: 4, 'paddingInline': 8, width: '50%', maxHeight: 240, flexGrow: 1, gridColumn: '1 / 3', position: 'absolute', top: 0, zIndex: 2, transform: `translateY(${y}px)` }} />") },
|
|
73
|
+
{ name: 'style: a const of placement, and a custom property', filename: app, code: "import { Card } from '@estiva-app/ui'\nconst at = { top: 0, left: 0 }\nexport function Probe() {\n return <Card style={at}><Card style={{ '--row': 2 }} /></Card>\n}\n" },
|
|
74
|
+
{ name: 'style the code works out while it runs passes', filename: app, code: ui('Card', ' <Card style={props.style} />') },
|
|
75
|
+
{ name: 'style on a component that hands on only its className is not read', filename: app, code: local('ConversationCard', './components/ConversationCard', ' <ConversationCard title="x" style={{ opacity: 0.5 }} />') },
|
|
69
76
|
{ name: 'EmptyState placed', filename: app, code: ui('EmptyState', ' <EmptyState className="mt-2 flex-1" message="Nothing yet" />') },
|
|
70
77
|
// escapes
|
|
71
|
-
{ name: 'escaped, with its reason', filename: app, code: ui('Link', ' // @estiva-escape: the whole row is the link, and no part does that yet\n <Link href="/x" className="after:absolute after:inset-0">x</Link>') },
|
|
78
|
+
{ 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>') },
|
|
72
79
|
// the package itself
|
|
73
80
|
{ name: 'inside the package: a name index.ts does not export', filename: pkg, code: local('Base', './Base', ' <Base className="text-h2" />') },
|
|
74
81
|
{ name: 'inside the package: a raw element in a part', filename: pkg, code: 'export function Card() {\n return <div className="rounded-lg border" />\n}\n' },
|
|
75
82
|
],
|
|
76
83
|
invalid: [
|
|
84
|
+
{
|
|
85
|
+
name: 'style: a corner, an opacity, a weight and a line height (B6)',
|
|
86
|
+
filename: app,
|
|
87
|
+
code: ui('Button', ' <Button style={{ borderRadius: 999, opacity: 0.4, fontWeight: 700, lineHeight: 2, marginTop: 4 }}>x</Button>'),
|
|
88
|
+
errors: [{ message: restyledStyle('`borderRadius` `opacity` `fontWeight` `lineHeight`', '`Button`', USE_VARIANT_OR_SIZE) }],
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
name: 'style: quoted keys, a const, and a re-export',
|
|
92
|
+
filename: app,
|
|
93
|
+
code: "import { Button } from './components/ui'\nconst look = { 'background': 'red' } as const\nexport function Probe() {\n return <Button style={look}>x</Button>\n}\n",
|
|
94
|
+
errors: [{ message: restyledStyle('`background`', '`Button`', USE_VARIANT_OR_SIZE) }],
|
|
95
|
+
},
|
|
77
96
|
{
|
|
78
97
|
name: 'a colour and a text size, naming the props that carry a look',
|
|
79
98
|
filename: app,
|
|
@@ -176,7 +195,7 @@ tester.run('no-restyled-part', noRestyledPart, {
|
|
|
176
195
|
{
|
|
177
196
|
name: 'an escape with no reason is reported, and so is the class',
|
|
178
197
|
filename: app,
|
|
179
|
-
code: ui('Link', ' // @estiva-escape:\n <Link href="/x" className="text-h2">x</Link>'),
|
|
198
|
+
code: ui('Link', ' // @estiva-escape(no-restyled-part):\n <Link href="/x" className="text-h2">x</Link>'),
|
|
180
199
|
errors: [{ messageId: 'escapeWithoutReason' }, { message: restyled('`text-h2`', '`Link`', ' Use its `variant` or `truncate`.') }],
|
|
181
200
|
},
|
|
182
201
|
{
|
|
@@ -53,6 +53,16 @@ export const PLACEMENT: { what: string; pattern: RegExp }[] = [
|
|
|
53
53
|
{ what: 'a name for hover', pattern: /^(?:group|peer)(?:\/[\w-]+)?$/ },
|
|
54
54
|
]
|
|
55
55
|
|
|
56
|
+
/**
|
|
57
|
+
* The same, as `style` keys (Katerina's ruling B6, 25 September): `style` on a
|
|
58
|
+
* part is read like its `className`, so a radius, a weight or an opacity cannot
|
|
59
|
+
* go in by the other door. CSS's own names for space, size, flex and grid, and
|
|
60
|
+
* position; `transform` and `translate` move a part without changing its look.
|
|
61
|
+
* A custom property (`--name`) passes: the part decides what it means.
|
|
62
|
+
*/
|
|
63
|
+
export const PLACEMENT_STYLE =
|
|
64
|
+
/^(?:margin|padding|inset|scrollMargin)(?:Top|Right|Bottom|Left|Block|Inline|BlockStart|BlockEnd|InlineStart|InlineEnd)?$|^(?:width|height|minWidth|minHeight|maxWidth|maxHeight|inlineSize|blockSize|minInlineSize|minBlockSize|maxInlineSize|maxBlockSize)$|^(?:display|flex|flexGrow|flexShrink|flexBasis|order|gap|rowGap|columnGap|alignSelf|justifySelf|placeSelf|gridArea|gridColumn|gridColumnStart|gridColumnEnd|gridRow|gridRowStart|gridRowEnd)$|^(?:position|top|right|bottom|left|zIndex|transform|translate)$/
|
|
65
|
+
|
|
56
66
|
/**
|
|
57
67
|
* A variant that draws another box or reaches inside the part: `before:`,
|
|
58
68
|
* `after:`, `[&>*]:`, `*:`… A placement class behind one of these is not the
|
|
@@ -592,6 +602,8 @@ export const noRestyledPart: Rule.RuleModule = {
|
|
|
592
602
|
messages: {
|
|
593
603
|
restyled:
|
|
594
604
|
'{{classes}} on {{where}} changes how it looks. A part of @estiva-app/ui is placed from outside, never restyled: only space, size, flex and grid, and position pass in.{{use}} A look for what is around it goes on your own element around it.',
|
|
605
|
+
restyledStyle:
|
|
606
|
+
'{{keys}} in the `style` of {{where}} changes how it looks. A part of @estiva-app/ui is placed from outside, never restyled, by class or by style: only space, size, flex and grid, and position pass in.{{use}} A look for what is around it goes on your own element around it.',
|
|
595
607
|
emptyStatePadding:
|
|
596
608
|
'{{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.',
|
|
597
609
|
...ESCAPE_MESSAGES,
|
|
@@ -628,6 +640,23 @@ export const noRestyledPart: Rule.RuleModule = {
|
|
|
628
640
|
if (padding.length) context.report({ loc: at.loc as NonNullable<Rule.Node['loc']>, messageId: 'emptyStatePadding', data: { classes: code(padding), where } })
|
|
629
641
|
}
|
|
630
642
|
|
|
643
|
+
/** `style` on a part: every key it can read must be placement (B6). */
|
|
644
|
+
const checkStyle = (element: Node, binding: PartBinding, value: Node | null | undefined, at: Node) => {
|
|
645
|
+
if (binding.props !== 'all' || value?.type !== 'JSXExpressionContainer') return
|
|
646
|
+
let object = child(value, 'expression')
|
|
647
|
+
if (object?.type === 'Identifier') object = constantOf(object, scopeOf(object))
|
|
648
|
+
while (object?.type === 'TSAsExpression' || object?.type === 'TSSatisfiesExpression') object = child(object, 'expression')
|
|
649
|
+
if (object?.type !== 'ObjectExpression') return
|
|
650
|
+
const keys = children(object, 'properties')
|
|
651
|
+
.map((property) => (property?.type === 'Property' && !property.computed ? nameOf(child(property, 'key')) : undefined))
|
|
652
|
+
.filter((key): key is string => !!key && !key.startsWith('--') && !PLACEMENT_STYLE.test(key))
|
|
653
|
+
if (!keys.length || escaped(element)) return
|
|
654
|
+
const props = PART_LOOK_PROPS[binding.part]
|
|
655
|
+
const use = props ? ` Use its ${or(props)}.` : ` \`${binding.part}\` has no prop for this yet: ask Katerina, and it gets made in @estiva-app/ui.`
|
|
656
|
+
const where = binding.via && binding.via !== binding.part ? `\`${binding.via}\` (it hands its props to \`${binding.part}\`)` : `\`${binding.part}\``
|
|
657
|
+
context.report({ loc: at.loc as NonNullable<Rule.Node['loc']>, messageId: 'restyledStyle', data: { keys: code([...new Set(keys)]), where, use } })
|
|
658
|
+
}
|
|
659
|
+
|
|
631
660
|
return {
|
|
632
661
|
Program(program) {
|
|
633
662
|
locals = moduleParts(file, program as unknown as Node, parser, new Set([file])).locals
|
|
@@ -641,6 +670,7 @@ export const noRestyledPart: Rule.RuleModule = {
|
|
|
641
670
|
if (attribute.type === 'JSXAttribute') {
|
|
642
671
|
const prop = nameOf(child(attribute, 'name'))
|
|
643
672
|
if (prop && CLASS_PROP.test(prop)) check(element, binding, prop, child(attribute, 'value'), attribute)
|
|
673
|
+
else if (prop === 'style') checkStyle(element, binding, child(attribute, 'value'), attribute)
|
|
644
674
|
continue
|
|
645
675
|
}
|
|
646
676
|
// `{...{ className }}`, or a `const` object spread onto the part.
|