@estiva-app/ui 0.15.0 → 0.16.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/Breadcrumb.d.ts.map +1 -1
- package/dist/CommandPalette.d.ts +114 -0
- package/dist/CommandPalette.d.ts.map +1 -0
- package/dist/Menu.d.ts +4 -0
- package/dist/Menu.d.ts.map +1 -1
- package/dist/Toast.d.ts.map +1 -1
- package/dist/eslint/has-a-page-and-a-story.d.ts +4 -0
- package/dist/eslint/has-a-page-and-a-story.d.ts.map +1 -0
- package/dist/eslint/index.d.ts +15 -1
- package/dist/eslint/index.d.ts.map +1 -1
- package/dist/eslint/index.js +176 -8
- package/dist/eslint/index.js.map +3 -3
- package/dist/eslint/no-hand-rolled-behaviour.d.ts +3 -0
- package/dist/eslint/no-hand-rolled-behaviour.d.ts.map +1 -0
- package/dist/eslint/raw-element-outside-a-wrapper.d.ts +29 -0
- package/dist/eslint/raw-element-outside-a-wrapper.d.ts.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +609 -287
- package/dist/index.js.map +4 -4
- package/package.json +5 -1
- package/src/Breadcrumb.tsx +6 -2
- package/src/CommandPalette.mdx +133 -0
- package/src/CommandPalette.stories.tsx +416 -0
- package/src/CommandPalette.test.tsx +392 -0
- package/src/CommandPalette.tsx +643 -0
- package/src/Menu.tsx +4 -2
- package/src/Toast.tsx +12 -24
- package/src/eslint/has-a-page-and-a-story.test.ts +57 -0
- package/src/eslint/has-a-page-and-a-story.ts +97 -0
- package/src/eslint/index.test.ts +35 -3
- package/src/eslint/index.ts +58 -13
- package/src/eslint/no-hand-rolled-behaviour.test.ts +70 -0
- package/src/eslint/no-hand-rolled-behaviour.ts +116 -0
- package/src/eslint/raw-element-outside-a-wrapper.test.ts +82 -0
- package/src/eslint/raw-element-outside-a-wrapper.ts +85 -0
- package/src/index.ts +17 -0
- package/stories/Choosing.mdx +1 -0
package/src/Toast.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createContext, useContext, useMemo, type ReactNode } from 'react'
|
|
2
2
|
import { Toast as BaseToast } from '@base-ui/react/toast'
|
|
3
3
|
import { IconAlertCircle, IconCircleCheck, IconCircleX } from '@tabler/icons-react'
|
|
4
|
+
import { Button } from './Button'
|
|
4
5
|
import { cn } from './cn'
|
|
5
6
|
|
|
6
7
|
/**
|
|
@@ -78,14 +79,6 @@ const ICON_STYLES: Record<ToastType, string> = {
|
|
|
78
79
|
error: 'signal:text-error-default',
|
|
79
80
|
}
|
|
80
81
|
|
|
81
|
-
const ACTION_BORDER_STYLES: Record<ToastType, string> = {
|
|
82
|
-
success: 'signal:border signal:border-border-default signal:hover:border-border-strong',
|
|
83
|
-
brand: 'signal:border signal:border-border-default signal:hover:border-border-strong',
|
|
84
|
-
neutral: 'border border-border-default',
|
|
85
|
-
warning: 'signal:border signal:border-border-default signal:hover:border-border-strong',
|
|
86
|
-
error: 'signal:border signal:border-border-default signal:hover:border-border-strong',
|
|
87
|
-
}
|
|
88
|
-
|
|
89
82
|
const LABEL_CLASSES = 'text-body-2 text-text-primary whitespace-nowrap'
|
|
90
83
|
|
|
91
84
|
const pillClassName = (type: ToastType, hasAction: boolean, className?: string) =>
|
|
@@ -98,20 +91,11 @@ const pillClassName = (type: ToastType, hasAction: boolean, className?: string)
|
|
|
98
91
|
className,
|
|
99
92
|
)
|
|
100
93
|
|
|
101
|
-
const actionClassName = (type: ToastType) =>
|
|
102
|
-
cn(
|
|
103
|
-
'h-6 flex items-center justify-center gap-1 px-1 py-1 rounded-md shrink-0 transition-colors',
|
|
104
|
-
ACTION_BORDER_STYLES[type],
|
|
105
|
-
type === 'neutral' ? 'hover:border-border-strong' : 'hover:opacity-80',
|
|
106
|
-
)
|
|
107
|
-
|
|
108
94
|
function LeadingIcon({ type }: { type: ToastType }) {
|
|
109
95
|
const Icon = ICONS[type]
|
|
110
96
|
return <Icon size={16} stroke={1.5} className={cn('text-text-primary shrink-0', ICON_STYLES[type])} />
|
|
111
97
|
}
|
|
112
98
|
|
|
113
|
-
const ACTION_LABEL_CLASSES = 'text-btn-small text-text-primary whitespace-nowrap'
|
|
114
|
-
|
|
115
99
|
/**
|
|
116
100
|
* One toast, drawn in place. What the provider shows is this pill; draw it
|
|
117
101
|
* yourself only where a page needs a toast's look without its timing — a
|
|
@@ -126,9 +110,9 @@ export function Toast({ label, type = 'neutral', leadingIcon = true, actionLabel
|
|
|
126
110
|
<span className={LABEL_CLASSES}>{label}</span>
|
|
127
111
|
</div>
|
|
128
112
|
{hasAction && (
|
|
129
|
-
<
|
|
130
|
-
|
|
131
|
-
</
|
|
113
|
+
<Button variant="outlined" size="small" onClick={onAction}>
|
|
114
|
+
{actionLabel}
|
|
115
|
+
</Button>
|
|
132
116
|
)}
|
|
133
117
|
</div>
|
|
134
118
|
)
|
|
@@ -229,10 +213,14 @@ function ToastList() {
|
|
|
229
213
|
onAction?.()
|
|
230
214
|
close(toast.id)
|
|
231
215
|
}}
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
216
|
+
// The same Button the toast drawn in place uses, told to *be* the
|
|
217
|
+
// Base UI action rather than sit inside one (Katerina, 16 September).
|
|
218
|
+
render={
|
|
219
|
+
<Button variant="outlined" size="small">
|
|
220
|
+
{actionLabel}
|
|
221
|
+
</Button>
|
|
222
|
+
}
|
|
223
|
+
/>
|
|
236
224
|
)}
|
|
237
225
|
</BaseToast.Root>
|
|
238
226
|
)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { fileURLToPath } from 'node:url'
|
|
3
|
+
import { RuleTester } from 'eslint'
|
|
4
|
+
import { parser } from 'typescript-eslint'
|
|
5
|
+
import { describe, expect, it } from 'vitest'
|
|
6
|
+
import { componentHasAPage, componentHasAStory } from './has-a-page-and-a-story'
|
|
7
|
+
|
|
8
|
+
RuleTester.describe = describe
|
|
9
|
+
RuleTester.it = it
|
|
10
|
+
RuleTester.itOnly = it.only
|
|
11
|
+
|
|
12
|
+
const tester = new RuleTester({
|
|
13
|
+
languageOptions: { parser, parserOptions: { ecmaFeatures: { jsx: true } } },
|
|
14
|
+
linterOptions: { reportUnusedDisableDirectives: 'off' },
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
/** Real files of this package, so the rules are tested against what they read. */
|
|
18
|
+
const src = (name: string) => fileURLToPath(new URL(`../${name}`, import.meta.url))
|
|
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'
|
|
21
|
+
|
|
22
|
+
describe('the files these rules read', () => {
|
|
23
|
+
it('Button has a page and a story beside it, and FieldLine and MenuItem have both without a component file', () => {
|
|
24
|
+
expect(existsSync(src('Button.mdx'))).toBe(true)
|
|
25
|
+
expect(existsSync(src('Button.stories.tsx'))).toBe(true)
|
|
26
|
+
for (const name of ['FieldLine', 'MenuItem']) {
|
|
27
|
+
expect(existsSync(src(`${name}.mdx`))).toBe(true)
|
|
28
|
+
expect(existsSync(src(`${name}.stories.tsx`))).toBe(true)
|
|
29
|
+
// The false positive to avoid: no component file of their own.
|
|
30
|
+
expect(existsSync(src(`${name}.tsx`))).toBe(false)
|
|
31
|
+
}
|
|
32
|
+
})
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
tester.run('component-has-a-page', componentHasAPage, {
|
|
36
|
+
valid: [
|
|
37
|
+
{ name: 'a component with its page beside it', code, filename: src('Button.tsx') },
|
|
38
|
+
{ name: 'a story file, which needs no page of its own', code, filename: src('Button.stories.tsx') },
|
|
39
|
+
{ name: 'a test file', code, filename: src('Button.test.tsx') },
|
|
40
|
+
{ name: 'a file that is not a component file at all', code, filename: src('index.ts') },
|
|
41
|
+
{ name: 'a component with its reason at the top', code: escaped, filename: src('NoPageProbe.tsx') },
|
|
42
|
+
],
|
|
43
|
+
invalid: [
|
|
44
|
+
{ name: 'a component with no page', code, filename: src('NoPageProbe.tsx'), errors: [{ messageId: 'missing' }] },
|
|
45
|
+
],
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
tester.run('component-has-a-story', componentHasAStory, {
|
|
49
|
+
valid: [
|
|
50
|
+
{ name: 'a component with its story beside it', code, filename: src('Button.tsx') },
|
|
51
|
+
{ name: 'a story file, which is not itself a component', code, filename: src('Button.stories.tsx') },
|
|
52
|
+
{ name: 'a component with its reason at the top', code: escaped, filename: src('NoStoryProbe.tsx') },
|
|
53
|
+
],
|
|
54
|
+
invalid: [
|
|
55
|
+
{ name: 'a component with no story', code, filename: src('NoStoryProbe.tsx'), errors: [{ messageId: 'missing' }] },
|
|
56
|
+
],
|
|
57
|
+
})
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import type { Rule } from 'eslint'
|
|
3
|
+
import { ESCAPE_MESSAGES, isEscaped } from './escape'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A component of the package with no page, and one with no story (UIG-5, P2 and
|
|
7
|
+
* P3).
|
|
8
|
+
*
|
|
9
|
+
* A component nobody can read about is a component nobody uses correctly, and
|
|
10
|
+
* one nobody can look at is one nobody reviews. Both were at zero when these
|
|
11
|
+
* were switched on; they are here so the next component cannot arrive without
|
|
12
|
+
* them.
|
|
13
|
+
*
|
|
14
|
+
* The pair is by file name, beside the component, which is how this package is
|
|
15
|
+
* laid out: `Button.tsx`, `Button.mdx`, `Button.stories.tsx`, `Button.test.tsx`
|
|
16
|
+
* side by side in `src/`.
|
|
17
|
+
*
|
|
18
|
+
* **The false positive to avoid** (UIG-1 confirmed it is real): `FieldLine` and
|
|
19
|
+
* `MenuItem` have a page and a story but no `.tsx` of their own — they are
|
|
20
|
+
* exported from a sibling. These rules read a component file and ask for its
|
|
21
|
+
* page and its story, never the other way round, so a page without a component
|
|
22
|
+
* is not this rule's business.
|
|
23
|
+
*
|
|
24
|
+
* A file that stays without one says why at its top: `// @estiva-escape: <why>`.
|
|
25
|
+
*/
|
|
26
|
+
interface ProgramNode {
|
|
27
|
+
body: { loc?: Rule.Node['loc']; range?: [number, number] }[]
|
|
28
|
+
loc: NonNullable<Rule.Node['loc']>
|
|
29
|
+
range: [number, number]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A component file: a `.tsx` in the package's source that is not a story or a test. */
|
|
33
|
+
function componentFile(filename: string): boolean {
|
|
34
|
+
return filename.endsWith('.tsx') && !/\.(stories|test)\.tsx$/.test(filename)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function sibling(filename: string, extension: string): string {
|
|
38
|
+
return filename.replace(/\.tsx$/, extension)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Report on the file's first statement, so the escape is a comment at the top of
|
|
43
|
+
* the file — the only place a reason for a missing page could go.
|
|
44
|
+
*/
|
|
45
|
+
function reportOnFile(context: Rule.RuleContext, program: ProgramNode, messageId: string, data: Record<string, string>): void {
|
|
46
|
+
const anchor = program.body[0] ?? program
|
|
47
|
+
if (anchor.loc && anchor.range && isEscaped(context, { loc: anchor.loc, range: anchor.range })) return
|
|
48
|
+
context.report({ loc: anchor.loc ?? program.loc, messageId, data })
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const componentHasAPage: Rule.RuleModule = {
|
|
52
|
+
meta: {
|
|
53
|
+
type: 'problem',
|
|
54
|
+
docs: { description: 'Every component of the package has a usage page beside it' },
|
|
55
|
+
schema: [],
|
|
56
|
+
messages: {
|
|
57
|
+
missing: '{{name}} has no page. Write {{page}} beside it — what it is, when, when not, how, and what it owns — or say why not at the top of the file.',
|
|
58
|
+
...ESCAPE_MESSAGES,
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
create(context) {
|
|
62
|
+
return {
|
|
63
|
+
Program(node) {
|
|
64
|
+
const filename = context.filename
|
|
65
|
+
if (!componentFile(filename)) return
|
|
66
|
+
const page = sibling(filename, '.mdx')
|
|
67
|
+
if (existsSync(page)) return
|
|
68
|
+
const name = filename.split(/[\\/]/).pop() ?? filename
|
|
69
|
+
reportOnFile(context, node as unknown as ProgramNode, 'missing', { name, page: page.split(/[\\/]/).pop() ?? page })
|
|
70
|
+
},
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export const componentHasAStory: Rule.RuleModule = {
|
|
76
|
+
meta: {
|
|
77
|
+
type: 'problem',
|
|
78
|
+
docs: { description: 'Every component of the package has a story beside it' },
|
|
79
|
+
schema: [],
|
|
80
|
+
messages: {
|
|
81
|
+
missing: '{{name}} has no story. Write {{story}} beside it, so it can be seen and reviewed, or say why not at the top of the file.',
|
|
82
|
+
...ESCAPE_MESSAGES,
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
create(context) {
|
|
86
|
+
return {
|
|
87
|
+
Program(node) {
|
|
88
|
+
const filename = context.filename
|
|
89
|
+
if (!componentFile(filename)) return
|
|
90
|
+
const story = sibling(filename, '.stories.tsx')
|
|
91
|
+
if (existsSync(story)) return
|
|
92
|
+
const name = filename.split(/[\\/]/).pop() ?? filename
|
|
93
|
+
reportOnFile(context, node as unknown as ProgramNode, 'missing', { name, story: story.split(/[\\/]/).pop() ?? story })
|
|
94
|
+
},
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
}
|
package/src/eslint/index.test.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs'
|
|
|
2
2
|
import { ESLint, type Linter } from 'eslint'
|
|
3
3
|
import { parser } from 'typescript-eslint'
|
|
4
4
|
import { describe, expect, it } from 'vitest'
|
|
5
|
-
import estiva, { countGates, PLUGIN_KEY } from './index'
|
|
5
|
+
import estiva, { APP_RULE_IDS, countGates, PACKAGE_RULE_IDS, PLUGIN_KEY } from './index'
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* The plugin as an app uses it: a flat config with `configs.recommended`,
|
|
@@ -32,12 +32,44 @@ describe('the plugin object', () => {
|
|
|
32
32
|
expect(estiva.meta).toEqual({ name: '@estiva-app/ui/eslint', version: pkg.version })
|
|
33
33
|
})
|
|
34
34
|
|
|
35
|
-
it('
|
|
36
|
-
expect(Object.keys(estiva.rules)).toEqual([
|
|
35
|
+
it('carries every rule, the app ones and the inward ones', () => {
|
|
36
|
+
expect(Object.keys(estiva.rules)).toEqual([
|
|
37
|
+
'no-raw-button',
|
|
38
|
+
'raw-element-outside-a-wrapper',
|
|
39
|
+
'no-hand-rolled-behaviour',
|
|
40
|
+
'component-has-a-page',
|
|
41
|
+
'component-has-a-story',
|
|
42
|
+
])
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The apps spread `recommended`. A rule added for the package (UIG-5) must not
|
|
47
|
+
* arrive in Peek or Ship with the next version bump: an app is full of raw
|
|
48
|
+
* elements it may keep until UIG-7, and has no `.mdx` pages at all. This is
|
|
49
|
+
* the test that holds that line — if you add an app-facing rule on purpose,
|
|
50
|
+
* change it deliberately, here.
|
|
51
|
+
*/
|
|
52
|
+
it('gives an app only the app rules, as errors, under estiva/', () => {
|
|
37
53
|
for (const config of [estiva.configs.recommended, estiva.configs.strict]) {
|
|
38
54
|
expect(config.plugins?.[PLUGIN_KEY]).toBe(estiva)
|
|
39
55
|
expect(config.rules).toEqual({ 'estiva/no-raw-button': 'error' })
|
|
40
56
|
}
|
|
57
|
+
expect(APP_RULE_IDS).toEqual(['estiva/no-raw-button'])
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('gives this package its own set, as errors, and it reaches no app config', () => {
|
|
61
|
+
expect(estiva.configs.package.plugins?.[PLUGIN_KEY]).toBe(estiva)
|
|
62
|
+
expect(estiva.configs.package.rules).toEqual({
|
|
63
|
+
'estiva/raw-element-outside-a-wrapper': 'error',
|
|
64
|
+
'estiva/no-hand-rolled-behaviour': 'error',
|
|
65
|
+
'estiva/component-has-a-page': 'error',
|
|
66
|
+
'estiva/component-has-a-story': 'error',
|
|
67
|
+
})
|
|
68
|
+
expect(PACKAGE_RULE_IDS).toEqual(Object.keys(estiva.configs.package.rules ?? {}))
|
|
69
|
+
for (const id of PACKAGE_RULE_IDS) {
|
|
70
|
+
expect(estiva.configs.recommended.rules?.[id]).toBeUndefined()
|
|
71
|
+
expect(estiva.configs.strict.rules?.[id]).toBeUndefined()
|
|
72
|
+
}
|
|
41
73
|
})
|
|
42
74
|
})
|
|
43
75
|
|
package/src/eslint/index.ts
CHANGED
|
@@ -18,7 +18,10 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import { createRequire } from 'node:module'
|
|
20
20
|
import type { ESLint, Linter } from 'eslint'
|
|
21
|
+
import { componentHasAPage, componentHasAStory } from './has-a-page-and-a-story'
|
|
22
|
+
import { noHandRolledBehaviour } from './no-hand-rolled-behaviour'
|
|
21
23
|
import { noRawButton } from './no-raw-button'
|
|
24
|
+
import { rawElementOutsideAWrapper } from './raw-element-outside-a-wrapper'
|
|
22
25
|
|
|
23
26
|
export { ESCAPE_MARKER, MIN_REASON, SETTINGS_KEY, isEscaped, type EstivaSettings } from './escape'
|
|
24
27
|
|
|
@@ -27,23 +30,52 @@ const { version } = createRequire(import.meta.url)('../../package.json') as { ve
|
|
|
27
30
|
/** The name the configs register the plugin under, so every rule id is `estiva/<rule>`. */
|
|
28
31
|
export const PLUGIN_KEY = 'estiva'
|
|
29
32
|
|
|
30
|
-
|
|
33
|
+
/**
|
|
34
|
+
* The rules an **app** runs: they say an app must not build what the package
|
|
35
|
+
* already has. `recommended` and `strict` carry these and only these.
|
|
36
|
+
*/
|
|
37
|
+
const appRules = {
|
|
31
38
|
'no-raw-button': noRawButton,
|
|
32
39
|
}
|
|
33
40
|
|
|
41
|
+
/**
|
|
42
|
+
* The rules the **package itself** runs, pointed inward (UIG-5): don't bury a
|
|
43
|
+
* raw element inside a component, don't rebuild what Base UI owns, don't ship a
|
|
44
|
+
* component without a page or a story.
|
|
45
|
+
*
|
|
46
|
+
* They are in `configs.package`, never in `recommended`, on purpose. Peek and
|
|
47
|
+
* Ship spread `recommended`, so a rule added here must not arrive in an app
|
|
48
|
+
* with the next version bump: an app is full of raw elements it is allowed to
|
|
49
|
+
* have until UIG-7, and has no `.mdx` pages at all. `index.test.ts` holds the
|
|
50
|
+
* apps' list to exactly the app rules.
|
|
51
|
+
*/
|
|
52
|
+
const packageRules = {
|
|
53
|
+
'raw-element-outside-a-wrapper': rawElementOutsideAWrapper,
|
|
54
|
+
'no-hand-rolled-behaviour': noHandRolledBehaviour,
|
|
55
|
+
'component-has-a-page': componentHasAPage,
|
|
56
|
+
'component-has-a-story': componentHasAStory,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const rules = { ...appRules, ...packageRules }
|
|
60
|
+
|
|
34
61
|
const plugin = {
|
|
35
62
|
meta: { name: '@estiva-app/ui/eslint', version },
|
|
36
63
|
rules,
|
|
37
|
-
configs: {} as { recommended: Linter.Config; strict: Linter.Config },
|
|
64
|
+
configs: {} as { recommended: Linter.Config; strict: Linter.Config; package: Linter.Config },
|
|
38
65
|
} satisfies ESLint.Plugin
|
|
39
66
|
|
|
40
|
-
|
|
67
|
+
/** The ids `recommended` and `strict` carry — what an app's gate runs and counts. */
|
|
68
|
+
export const APP_RULE_IDS = Object.keys(appRules).map((name) => `${PLUGIN_KEY}/${name}`)
|
|
69
|
+
/** The ids `package` carries — what this package's own gate runs and counts (UIG-5). */
|
|
70
|
+
export const PACKAGE_RULE_IDS = Object.keys(packageRules).map((name) => `${PLUGIN_KEY}/${name}`)
|
|
41
71
|
|
|
42
72
|
/**
|
|
43
|
-
* `recommended` switches every rule on at the level it was ruled at: an
|
|
44
|
-
* blocks, a warning is reported and never blocks. `strict` makes every
|
|
45
|
-
* error. With one rule, an error, the two are the same today; they part
|
|
46
|
-
* the first warning-level rule arrives (UIG-25).
|
|
73
|
+
* `recommended` switches every **app** rule on at the level it was ruled at: an
|
|
74
|
+
* error blocks, a warning is reported and never blocks. `strict` makes every app
|
|
75
|
+
* rule an error. With one rule, an error, the two are the same today; they part
|
|
76
|
+
* when the first warning-level rule arrives (UIG-25).
|
|
77
|
+
*
|
|
78
|
+
* `package` is the inward set (UIG-5), which only this package runs.
|
|
47
79
|
*/
|
|
48
80
|
plugin.configs.recommended = {
|
|
49
81
|
name: '@estiva-app/ui/recommended',
|
|
@@ -53,7 +85,12 @@ plugin.configs.recommended = {
|
|
|
53
85
|
plugin.configs.strict = {
|
|
54
86
|
name: '@estiva-app/ui/strict',
|
|
55
87
|
plugins: { [PLUGIN_KEY]: plugin },
|
|
56
|
-
rules: Object.fromEntries(
|
|
88
|
+
rules: Object.fromEntries(APP_RULE_IDS.map((id) => [id, 'error'])),
|
|
89
|
+
}
|
|
90
|
+
plugin.configs.package = {
|
|
91
|
+
name: '@estiva-app/ui/package',
|
|
92
|
+
plugins: { [PLUGIN_KEY]: plugin },
|
|
93
|
+
rules: Object.fromEntries(PACKAGE_RULE_IDS.map((id) => [id, 'error'])),
|
|
57
94
|
}
|
|
58
95
|
|
|
59
96
|
export default plugin
|
|
@@ -80,20 +117,28 @@ export interface GateCount {
|
|
|
80
117
|
* Run the lint with `settings: { estiva: { reportEscapes: true } }` for the
|
|
81
118
|
* escapes to be counted; without it they are silent and count 0. A marker
|
|
82
119
|
* with no reason, or inside a directive, counts as an error of its rule.
|
|
120
|
+
*
|
|
121
|
+
* `seed` is which rules the count lists when they found nothing, and it is the
|
|
122
|
+
* app rules unless a caller says otherwise: an app's count file must not gain
|
|
123
|
+
* rows for the inward rules (UIG-5) that its gate does not run. This package's
|
|
124
|
+
* own count script passes `PACKAGE_RULE_IDS`.
|
|
83
125
|
*/
|
|
84
|
-
export function countGates(results: ESLint.LintResult[]): GateCount {
|
|
85
|
-
const counts: Record<string, GateRuleCount> = Object.fromEntries(
|
|
126
|
+
export function countGates(results: ESLint.LintResult[], seed: readonly string[] = APP_RULE_IDS): GateCount {
|
|
127
|
+
const counts: Record<string, GateRuleCount> = Object.fromEntries(seed.map((id) => [id, { errors: 0, warnings: 0, escapes: 0 }]))
|
|
86
128
|
const disabled: GateCount['disabled'] = []
|
|
129
|
+
const ours = (ruleId: string | null | undefined): ruleId is string => typeof ruleId === 'string' && ruleId.startsWith(`${PLUGIN_KEY}/`)
|
|
87
130
|
for (const result of results) {
|
|
88
131
|
for (const message of result.messages) {
|
|
89
|
-
|
|
90
|
-
|
|
132
|
+
// Seeded or not: every rule of this plugin that reported is counted, so a
|
|
133
|
+
// lint of a set the caller did not name is still counted in full.
|
|
134
|
+
if (!ours(message.ruleId)) continue
|
|
135
|
+
const count = (counts[message.ruleId] ??= { errors: 0, warnings: 0, escapes: 0 })
|
|
91
136
|
if (message.messageId === 'escaped') count.escapes += 1
|
|
92
137
|
else if (message.severity === 2) count.errors += 1
|
|
93
138
|
else count.warnings += 1
|
|
94
139
|
}
|
|
95
140
|
for (const message of result.suppressedMessages ?? []) {
|
|
96
|
-
if (message.ruleId
|
|
141
|
+
if (ours(message.ruleId)) disabled.push({ filePath: result.filePath, line: message.line, ruleId: message.ruleId })
|
|
97
142
|
}
|
|
98
143
|
}
|
|
99
144
|
return { rules: counts, disabled }
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { RuleTester } from 'eslint'
|
|
2
|
+
import { parser } from 'typescript-eslint'
|
|
3
|
+
import { describe, it } from 'vitest'
|
|
4
|
+
import { noHandRolledBehaviour } from './no-hand-rolled-behaviour'
|
|
5
|
+
|
|
6
|
+
RuleTester.describe = describe
|
|
7
|
+
RuleTester.it = it
|
|
8
|
+
RuleTester.itOnly = it.only
|
|
9
|
+
|
|
10
|
+
const tester = new RuleTester({
|
|
11
|
+
languageOptions: { parser, parserOptions: { ecmaFeatures: { jsx: true } } },
|
|
12
|
+
linterOptions: { reportUnusedDisableDirectives: 'off' },
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
tester.run('no-hand-rolled-behaviour', noHandRolledBehaviour, {
|
|
16
|
+
valid: [
|
|
17
|
+
{ name: 'a Base UI part doing its own portalling', code: "import { Popover } from '@base-ui/react/popover'\nexport const Probe = () => <Popover.Portal />\n" },
|
|
18
|
+
{
|
|
19
|
+
name: "a virtual anchor handed to Base UI, as Popover's",
|
|
20
|
+
code: 'export function probe(rect: DOMRect) {\n return { getBoundingClientRect: () => rect }\n}\n',
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
name: "an element's own handler, which is not a global listener",
|
|
24
|
+
code: 'export function probe(el: HTMLElement) {\n el.addEventListener("keydown", () => {})\n}\n',
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
name: 'a listener for something no floating part owns',
|
|
28
|
+
code: 'export function probe() {\n window.addEventListener("online", () => {})\n}\n',
|
|
29
|
+
},
|
|
30
|
+
{
|
|
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",
|
|
33
|
+
},
|
|
34
|
+
{
|
|
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',
|
|
37
|
+
},
|
|
38
|
+
{
|
|
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',
|
|
41
|
+
},
|
|
42
|
+
],
|
|
43
|
+
invalid: [
|
|
44
|
+
{
|
|
45
|
+
name: 'importing createPortal',
|
|
46
|
+
code: "import { createPortal } from 'react-dom'\nexport const Probe = () => null\n",
|
|
47
|
+
errors: [{ messageId: 'portal' }],
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
name: 'calling createPortal',
|
|
51
|
+
code: 'export function probe(to: HTMLElement) {\n return createPortal(null, to)\n}\n',
|
|
52
|
+
errors: [{ messageId: 'portal' }],
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
name: 'following the anchor by hand',
|
|
56
|
+
code: 'export function probe() {\n window.addEventListener("scroll", () => {})\n}\n',
|
|
57
|
+
errors: [{ messageId: 'listener', data: { event: 'scroll', target: 'window' } }],
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
name: 'closing on an outside press by hand',
|
|
61
|
+
code: 'export function probe() {\n document.addEventListener("mousedown", () => {})\n}\n',
|
|
62
|
+
errors: [{ messageId: 'listener', data: { event: 'mousedown', target: 'document' } }],
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
name: 'closing on Escape by hand',
|
|
66
|
+
code: 'export function probe() {\n document.addEventListener("keydown", () => {})\n}\n',
|
|
67
|
+
errors: [{ messageId: 'listener', data: { event: 'keydown', target: 'document' } }],
|
|
68
|
+
},
|
|
69
|
+
],
|
|
70
|
+
})
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import type { Rule } from 'eslint'
|
|
2
|
+
import { ESCAPE_MESSAGES, isEscaped } from './escape'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Behaviour Base UI already owns, written by hand inside the package (UIG-5,
|
|
6
|
+
* P1 — the migration's standing rule D6 as a machine).
|
|
7
|
+
*
|
|
8
|
+
* This is the rule with the most to lose. A hand-made portal or a global
|
|
9
|
+
* listener inside `DialogShell` is inherited by every caller in every app, and
|
|
10
|
+
* nobody sees it from there.
|
|
11
|
+
*
|
|
12
|
+
* Two facts, both mechanical, both the signature of a floating part rebuilt by
|
|
13
|
+
* hand:
|
|
14
|
+
*
|
|
15
|
+
* - **`createPortal`**, or importing it from `react-dom`. Base UI's parts carry
|
|
16
|
+
* their own portal.
|
|
17
|
+
* - **a listener on `window` or `document`** for the events a floating part
|
|
18
|
+
* lives on — resize, scroll, an outside press, a key. Base UI's Positioner
|
|
19
|
+
* follows the anchor and its Root closes on an outside press and on Escape.
|
|
20
|
+
*
|
|
21
|
+
* What it deliberately does not try to read: measuring arithmetic. `Popover`
|
|
22
|
+
* hands Base UI a virtual anchor with a `getBoundingClientRect`, which is the
|
|
23
|
+
* documented way to anchor to a rectangle rather than an element, and a rule
|
|
24
|
+
* that guessed at arithmetic would report it. Anything that shape is caught by
|
|
25
|
+
* a reader, not by this.
|
|
26
|
+
*
|
|
27
|
+
* A place that keeps one says why on the line above: `// @estiva-escape: <why>`.
|
|
28
|
+
*/
|
|
29
|
+
const FLOATING_EVENTS = new Set(['resize', 'scroll', 'mousedown', 'pointerdown', 'keydown', 'keyup', 'focusin', 'focusout', 'click'])
|
|
30
|
+
|
|
31
|
+
interface Node {
|
|
32
|
+
type: string
|
|
33
|
+
loc: NonNullable<Rule.Node['loc']>
|
|
34
|
+
range: [number, number]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface Identifier extends Node {
|
|
38
|
+
name: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface ImportDeclaration extends Node {
|
|
42
|
+
source: { value: unknown }
|
|
43
|
+
specifiers: { type: string; imported?: { name?: string }; local?: { name?: string }; loc?: Rule.Node['loc']; range?: [number, number] }[]
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface CallExpression extends Node {
|
|
47
|
+
callee: { type: string; name?: string; object?: { type: string; name?: string }; property?: { type: string; name?: string } }
|
|
48
|
+
arguments: { type: string; value?: unknown }[]
|
|
49
|
+
parent?: { type: string; loc?: Rule.Node['loc']; range?: [number, number]; parent?: CallExpression['parent'] }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The statement a call sits in, which is what an escape is written above:
|
|
54
|
+
* `return createPortal(…)` puts the call in the middle of its line, and a
|
|
55
|
+
* comment above the line is above the statement.
|
|
56
|
+
*/
|
|
57
|
+
function statement(node: { loc: NonNullable<Rule.Node['loc']>; range: [number, number]; parent?: CallExpression['parent'] }): {
|
|
58
|
+
loc: NonNullable<Rule.Node['loc']>
|
|
59
|
+
range: [number, number]
|
|
60
|
+
} {
|
|
61
|
+
for (let p = node.parent; p; p = p.parent) {
|
|
62
|
+
if (!/(Statement|Declaration)$/.test(p.type)) continue
|
|
63
|
+
if (p.loc && p.range) return { loc: p.loc, range: p.range }
|
|
64
|
+
break
|
|
65
|
+
}
|
|
66
|
+
return { loc: node.loc, range: node.range }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export const noHandRolledBehaviour: Rule.RuleModule = {
|
|
70
|
+
meta: {
|
|
71
|
+
type: 'problem',
|
|
72
|
+
docs: { description: 'Overlay behaviour written by hand where Base UI has a part (D6)' },
|
|
73
|
+
schema: [],
|
|
74
|
+
messages: {
|
|
75
|
+
portal:
|
|
76
|
+
'A portal written by hand. Base UI\'s parts carry their own (Popover, Menu, Select, Tooltip, Dialog, Toast): use the part, or say why this one stays.',
|
|
77
|
+
listener:
|
|
78
|
+
'A `{{event}}` listener on `{{target}}`. A floating part follows its anchor and closes on an outside press and on Escape by itself (Base UI Positioner and Root): use the part, or say why this one stays.',
|
|
79
|
+
...ESCAPE_MESSAGES,
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
create(context) {
|
|
83
|
+
return {
|
|
84
|
+
ImportDeclaration(node: Rule.Node) {
|
|
85
|
+
const declaration = node as unknown as ImportDeclaration
|
|
86
|
+
if (declaration.source.value !== 'react-dom') return
|
|
87
|
+
for (const specifier of declaration.specifiers) {
|
|
88
|
+
if (specifier.type !== 'ImportSpecifier' || specifier.imported?.name !== 'createPortal') continue
|
|
89
|
+
// The escape goes above the import line, not above the name inside it.
|
|
90
|
+
if (isEscaped(context, declaration)) return
|
|
91
|
+
context.report({ loc: declaration.loc, messageId: 'portal' })
|
|
92
|
+
return
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
CallExpression(node: Rule.Node) {
|
|
96
|
+
const call = node as unknown as CallExpression
|
|
97
|
+
const { callee } = call
|
|
98
|
+
|
|
99
|
+
if (callee.type === 'Identifier' && callee.name === 'createPortal') {
|
|
100
|
+
if (isEscaped(context, statement(call))) return
|
|
101
|
+
context.report({ loc: call.loc, messageId: 'portal' })
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (callee.type !== 'MemberExpression' || callee.property?.name !== 'addEventListener') return
|
|
106
|
+
const target = callee.object?.type === 'Identifier' ? callee.object.name : undefined
|
|
107
|
+
if (target !== 'window' && target !== 'document') return
|
|
108
|
+
const [first] = call.arguments
|
|
109
|
+
const event = first?.type === 'Literal' && typeof first.value === 'string' ? first.value : undefined
|
|
110
|
+
if (!event || !FLOATING_EVENTS.has(event)) return
|
|
111
|
+
if (isEscaped(context, statement(call))) return
|
|
112
|
+
context.report({ loc: call.loc, messageId: 'listener', data: { event, target } })
|
|
113
|
+
},
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
}
|