@estiva-app/ui 0.18.0 → 0.19.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.
@@ -0,0 +1,276 @@
1
+ import { readdirSync, readFileSync } from 'node:fs'
2
+ import { createRequire } from 'node:module'
3
+ import { RuleTester } from 'eslint'
4
+ import ts from 'typescript'
5
+ import { parser } from 'typescript-eslint'
6
+ import { describe, expect, it } from 'vitest'
7
+ import {
8
+ BASE_UI_PARTS,
9
+ baseUiModule,
10
+ noRebuiltBehaviour,
11
+ OWNED_BEHAVIOURS,
12
+ ROLE_PARTS,
13
+ utilityOf,
14
+ WALKING_KEYS,
15
+ } from './no-rebuilt-behaviour'
16
+
17
+ RuleTester.describe = describe
18
+ RuleTester.it = it
19
+ RuleTester.itOnly = it.only
20
+
21
+ const tester = new RuleTester({
22
+ languageOptions: { parser, parserOptions: { ecmaFeatures: { jsx: true } } },
23
+ linterOptions: { reportUnusedDisableDirectives: 'off' },
24
+ })
25
+
26
+ const component = (body: string) => `export function Probe() {\n return (\n${body}\n )\n}\n`
27
+ const effect = (body: string) => `useEffect(() => {\n${body}\n}, [])\n`
28
+ const LIST = ' To pick several, `ChipInput`; to search and act, `CommandPalette`; to tick several in a list, `Checkbox` with `row`.'
29
+
30
+ tester.run('no-rebuilt-behaviour', noRebuiltBehaviour, {
31
+ valid: [
32
+ // Base UI and portals
33
+ { name: 'a part from the package', code: "import { Select, Popover } from '@estiva-app/ui'" },
34
+ { name: 'react-dom without createPortal', code: "import { flushSync } from 'react-dom'\nflushSync(() => {})" },
35
+ { name: 'a package whose name only starts like Base UI', code: "import x from '@base-uix/react'" },
36
+
37
+ // listeners
38
+ { name: 'the window coming back is not a floating part', code: effect(" window.addEventListener('focus', refresh)\n document.addEventListener('visibilitychange', refresh)\n window.addEventListener('storage', sync)") },
39
+ { name: 'a listener on an element, not the page', code: effect(" ref.current.addEventListener('keydown', onKey)") },
40
+ { name: 'an event the code computes', code: effect(' document.addEventListener(name, handler)') },
41
+ { name: "the app's own event on window", code: effect(" window.addEventListener('highlight-tag-click', open)") },
42
+
43
+ // keys
44
+ { name: 'Enter and Escape in a field are typing', code: "const onKeyDown = (e) => {\n if (e.key === 'Enter') send()\n if (e.key === 'Escape') cancel()\n}" },
45
+ { name: 'a shortcut letter', code: "const onKeyDown = (e) => {\n if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') open()\n}" },
46
+ { name: 'the word ArrowDown that is not a key', code: "const label = 'ArrowDown'\nif (label === 'ArrowDown') go()" },
47
+
48
+ // roles
49
+ { name: 'role img on an svg', code: component(' <svg role="img" aria-label="Logo"><path d="M0 0" /></svg>') },
50
+ { name: 'the roles that describe rather than behave', code: component(' <div role="group" aria-label="Format">\n <div role="presentation" />\n <section role="region" aria-label="A" />\n <span role="none" />\n </div>') },
51
+ { name: 'a role the code computes', code: component(' <div role={role} />') },
52
+
53
+ // tab stops
54
+ { name: 'tabIndex -1 makes a box focusable by script, not a Tab stop', code: component(' <p tabIndex={-1}>measured</p>') },
55
+ { name: 'tabIndex on a control', code: component(' <div>\n <button tabIndex={0}>x</button>\n <input tabIndex={0} />\n <a href="/x" tabIndex={0}>x</a>\n </div>') },
56
+ { name: 'tabIndex passed through from a prop', code: component(' <div tabIndex={props.tabIndex} />') },
57
+ { name: 'tabIndex on an editable region', code: component(' <div contentEditable tabIndex={0} />') },
58
+ { name: 'tabIndex on a package component', code: component(' <MenuItem tabIndex={-1} label="Item" />') },
59
+
60
+ // scrolling
61
+ { name: 'overflow that does not scroll', code: component(' <div className="overflow-hidden overflow-x-clip overflow-visible overscroll-contain" />') },
62
+ { name: 'a word that only contains the class', code: "const id = 'my-overflow-auto-thing'" },
63
+ { name: 'a style that hides overflow', code: component(" <div style={{ overflow: 'hidden' }} />") },
64
+
65
+ // escapes
66
+ {
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)"),
69
+ },
70
+ {
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" />'),
73
+ },
74
+ {
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}))",
77
+ },
78
+ {
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}",
81
+ },
82
+ {
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]",
85
+ },
86
+ {
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" />'),
89
+ },
90
+ ],
91
+ invalid: [
92
+ // Base UI
93
+ {
94
+ name: 'a Base UI import names the part built on it',
95
+ code: "import { Dialog } from '@base-ui/react/dialog'",
96
+ errors: [{ message: 'Only @estiva-app/ui imports Base UI (`@base-ui/react/dialog`). Use `DialogShell` from @estiva-app/ui. To search and act, `CommandPalette`.', line: 1 }],
97
+ },
98
+ ...Object.entries(BASE_UI_PARTS).map(([module, part]) => ({
99
+ name: part ? `@base-ui/react/${module} names ${part.use}` : `@base-ui/react/${module} has no part yet`,
100
+ code: `import * as Part from '@base-ui/react/${module}'`,
101
+ errors: [{ messageId: part ? 'baseUi' : 'baseUiNoPart' }],
102
+ })),
103
+ { name: 'the old package spelling', code: "import { Menu } from '@base-ui-components/react/menu'", errors: [{ message: 'Only @estiva-app/ui imports Base UI (`@base-ui-components/react/menu`). Use `Menu` from @estiva-app/ui.' }] },
104
+ { name: 'the package root', code: "import { Popover } from '@base-ui/react'", errors: [{ messageId: 'baseUiNoPart' }] },
105
+ { name: "a Base UI utility", code: "import { useRender } from '@base-ui/react/use-render'", errors: [{ messageId: 'baseUiNoPart' }] },
106
+ { name: 'a type-only import', code: "import type { PopoverRootProps } from '@base-ui/react/popover'", errors: [{ messageId: 'baseUi' }] },
107
+ { name: 're-exported', code: "export { Popover } from '@base-ui/react/popover'", errors: [{ messageId: 'baseUi' }] },
108
+ { name: 'imported when needed', code: "const Tabs = lazy(() => import('@base-ui/react/tabs'))", errors: [{ messageId: 'baseUi' }] },
109
+ { name: 'required', code: "const { Toolbar } = require('@base-ui/react/toolbar')", errors: [{ messageId: 'baseUi' }] },
110
+
111
+ // portals
112
+ {
113
+ name: 'createPortal, called: reported where it is called, once',
114
+ code: "import { createPortal } from 'react-dom'\nfunction Viewer() {\n return createPortal(<div />, document.body)\n}",
115
+ errors: [{ messageId: 'portal', line: 3 }],
116
+ },
117
+ { name: 'createPortal imported and never called', code: "import { createPortal } from 'react-dom'\nexport function Header() {\n return null\n}", errors: [{ messageId: 'portal', line: 1 }] },
118
+ { name: 'createPortal on the namespace', code: "import ReactDOM from 'react-dom'\nReactDOM.createPortal(child, node)", errors: [{ messageId: 'portal', line: 2 }] },
119
+
120
+ // listeners
121
+ {
122
+ name: 'a press outside, by hand',
123
+ code: effect(" document.addEventListener('mousedown', close)"),
124
+ errors: [{ message: 'A `mousedown` listener on `document`: closing on a press outside, by hand. `Popover`, `Menu`, `Select`, `DialogShell` and `PreviewCard` from @estiva-app/ui close themselves.', line: 2 }],
125
+ },
126
+ { name: 'keys for the whole page', code: effect(" window.addEventListener('keydown', onKey)"), errors: [{ messageId: 'key', data: { event: 'keydown', target: 'window' } }] },
127
+ { name: 'focus held by hand', code: effect(" document.body.addEventListener('focusin', keep, true)"), errors: [{ messageId: 'focus', data: { event: 'focusin', target: 'document.body' } }] },
128
+ { name: 'following an anchor by hand', code: effect(" window.addEventListener('resize', place)\n window.addEventListener('scroll', place, true)"), errors: [{ messageId: 'follow', line: 2 }, { messageId: 'follow', line: 3 }] },
129
+ { name: 'a handler property on window', code: 'window.onkeydown = (e) => close(e)', errors: [{ messageId: 'key', data: { event: 'keydown', target: 'window' } }] },
130
+ { name: "the page's scroll locked by hand", code: "document.body.style.overflow = 'hidden'", errors: [{ messageId: 'scrollLock' }] },
131
+
132
+ // keys
133
+ {
134
+ name: 'arrow keys in one handler: one report, at the first',
135
+ code: "const onKeyDown = (e) => {\n if (e.key === 'ArrowDown') next()\n if (e.key === 'ArrowUp') previous()\n}",
136
+ errors: [{ message: 'Arrow keys handled by hand (`ArrowDown`). `Menu`, `Select`, `ChipInput`, `CommandPalette`, `Tabs` and `Toolbar` from @estiva-app/ui move through their items themselves.', line: 2 }],
137
+ },
138
+ ...WALKING_KEYS.map((key) => ({ name: `${key} is a walking key`, code: `function onKey(event) {\n if (event.code == '${key}') go()\n}`, errors: [{ messageId: 'walking', data: { key } }] })),
139
+ { name: 'two handlers are two reports', code: "const a = (e) => e.key === 'ArrowLeft'\nconst b = (e) => e.key === 'ArrowRight'", errors: [{ messageId: 'walking', line: 1 }, { messageId: 'walking', line: 2 }] },
140
+ { name: 'a switch on the key', code: "function onKey(e) {\n switch (e.key) {\n case 'Enter': return send()\n case 'Home': return first()\n }\n}", errors: [{ messageId: 'walking', data: { key: 'Home' }, line: 4 }] },
141
+ { name: 'a list of keys', code: "const walks = (event) => ['ArrowUp', 'ArrowDown'].includes(event.key)", errors: [{ messageId: 'walking', data: { key: 'ArrowUp' } }] },
142
+ { name: 'a destructured key', code: "function onKey({ key }) {\n if (key !== 'PageDown') return\n}", errors: [{ messageId: 'walking', data: { key: 'PageDown' } }] },
143
+ { name: 'the Tab key, by hand', code: "const trap = (e) => {\n if (e.key === 'Tab') keepInside(e)\n}", errors: [{ messageId: 'tabKey', line: 2 }] },
144
+ { name: 'arrow keys in a JSX handler', code: component(" <div onKeyDown={(e) => { if (e.key === 'ArrowDown') next() }} />"), errors: [{ messageId: 'walking', line: 3 }] },
145
+
146
+ // roles
147
+ ...Object.entries(ROLE_PARTS).map(([role, { thing, part }]) => ({
148
+ name: part ? `role="${role}" names ${part.use}` : `role="${role}" has no part yet`,
149
+ code: component(` <div role="${role}" />`),
150
+ errors: [
151
+ part
152
+ ? { message: `A hand-written \`role="${role}"\` is a hand-made ${thing}. Use \`${part.use}\` from @estiva-app/ui.${part.more ?? ''}` }
153
+ : { message: `A hand-written \`role="${role}"\` is a hand-made ${thing}, and @estiva-app/ui has no part for one yet. Do not build one here: ask Katerina, and it gets made in @estiva-app/ui.` },
154
+ ],
155
+ })),
156
+ { name: 'a hand-made option row (Peek, AddToOpenWorkDialog)', code: component(' <div role="option" aria-selected={checked} onClick={toggle} />'), errors: [{ message: `A hand-written \`role="option"\` is a hand-made list. Use \`Select\` from @estiva-app/ui.${LIST}` }] },
157
+ { name: 'a role on a package component', code: component(' <MenuItem role="option" label="Item" />'), errors: [{ messageId: 'role' }] },
158
+ { name: 'a role in braces, or one of two', code: component(" <p role={failed ? 'alert' : 'status'} />"), errors: [{ messageId: 'role', data: { role: 'alert', thing: 'message', use: 'FieldLine', more: ' For a notice, `Banner`; for a message that comes and goes, `Toast`.' } }] },
159
+
160
+ // tab stops
161
+ { name: 'tabIndex 0 on a div', code: component(' <div tabIndex={0} />'), errors: [{ message: '`tabIndex=0` makes a `<div>` a Tab stop by hand. Use `Button`, `IconButton` or `Link` from @estiva-app/ui, which are reachable already.' }] },
162
+ { name: 'tabIndex written as a string', code: component(' <span tabIndex="0" />'), errors: [{ messageId: 'tabStop' }] },
163
+ { name: 'tabIndex above 0', code: component(' <li tabIndex={2} />'), errors: [{ messageId: 'tabStop' }] },
164
+ { name: 'tabIndex 0 on one side of a condition (Ship, DescriptionEditor)', code: component(' <div tabIndex={readOnly ? undefined : 0} />'), errors: [{ messageId: 'tabStop', data: { value: 'readOnly ? undefined : 0', element: 'div' } }] },
165
+
166
+ // scrolling
167
+ { name: 'overflow-y-auto', code: component(' <div className="h-64 overflow-y-auto" />'), errors: [{ message: "`overflow-y-auto` scrolls with the browser's scrollbar. Use `ScrollArea` from @estiva-app/ui, which draws ours." }] },
168
+ { name: 'every overflow that scrolls', code: component(' <div className="overflow-auto overflow-scroll overflow-x-auto overflow-y-scroll" />'), errors: [{ messageId: 'scrollClass' }, { messageId: 'scrollClass' }, { messageId: 'scrollClass' }, { messageId: 'scrollClass' }] },
169
+ { name: 'behind a word-shaped variant', code: component(' <div className="md:overflow-auto" />'), errors: [{ messageId: 'scrollClass', data: { token: 'md:overflow-auto' } }] },
170
+ { name: 'behind an arbitrary variant (Ship, prose.ts)', code: "const PROSE_CLASSES = [\n '[&_pre]:overflow-x-auto [&_pre]:rounded-md',\n]", errors: [{ messageId: 'scrollClass', data: { token: '[&_pre]:overflow-x-auto' }, line: 2 }] },
171
+ { name: 'marked important', code: "const box = cn('!overflow-y-auto')", errors: [{ messageId: 'scrollClass' }] },
172
+ { name: 'inside a template', code: 'const box = `flex ${open ? "a" : "b"} overflow-y-auto`', errors: [{ messageId: 'scrollClass' }] },
173
+ { name: 'in a style', code: component(" <div style={{ overflowY: 'auto' }} />"), errors: [{ message: "`overflowY: 'auto'` scrolls with the browser's scrollbar. Use `ScrollArea` from @estiva-app/ui, which draws ours." }] },
174
+
175
+ // escapes
176
+ {
177
+ name: 'an escape with no reason hides nothing',
178
+ code: effect(" // @estiva-escape:\n document.addEventListener('mousedown', close)"),
179
+ errors: [{ messageId: 'escapeWithoutReason', line: 2 }, { messageId: 'press', line: 3 }],
180
+ },
181
+ {
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)"),
184
+ errors: [{ messageId: 'key', line: 4 }],
185
+ },
186
+ {
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} />'),
189
+ settings: { estiva: { reportEscapes: true } },
190
+ errors: [{ messageId: 'escaped', line: 3 }],
191
+ },
192
+ ],
193
+ })
194
+
195
+ describe('the behaviour table is derived from the package source', () => {
196
+ const src = new URL('../', import.meta.url)
197
+ const index = readFileSync(new URL('index.ts', src), 'utf8')
198
+
199
+ /** Every name the package root exports, and the file it comes from. */
200
+ const exportedFrom = new Map<string, string>()
201
+ for (const statement of ts.createSourceFile('index.ts', index, ts.ScriptTarget.Latest, true).statements) {
202
+ if (!ts.isExportDeclaration(statement) || !statement.moduleSpecifier || !ts.isStringLiteral(statement.moduleSpecifier)) continue
203
+ if (!statement.exportClause || !ts.isNamedExports(statement.exportClause)) continue
204
+ for (const element of statement.exportClause.elements) exportedFrom.set(element.name.text, statement.moduleSpecifier.text.replace(/^\.\//, ''))
205
+ }
206
+
207
+ /** Which Base UI modules each component file imports, read with the TypeScript parser. */
208
+ const importsOf = new Map<string, Set<string>>()
209
+ for (const file of readdirSync(src).filter((f) => /\.tsx?$/.test(f) && !/\.(test|stories)\.tsx?$/.test(f))) {
210
+ const sourceFile = ts.createSourceFile(file, readFileSync(new URL(file, src), 'utf8'), ts.ScriptTarget.Latest, true)
211
+ const modules = new Set<string>()
212
+ for (const statement of sourceFile.statements) {
213
+ if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier)) continue
214
+ const module = baseUiModule(statement.moduleSpecifier.text)
215
+ if (module !== undefined) modules.add(module)
216
+ }
217
+ importsOf.set(file.replace(/\.tsx?$/, ''), modules)
218
+ }
219
+ const importedAnywhere = new Set([...importsOf.values()].flatMap((s) => [...s]))
220
+
221
+ /** Base UI's helpers, which no component is built on: an import of one says to ask. */
222
+ const UTILITIES = new Set(['', 'types', 'use-render', 'merge-props', 'csp-provider', 'direction-provider', 'unstable-use-media-query'])
223
+
224
+ it('names every Base UI part the package imports', () => {
225
+ const unnamed = [...importedAnywhere].filter((m) => !UTILITIES.has(m) && !BASE_UI_PARTS[m])
226
+ expect(unnamed).toEqual([])
227
+ expect(importedAnywhere.size).toBeGreaterThan(20)
228
+ })
229
+
230
+ it('names, for each part, a component whose own file imports it', () => {
231
+ for (const [module, part] of Object.entries(BASE_UI_PARTS)) {
232
+ if (!part) continue
233
+ const file = exportedFrom.get(part.use)
234
+ expect(file, `${part.use} is exported`).toBeDefined()
235
+ expect([...(importsOf.get(file as string) ?? [])], `${part.use} (${file}) imports @base-ui/react/${module}`).toContain(module)
236
+ }
237
+ })
238
+
239
+ it('says "no part yet" only for parts no component imports', () => {
240
+ const named = Object.entries(BASE_UI_PARTS).filter(([, part]) => part === null).map(([module]) => module)
241
+ expect(named.filter((m) => importedAnywhere.has(m))).toEqual([])
242
+ })
243
+
244
+ it("covers every module Base UI publishes", () => {
245
+ const base = createRequire(import.meta.url)('@base-ui/react/package.json') as { exports: Record<string, unknown> }
246
+ const published = Object.keys(base.exports)
247
+ .filter((key) => key.startsWith('./') && !key.startsWith('./internals/') && !key.endsWith('.json'))
248
+ .map((key) => key.slice(2))
249
+ expect(published.filter((m) => !UTILITIES.has(m) && !Object.hasOwn(BASE_UI_PARTS, m))).toEqual([])
250
+ })
251
+
252
+ it('names only components the package exports, everywhere it names one', () => {
253
+ const messages = Object.values(noRebuiltBehaviour.meta?.messages ?? {}).join(' ')
254
+ const tables = JSON.stringify([BASE_UI_PARTS, ROLE_PARTS, OWNED_BEHAVIOURS.map((b) => b.owners)])
255
+ const named = new Set([...`${messages} ${tables}`.matchAll(/`([A-Z]\w+)`|"use":"(\w+)"|"([A-Z]\w+)"/g)].map((m) => m[1] ?? m[2] ?? m[3]))
256
+ expect([...named].filter((name) => !exportedFrom.has(name))).toEqual([])
257
+ })
258
+
259
+ it('has one row per behaviour, each naming an owner and what it reads', () => {
260
+ expect(OWNED_BEHAVIOURS.map((b) => b.id)).toEqual(['base-ui', 'portal', 'press-outside', 'page-keys', 'focus', 'scroll-lock', 'follow', 'walking', 'role', 'tab-stop', 'scroll'])
261
+ for (const row of OWNED_BEHAVIOURS) {
262
+ expect(row.owners.length, row.id).toBeGreaterThan(0)
263
+ expect(row.baseUi.length, row.id).toBeGreaterThan(0)
264
+ expect(row.reads, row.id).not.toBe('')
265
+ }
266
+ })
267
+ })
268
+
269
+ describe('reading a class past its variants', () => {
270
+ it('keeps brackets whole', () => {
271
+ expect(utilityOf('[&_pre]:overflow-x-auto')).toBe('overflow-x-auto')
272
+ expect(utilityOf('md:hover:overflow-auto')).toBe('overflow-auto')
273
+ expect(utilityOf('[&:not(pre)>code]:overflow-y-scroll')).toBe('overflow-y-scroll')
274
+ expect(utilityOf('!overflow-auto')).toBe('overflow-auto')
275
+ })
276
+ })