@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.
- package/README.md +13 -5
- package/dist/Checkbox.d.ts +16 -1
- package/dist/Checkbox.d.ts.map +1 -1
- package/dist/ScrollArea.d.ts +6 -0
- package/dist/ScrollArea.d.ts.map +1 -1
- package/dist/eslint/index.d.ts +2 -0
- package/dist/eslint/index.d.ts.map +1 -1
- package/dist/eslint/index.js +459 -2
- package/dist/eslint/index.js.map +4 -4
- package/dist/eslint/no-rebuilt-behaviour.d.ts +88 -0
- package/dist/eslint/no-rebuilt-behaviour.d.ts.map +1 -0
- package/dist/index.js +10 -2
- package/dist/index.js.map +2 -2
- package/package.json +1 -1
- package/src/Checkbox.mdx +20 -1
- package/src/Checkbox.stories.tsx +44 -0
- package/src/Checkbox.test.tsx +63 -0
- package/src/Checkbox.tsx +30 -1
- package/src/ScrollArea.stories.tsx +18 -0
- package/src/ScrollArea.tsx +7 -1
- package/src/eslint/index.test.ts +31 -7
- package/src/eslint/index.ts +10 -3
- package/src/eslint/no-rebuilt-behaviour.test.ts +276 -0
- package/src/eslint/no-rebuilt-behaviour.ts +593 -0
- package/stories/Choosing.mdx +1 -1
|
@@ -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
|
+
})
|