@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.
Files changed (38) hide show
  1. package/dist/Breadcrumb.d.ts.map +1 -1
  2. package/dist/CommandPalette.d.ts +114 -0
  3. package/dist/CommandPalette.d.ts.map +1 -0
  4. package/dist/Menu.d.ts +4 -0
  5. package/dist/Menu.d.ts.map +1 -1
  6. package/dist/Toast.d.ts.map +1 -1
  7. package/dist/eslint/has-a-page-and-a-story.d.ts +4 -0
  8. package/dist/eslint/has-a-page-and-a-story.d.ts.map +1 -0
  9. package/dist/eslint/index.d.ts +15 -1
  10. package/dist/eslint/index.d.ts.map +1 -1
  11. package/dist/eslint/index.js +176 -8
  12. package/dist/eslint/index.js.map +3 -3
  13. package/dist/eslint/no-hand-rolled-behaviour.d.ts +3 -0
  14. package/dist/eslint/no-hand-rolled-behaviour.d.ts.map +1 -0
  15. package/dist/eslint/raw-element-outside-a-wrapper.d.ts +29 -0
  16. package/dist/eslint/raw-element-outside-a-wrapper.d.ts.map +1 -0
  17. package/dist/index.d.ts +1 -0
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +609 -287
  20. package/dist/index.js.map +4 -4
  21. package/package.json +5 -1
  22. package/src/Breadcrumb.tsx +6 -2
  23. package/src/CommandPalette.mdx +133 -0
  24. package/src/CommandPalette.stories.tsx +416 -0
  25. package/src/CommandPalette.test.tsx +392 -0
  26. package/src/CommandPalette.tsx +643 -0
  27. package/src/Menu.tsx +4 -2
  28. package/src/Toast.tsx +12 -24
  29. package/src/eslint/has-a-page-and-a-story.test.ts +57 -0
  30. package/src/eslint/has-a-page-and-a-story.ts +97 -0
  31. package/src/eslint/index.test.ts +35 -3
  32. package/src/eslint/index.ts +58 -13
  33. package/src/eslint/no-hand-rolled-behaviour.test.ts +70 -0
  34. package/src/eslint/no-hand-rolled-behaviour.ts +116 -0
  35. package/src/eslint/raw-element-outside-a-wrapper.test.ts +82 -0
  36. package/src/eslint/raw-element-outside-a-wrapper.ts +85 -0
  37. package/src/index.ts +17 -0
  38. 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
- <button type="button" onClick={onAction} className={actionClassName(type)}>
130
- <span className={ACTION_LABEL_CLASSES}>{actionLabel}</span>
131
- </button>
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
- className={actionClassName(type)}
233
- >
234
- <span className={ACTION_LABEL_CLASSES}>{actionLabel}</span>
235
- </BaseToast.Action>
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
+ }
@@ -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('has no-raw-button, and both configs switch it on as an error under estiva/', () => {
36
- expect(Object.keys(estiva.rules)).toEqual(['no-raw-button'])
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
 
@@ -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
- const rules = {
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
- const ruleIds = Object.keys(rules).map((name) => `${PLUGIN_KEY}/${name}`)
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 error
44
- * blocks, a warning is reported and never blocks. `strict` makes every rule an
45
- * error. With one rule, an error, the two are the same today; they part when
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(ruleIds.map((id) => [id, 'error'])),
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(ruleIds.map((id) => [id, { errors: 0, warnings: 0, escapes: 0 }]))
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
- const count = message.ruleId ? counts[message.ruleId] : undefined
90
- if (!count) continue
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 && counts[message.ruleId]) disabled.push({ filePath: result.filePath, line: message.line, ruleId: 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
+ }