@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,593 @@
1
+ import type { Rule } from 'eslint'
2
+ import { ESCAPE_MESSAGES, isEscaped } from './escape'
3
+
4
+ /**
5
+ * Behaviour the package already owns, rebuilt by hand in an app (UIG-8, "forbid
6
+ * the reach"). A hand-made dropdown, dialog, list or scrolling box does not look
7
+ * wrong in review; it shows itself when somebody uses a keyboard, or has 81
8
+ * folders. Every message names the part that already does it.
9
+ *
10
+ * What it reads is derived from the package source: which `@base-ui/react` part
11
+ * each component imports, and so what it owns (`OWNED_BEHAVIOURS`, printed in
12
+ * docs/GATES.md and reused by UIG-12's registry). `no-rebuilt-behaviour.test.ts`
13
+ * holds the tables to the source, so a new part cannot be added without them.
14
+ *
15
+ * What it deliberately does not read, and why:
16
+ *
17
+ * - **A box that should scroll and does not.** A class rule finds the scrolling
18
+ * box built by hand; it cannot find the one that was never built. Peek's
19
+ * Folders column (fixed in peek 3dc663b) had no overflow class at all. Only a
20
+ * check that opens the page can find that.
21
+ * - **Enter and Escape** compared by hand. In a field or an editor they are
22
+ * typing, and `Form` and `EditableText` own the ones that send or cancel.
23
+ * Escape and every other key taken for the whole page are read, through the
24
+ * listener.
25
+ * - **aria-expanded, aria-pressed and the other states** written on a package
26
+ * component: the component is already the package's (UIG-14's usage rules).
27
+ * - **Measuring arithmetic**, for the reason UIG-5 gave: `Popover` takes a
28
+ * rectangle to hang from, and a rule that guessed at arithmetic would report it.
29
+ * - A listener on a variable that happens to hold the document.
30
+ *
31
+ * A place that keeps one says why on the line above: `// @estiva-escape: <why>`.
32
+ * An escape above a statement, an element or an object property covers what the
33
+ * rule found there.
34
+ */
35
+
36
+ /** The part a message names, and where one behaviour has more owners, the others after it. `null`: no part yet. */
37
+ export interface OwnerPart {
38
+ use: string
39
+ more?: string
40
+ }
41
+
42
+ /**
43
+ * Every Base UI module, and the package component built on it. Read from the
44
+ * package's imports (`src/*.tsx`); the test fails if a component imports a
45
+ * module this does not name, or names a component that does not import it.
46
+ * A module no component uses has no part yet, and neither do Base UI's utilities.
47
+ */
48
+ export const BASE_UI_PARTS: Record<string, OwnerPart | null> = {
49
+ 'alert-dialog': { use: 'DialogShell', more: ' To ask yes or no, `ConfirmDialog`.' },
50
+ autocomplete: { use: 'CommandPalette' },
51
+ avatar: { use: 'Avatar' },
52
+ button: { use: 'Button', more: ' Icon only, `IconButton`.' },
53
+ checkbox: { use: 'Checkbox' },
54
+ collapsible: { use: 'CollapsibleSection' },
55
+ combobox: { use: 'ChipInput' },
56
+ dialog: { use: 'DialogShell', more: ' To search and act, `CommandPalette`.' },
57
+ field: { use: 'Field' },
58
+ fieldset: { use: 'Form' },
59
+ form: { use: 'Form' },
60
+ input: { use: 'TextInput', more: ' For a search, `SearchInput`.' },
61
+ menu: { use: 'Menu' },
62
+ popover: { use: 'Popover' },
63
+ 'preview-card': { use: 'PreviewCard' },
64
+ progress: { use: 'ProgressBar' },
65
+ 'scroll-area': { use: 'ScrollArea' },
66
+ select: { use: 'Select' },
67
+ separator: { use: 'Divider' },
68
+ tabs: { use: 'Tabs' },
69
+ toast: { use: 'Toast' },
70
+ toggle: { use: 'Reaction' },
71
+ toolbar: { use: 'Toolbar' },
72
+ tooltip: { use: 'Tooltip', more: ' Around a control, `WithTooltip`.' },
73
+ accordion: null,
74
+ 'checkbox-group': null,
75
+ 'context-menu': null,
76
+ drawer: null,
77
+ menubar: null,
78
+ meter: null,
79
+ 'navigation-menu': null,
80
+ 'number-field': null,
81
+ 'otp-field': null,
82
+ radio: null,
83
+ 'radio-group': null,
84
+ slider: null,
85
+ switch: null,
86
+ 'toggle-group': null,
87
+ }
88
+
89
+ const LIST_PART: OwnerPart = {
90
+ use: 'Select',
91
+ more: ' To pick several, `ChipInput`; to search and act, `CommandPalette`; to tick several in a list, `Checkbox` with `row`.',
92
+ }
93
+
94
+ const MESSAGE_PART: OwnerPart = { use: 'FieldLine', more: ' For a notice, `Banner`; for a message that comes and goes, `Toast`.' }
95
+
96
+ /**
97
+ * A role written by hand, and the part that sets it. Base UI's parts set most of
98
+ * these; `tooltip` (Base UI's Tooltip sets none), `alert`, `status` and `link` are
99
+ * the package's own. A role Base UI has a part for and this package has not got
100
+ * yet is `null`. Not here, on purpose: `img`, `group`, `presentation`, `none`,
101
+ * `region` and every landmark — they describe, they do not behave.
102
+ */
103
+ export const ROLE_PARTS: Record<string, { thing: string; part: OwnerPart | null }> = {
104
+ dialog: { thing: 'dialog', part: { use: 'DialogShell', more: ' To ask yes or no, `ConfirmDialog`.' } },
105
+ alertdialog: { thing: 'dialog', part: { use: 'ConfirmDialog' } },
106
+ menu: { thing: 'menu', part: { use: 'Menu' } },
107
+ menuitem: { thing: 'menu', part: { use: 'Menu' } },
108
+ menuitemcheckbox: { thing: 'menu', part: { use: 'Menu' } },
109
+ menuitemradio: { thing: 'menu', part: { use: 'Menu' } },
110
+ listbox: { thing: 'list', part: LIST_PART },
111
+ option: { thing: 'list', part: LIST_PART },
112
+ combobox: { thing: 'list', part: LIST_PART },
113
+ tablist: { thing: 'set of tabs', part: { use: 'Tabs' } },
114
+ tab: { thing: 'set of tabs', part: { use: 'Tabs' } },
115
+ tabpanel: { thing: 'set of tabs', part: { use: 'Tabs' } },
116
+ toolbar: { thing: 'toolbar', part: { use: 'Toolbar' } },
117
+ progressbar: { thing: 'progress bar', part: { use: 'ProgressBar' } },
118
+ checkbox: { thing: 'tick box', part: { use: 'Checkbox' } },
119
+ separator: { thing: 'divider', part: { use: 'Divider' } },
120
+ button: { thing: 'button', part: { use: 'Button', more: ' Icon only, `IconButton`.' } },
121
+ link: { thing: 'link', part: { use: 'Link' } },
122
+ tooltip: { thing: 'tooltip', part: { use: 'Tooltip', more: ' Around a control, `WithTooltip`.' } },
123
+ alert: { thing: 'message', part: MESSAGE_PART },
124
+ status: { thing: 'message', part: MESSAGE_PART },
125
+ menubar: { thing: 'menu bar', part: null },
126
+ switch: { thing: 'switch', part: null },
127
+ radio: { thing: 'radio button', part: null },
128
+ radiogroup: { thing: 'radio group', part: null },
129
+ slider: { thing: 'slider', part: null },
130
+ spinbutton: { thing: 'number field', part: null },
131
+ meter: { thing: 'meter', part: null },
132
+ }
133
+
134
+ /** The keys a list, a menu, tabs and a toolbar move with. Enter and Escape are not here (see above). */
135
+ export const WALKING_KEYS = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End', 'PageUp', 'PageDown']
136
+
137
+ /**
138
+ * A listener on the whole page, by what it is for. The events Base UI's own
139
+ * parts listen to on the document (useDismiss, FloatingFocusManager, the
140
+ * Positioner's autoUpdate): a floating part closes, holds focus and follows its
141
+ * anchor with these. `focus`, `blur`, `visibilitychange` and `storage` are not
142
+ * here: an app refreshing when the window comes back is not a floating part.
143
+ */
144
+ export const PAGE_EVENTS: Record<string, 'press' | 'key' | 'focus' | 'follow'> = {
145
+ mousedown: 'press',
146
+ pointerdown: 'press',
147
+ click: 'press',
148
+ touchstart: 'press',
149
+ keydown: 'key',
150
+ keyup: 'key',
151
+ keypress: 'key',
152
+ focusin: 'focus',
153
+ focusout: 'focus',
154
+ scroll: 'follow',
155
+ resize: 'follow',
156
+ }
157
+
158
+ /** Elements that are reachable with Tab by themselves; `tabIndex` on these is not a Tab stop made by hand. */
159
+ export const INTERACTIVE_ELEMENTS = new Set(['a', 'button', 'input', 'select', 'textarea', 'summary', 'iframe', 'embed', 'object', 'audio', 'video'])
160
+
161
+ /** `overflow-auto`, `overflow-y-scroll`…: a box that scrolls with the browser's scrollbar. */
162
+ export const SCROLL_CLASS = /^overflow(?:-[xy])?-(?:auto|scroll)$/
163
+
164
+ /** One row of the enumeration (UIG-8's acceptance; the shape UIG-12's registry reads). */
165
+ export interface OwnedBehaviour {
166
+ id: string
167
+ /** What a person notices the part doing. */
168
+ behaviour: string
169
+ /** The Base UI parts that do it, read from their source (1.8.0). */
170
+ baseUi: string[]
171
+ /** The package components an error names. */
172
+ owners: string[]
173
+ /** What the rule reads in an app to find it rebuilt. */
174
+ reads: string
175
+ }
176
+
177
+ const uses = (parts: (OwnerPart | null)[]) => [
178
+ ...new Set(parts.flatMap((p) => (p ? [p.use, ...[...(p.more ?? '').matchAll(/`([A-Z]\w+)`/g)].map((m) => m[1])] : []))),
179
+ ]
180
+
181
+ export const OWNED_BEHAVIOURS: OwnedBehaviour[] = [
182
+ {
183
+ id: 'base-ui',
184
+ behaviour: 'Is built on Base UI',
185
+ baseUi: Object.keys(BASE_UI_PARTS),
186
+ owners: uses(Object.values(BASE_UI_PARTS)),
187
+ reads: 'an import from @base-ui/react (and the old @base-ui-components spelling)',
188
+ },
189
+ {
190
+ id: 'portal',
191
+ behaviour: 'Floats on top of the page',
192
+ baseUi: ['Dialog', 'AlertDialog', 'Popover', 'Menu', 'Select', 'Combobox', 'Autocomplete', 'Tooltip', 'PreviewCard', 'Toast'],
193
+ owners: ['DialogShell', 'ConfirmDialog', 'CommandPalette', 'Popover', 'Menu', 'Select', 'ChipInput', 'Tooltip', 'PreviewCard', 'Toast'],
194
+ reads: 'createPortal, called, or imported and never called',
195
+ },
196
+ {
197
+ id: 'press-outside',
198
+ behaviour: 'Closes on a press outside',
199
+ baseUi: ['Dialog', 'AlertDialog', 'Popover', 'Menu', 'Select', 'Combobox', 'Tooltip', 'PreviewCard'],
200
+ owners: ['DialogShell', 'Popover', 'Menu', 'Select', 'PreviewCard'],
201
+ reads: 'a mousedown, pointerdown, click or touchstart listener on window or document',
202
+ },
203
+ {
204
+ id: 'page-keys',
205
+ behaviour: 'Closes on Escape, and takes its keys, by itself',
206
+ baseUi: ['Dialog', 'AlertDialog', 'Popover', 'Menu', 'Select', 'Combobox', 'Tooltip', 'PreviewCard', 'Toast'],
207
+ owners: ['DialogShell', 'Popover', 'Menu', 'Select', 'Tabs', 'Toolbar'],
208
+ reads: 'a keydown, keyup or keypress listener on window or document',
209
+ },
210
+ {
211
+ id: 'focus',
212
+ behaviour: 'Holds focus inside while open, and gives it back',
213
+ baseUi: ['Dialog', 'AlertDialog', 'Popover', 'Menu', 'Select', 'Combobox'],
214
+ owners: ['DialogShell', 'CommandPalette'],
215
+ reads: 'a focusin or focusout listener on window or document; the Tab key compared by hand',
216
+ },
217
+ {
218
+ id: 'scroll-lock',
219
+ behaviour: 'Stops the page behind it scrolling',
220
+ baseUi: ['Dialog', 'AlertDialog'],
221
+ owners: ['DialogShell'],
222
+ reads: 'overflow written into the style of document.body or document.documentElement',
223
+ },
224
+ {
225
+ id: 'follow',
226
+ behaviour: 'Stays attached to its anchor on scroll and resize',
227
+ baseUi: ['Popover', 'Menu', 'Select', 'Combobox', 'Tooltip', 'PreviewCard', 'Toast'],
228
+ owners: ['Popover', 'Menu', 'Select', 'Tooltip', 'PreviewCard'],
229
+ reads: 'a scroll or resize listener on window or document',
230
+ },
231
+ {
232
+ id: 'walking',
233
+ behaviour: 'Moves through its items with the arrow keys',
234
+ baseUi: ['Menu', 'Select', 'Combobox', 'Autocomplete', 'Tabs', 'Toolbar'],
235
+ owners: ['Menu', 'Select', 'ChipInput', 'CommandPalette', 'Tabs', 'Toolbar'],
236
+ reads: `a key compared by hand with ${WALKING_KEYS.join(', ')}`,
237
+ },
238
+ {
239
+ id: 'role',
240
+ behaviour: 'Says what it is to assistive technology',
241
+ baseUi: ['Dialog', 'AlertDialog', 'Menu', 'Select', 'Combobox', 'Tabs', 'Toolbar', 'Progress', 'Checkbox', 'Separator', 'Button'],
242
+ owners: uses(Object.values(ROLE_PARTS).map((r) => r.part)),
243
+ reads: `role written by hand: ${Object.keys(ROLE_PARTS).join(', ')}`,
244
+ },
245
+ {
246
+ id: 'tab-stop',
247
+ behaviour: 'Is reachable with Tab',
248
+ baseUi: ['Button', 'Toggle', 'Checkbox', 'Tabs', 'Toolbar', 'ScrollArea'],
249
+ owners: ['Button', 'IconButton', 'Link'],
250
+ reads: 'tabIndex that can be 0 or more, on an element that is not a control',
251
+ },
252
+ {
253
+ id: 'scroll',
254
+ behaviour: 'Scrolls with our scrollbar',
255
+ baseUi: ['ScrollArea'],
256
+ owners: ['ScrollArea'],
257
+ reads: 'overflow-auto or overflow-scroll (x or y, behind any variant) in a string, or overflow auto or scroll in a style',
258
+ },
259
+ ]
260
+
261
+ interface Node {
262
+ type: string
263
+ loc: NonNullable<Rule.Node['loc']>
264
+ range: [number, number]
265
+ parent?: Node
266
+ [key: string]: unknown
267
+ }
268
+
269
+ const PAGE_TARGETS = new Set(['window', 'document', 'globalThis', 'self', 'document.body', 'document.documentElement', 'window.document'])
270
+
271
+ /** Where an escape for this node is written: its object property, its element, its array item, or its statement. */
272
+ function anchorOf(node: Node): Node {
273
+ if (node.type === 'FunctionDeclaration') {
274
+ return node.parent && /^Export/.test(node.parent.type) ? node.parent : node
275
+ }
276
+ let child = node
277
+ for (let p = node.parent; p; child = p, p = p.parent) {
278
+ if (p.type === 'JSXOpeningElement' || p.type === 'Property' || p.type === 'MethodDefinition' || p.type === 'PropertyDefinition') return p
279
+ if (p.type === 'ArrayExpression') return child
280
+ if (p.type !== 'BlockStatement' && /(Statement|Declaration)$/.test(p.type)) return p
281
+ }
282
+ return node
283
+ }
284
+
285
+ function enclosingFunction(node: Node): Node | undefined {
286
+ for (let p = node.parent; p; p = p.parent) {
287
+ if (p.type === 'FunctionDeclaration' || p.type === 'FunctionExpression' || p.type === 'ArrowFunctionExpression') return p
288
+ }
289
+ return undefined
290
+ }
291
+
292
+ function stringOf(node: Node | undefined | null): string | undefined {
293
+ if (!node) return undefined
294
+ if (node.type === 'Literal' && typeof node.value === 'string') return node.value
295
+ const quasis = node.quasis as { value: { cooked: string | null } }[] | undefined
296
+ const expressions = node.expressions as unknown[] | undefined
297
+ if (node.type === 'TemplateLiteral' && expressions?.length === 0) return quasis?.[0]?.value.cooked ?? undefined
298
+ return undefined
299
+ }
300
+
301
+ /** Every string a JSX attribute's value can be: `"x"`, `{'x'}`, `{a ? 'x' : 'y'}`, `{a && 'x'}`. */
302
+ function stringsOf(node: Node | undefined | null): string[] {
303
+ if (!node) return []
304
+ if (node.type === 'JSXExpressionContainer') return stringsOf(node.expression as Node)
305
+ const s = stringOf(node)
306
+ if (s !== undefined) return [s]
307
+ if (node.type === 'ConditionalExpression') return [...stringsOf(node.consequent as Node), ...stringsOf(node.alternate as Node)]
308
+ if (node.type === 'LogicalExpression') return [...stringsOf(node.left as Node), ...stringsOf(node.right as Node)]
309
+ return []
310
+ }
311
+
312
+ /** Whether a `tabIndex` value can be 0 or more, as far as the code spells it out. */
313
+ function canBeTabStop(node: Node | undefined | null): boolean {
314
+ if (!node) return false
315
+ if (node.type === 'JSXExpressionContainer') return canBeTabStop(node.expression as Node)
316
+ if (node.type === 'Literal') {
317
+ if (typeof node.value === 'number') return node.value >= 0
318
+ if (typeof node.value === 'string') return /^\s*\d+\s*$/.test(node.value)
319
+ return false
320
+ }
321
+ if (node.type === 'ConditionalExpression') return canBeTabStop(node.consequent as Node) || canBeTabStop(node.alternate as Node)
322
+ if (node.type === 'LogicalExpression') return canBeTabStop(node.left as Node) || canBeTabStop(node.right as Node)
323
+ if (node.type === 'TSAsExpression' || node.type === 'TSNonNullExpression') return canBeTabStop(node.expression as Node)
324
+ return false
325
+ }
326
+
327
+ /** A class token's utility, past its variants: `[&_pre]:overflow-x-auto` → `overflow-x-auto`. */
328
+ export function utilityOf(token: string): string {
329
+ let depth = 0
330
+ let last = -1
331
+ for (let i = 0; i < token.length; i++) {
332
+ const c = token[i]
333
+ if (c === '[') depth++
334
+ else if (c === ']') depth--
335
+ else if (c === ':' && depth === 0) last = i
336
+ }
337
+ return token.slice(last + 1).replace(/^!/, '').replace(/!$/, '')
338
+ }
339
+
340
+ /** The Base UI module an import names, or `undefined` when it is not Base UI. `''` is the package root. */
341
+ export function baseUiModule(source: string): string | undefined {
342
+ const m = /^@base-ui(?:-components)?\/([^/]+)(?:\/(.+))?$/.exec(source)
343
+ if (!m) return undefined
344
+ const [, pkg, sub] = m
345
+ return pkg === 'react' ? (sub ?? '') : `${pkg}${sub ? `/${sub}` : ''}`
346
+ }
347
+
348
+ function isKeySubject(node: Node | undefined): boolean {
349
+ if (!node) return false
350
+ if (node.type === 'Identifier') return node.name === 'key' || node.name === 'code'
351
+ if (node.type !== 'MemberExpression' || node.computed) return false
352
+ const property = node.property as Node
353
+ return property.type === 'Identifier' && (property.name === 'key' || property.name === 'code')
354
+ }
355
+
356
+ function pageTarget(node: Node, text: string): boolean {
357
+ return (node.type === 'Identifier' || node.type === 'MemberExpression') && PAGE_TARGETS.has(text)
358
+ }
359
+
360
+ export const noRebuiltBehaviour: Rule.RuleModule = {
361
+ meta: {
362
+ type: 'problem',
363
+ docs: { description: 'Behaviour @estiva-app/ui already owns, rebuilt by hand in an app' },
364
+ schema: [],
365
+ messages: {
366
+ baseUi: 'Only @estiva-app/ui imports Base UI (`{{module}}`). Use `{{use}}` from @estiva-app/ui.{{more}}',
367
+ baseUiNoPart:
368
+ 'Only @estiva-app/ui imports Base UI (`{{module}}`), and it has no part built on it yet. Do not build one here: ask Katerina, and it gets made in @estiva-app/ui.',
369
+ portal:
370
+ 'A layer put on top of the page by hand (`createPortal`). Every floating part of @estiva-app/ui carries its own: `DialogShell` for a dialog, `Popover` for a panel, `Menu`, `Select`, `Tooltip`, `PreviewCard`, `Toast`.',
371
+ press:
372
+ 'A `{{event}}` listener on `{{target}}`: closing on a press outside, by hand. `Popover`, `Menu`, `Select`, `DialogShell` and `PreviewCard` from @estiva-app/ui close themselves.',
373
+ key: 'A `{{event}}` listener on `{{target}}`: keys taken for the whole page, by hand. `DialogShell`, `Popover`, `Menu` and `Select` from @estiva-app/ui close on Escape themselves; `Menu`, `Select`, `Tabs` and `Toolbar` move with the arrow keys.',
374
+ focus:
375
+ 'A `{{event}}` listener on `{{target}}`: focus held by hand. `DialogShell` and `CommandPalette` from @estiva-app/ui keep focus inside and give it back.',
376
+ follow:
377
+ 'A `{{event}}` listener on `{{target}}`: following an anchor by hand. `Popover`, `Menu`, `Select`, `Tooltip` and `PreviewCard` from @estiva-app/ui stay attached to theirs.',
378
+ scrollLock: "The page's scroll locked by hand. `DialogShell` from @estiva-app/ui stops the page scrolling while it is open, and gives it back.",
379
+ walking:
380
+ 'Arrow keys handled by hand (`{{key}}`). `Menu`, `Select`, `ChipInput`, `CommandPalette`, `Tabs` and `Toolbar` from @estiva-app/ui move through their items themselves.',
381
+ tabKey: 'The Tab key handled by hand. `DialogShell` and `CommandPalette` from @estiva-app/ui keep focus inside themselves.',
382
+ role: 'A hand-written `role="{{role}}"` is a hand-made {{thing}}. Use `{{use}}` from @estiva-app/ui.{{more}}',
383
+ roleNoPart:
384
+ '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.',
385
+ tabStop:
386
+ '`tabIndex={{value}}` makes a `<{{element}}>` a Tab stop by hand. Use `Button`, `IconButton` or `Link` from @estiva-app/ui, which are reachable already.',
387
+ scrollClass: "`{{token}}` scrolls with the browser's scrollbar. Use `ScrollArea` from @estiva-app/ui, which draws ours.",
388
+ scrollStyle: "`{{style}}` scrolls with the browser's scrollbar. Use `ScrollArea` from @estiva-app/ui, which draws ours.",
389
+ ...ESCAPE_MESSAGES,
390
+ },
391
+ },
392
+ create(context) {
393
+ const { sourceCode } = context
394
+ const text = (node: Node) => sourceCode.getText(node as unknown as Rule.Node)
395
+
396
+ // One escape covers what the rule found at its anchor, and is counted once.
397
+ const escapes = new Map<Node, boolean>()
398
+ const escaped = (anchor: Node) => {
399
+ if (!escapes.has(anchor)) escapes.set(anchor, isEscaped(context, anchor))
400
+ return escapes.get(anchor) === true
401
+ }
402
+ const seen = new Set<string>()
403
+ const report = (anchor: Node, at: Node, messageId: string, data: Record<string, string> = {}, once = `${at.range[0]}`) => {
404
+ const key = `${anchor.range[0]}|${messageId}|${once}`
405
+ if (seen.has(key)) return
406
+ seen.add(key)
407
+ if (escaped(anchor)) return
408
+ context.report({ loc: at.loc, messageId, data })
409
+ }
410
+
411
+ const importSource = (node: Node, source: Node | undefined | null) => {
412
+ const value = stringOf(source)
413
+ if (value === undefined) return
414
+ const module = baseUiModule(value)
415
+ if (module === undefined) return
416
+ const part = Object.hasOwn(BASE_UI_PARTS, module) ? BASE_UI_PARTS[module] : null
417
+ const anchor = anchorOf(node)
418
+ if (part) report(anchor, node, 'baseUi', { module: value, use: part.use, more: part.more ?? '' })
419
+ else report(anchor, node, 'baseUiNoPart', { module: value })
420
+ }
421
+
422
+ const keyCompared = (node: Node, key: string | undefined) => {
423
+ if (key === undefined) return
424
+ const fn = enclosingFunction(node)
425
+ const anchor = anchorOf(fn ?? node)
426
+ if (WALKING_KEYS.includes(key)) report(anchor, node, 'walking', { key }, `walking@${fn?.range[0] ?? node.range[0]}`)
427
+ else if (key === 'Tab') report(anchor, node, 'tabKey', {}, `tab@${fn?.range[0] ?? node.range[0]}`)
428
+ }
429
+
430
+ const classTokens = (node: Node, value: string) => {
431
+ for (const token of value.split(/\s+/)) {
432
+ if (token && SCROLL_CLASS.test(utilityOf(token))) report(anchorOf(node), node, 'scrollClass', { token }, `class@${token}`)
433
+ }
434
+ }
435
+
436
+ // `createPortal` imported: reported where it is called, or at the import when it never is.
437
+ const portalImports = new Map<string, Node>()
438
+ let portalCalled = false
439
+
440
+ return {
441
+ ImportDeclaration(ruleNode: Rule.Node) {
442
+ const node = ruleNode as unknown as Node
443
+ importSource(node, node.source as Node)
444
+ if (stringOf(node.source as Node) !== 'react-dom') return
445
+ for (const specifier of node.specifiers as Node[]) {
446
+ const imported = specifier.imported as Node | undefined
447
+ if (specifier.type === 'ImportSpecifier' && imported?.name === 'createPortal') portalImports.set((specifier.local as Node).name as string, node)
448
+ }
449
+ },
450
+ ExportNamedDeclaration(ruleNode: Rule.Node) {
451
+ const node = ruleNode as unknown as Node
452
+ if (node.source) importSource(node, node.source as Node)
453
+ },
454
+ ExportAllDeclaration(ruleNode: Rule.Node) {
455
+ const node = ruleNode as unknown as Node
456
+ importSource(node, node.source as Node)
457
+ },
458
+ ImportExpression(ruleNode: Rule.Node) {
459
+ const node = ruleNode as unknown as Node
460
+ importSource(node, node.source as Node)
461
+ },
462
+
463
+ CallExpression(ruleNode: Rule.Node) {
464
+ const node = ruleNode as unknown as Node
465
+ const callee = node.callee as Node
466
+ const args = node.arguments as Node[]
467
+
468
+ if (callee.type === 'Identifier' && callee.name === 'require') importSource(node, args[0])
469
+
470
+ if (callee.type === 'Identifier' && portalImports.has(callee.name as string)) {
471
+ portalCalled = true
472
+ report(anchorOf(node), node, 'portal')
473
+ }
474
+ if (callee.type !== 'MemberExpression') return
475
+ const object = callee.object as Node
476
+ const property = callee.property as Node
477
+ if (property.type !== 'Identifier') return
478
+
479
+ if (property.name === 'createPortal') {
480
+ report(anchorOf(node), node, 'portal')
481
+ return
482
+ }
483
+
484
+ if (property.name === 'addEventListener') {
485
+ const target = text(object)
486
+ if (!pageTarget(object, target)) return
487
+ const event = stringOf(args[0])
488
+ const group = event === undefined ? undefined : PAGE_EVENTS[event]
489
+ if (!event || !group) return
490
+ report(anchorOf(node), node, group, { event, target })
491
+ return
492
+ }
493
+
494
+ // ['ArrowUp', 'ArrowDown'].includes(event.key)
495
+ if (property.name === 'includes' && object.type === 'ArrayExpression' && isKeySubject(args[0])) {
496
+ for (const element of object.elements as Node[]) keyCompared(node, stringOf(element))
497
+ }
498
+ },
499
+
500
+ AssignmentExpression(ruleNode: Rule.Node) {
501
+ const node = ruleNode as unknown as Node
502
+ const left = node.left as Node
503
+ if (left.type !== 'MemberExpression') return
504
+ const property = left.property as Node
505
+ const object = left.object as Node
506
+ const handler = property.type === 'Identifier' ? /^on([a-z]+)$/.exec(property.name as string) : null
507
+ if (handler && pageTarget(object, text(object)) && PAGE_EVENTS[handler[1]]) {
508
+ report(anchorOf(node), node, PAGE_EVENTS[handler[1]], { event: handler[1], target: text(object) })
509
+ return
510
+ }
511
+ if (/^document\.(?:body|documentElement)\.style\.overflow[XY]?$/.test(text(left))) report(anchorOf(node), node, 'scrollLock')
512
+ },
513
+
514
+ BinaryExpression(ruleNode: Rule.Node) {
515
+ const node = ruleNode as unknown as Node
516
+ if (!['===', '==', '!==', '!='].includes(node.operator as string)) return
517
+ const left = node.left as Node
518
+ const right = node.right as Node
519
+ if (isKeySubject(left)) keyCompared(node, stringOf(right))
520
+ else if (isKeySubject(right)) keyCompared(node, stringOf(left))
521
+ },
522
+
523
+ SwitchCase(ruleNode: Rule.Node) {
524
+ const node = ruleNode as unknown as Node
525
+ const statement = node.parent as Node
526
+ if (node.test && isKeySubject(statement.discriminant as Node)) keyCompared(node, stringOf(node.test as Node))
527
+ },
528
+
529
+ JSXAttribute(ruleNode: Rule.Node) {
530
+ const node = ruleNode as unknown as Node
531
+ const nameNode = node.name as Node
532
+ if (nameNode.type !== 'JSXIdentifier') return
533
+ const name = nameNode.name as string
534
+ const element = node.parent as Node
535
+ const value = node.value as Node | null
536
+
537
+ if (name === 'role') {
538
+ for (const role of stringsOf(value).flatMap((v) => v.trim().split(/\s+/))) {
539
+ if (!Object.hasOwn(ROLE_PARTS, role)) continue
540
+ const { thing, part } = ROLE_PARTS[role]
541
+ if (part) report(element, node, 'role', { role, thing, use: part.use, more: part.more ?? '' })
542
+ else report(element, node, 'roleNoPart', { role, thing })
543
+ return
544
+ }
545
+ return
546
+ }
547
+
548
+ if (name === 'tabIndex' || name === 'tabindex') {
549
+ const tag = element.name as Node
550
+ if (tag.type !== 'JSXIdentifier' || !/^[a-z]/.test(tag.name as string) || INTERACTIVE_ELEMENTS.has(tag.name as string)) return
551
+ const attributes = element.attributes as Node[]
552
+ const editable = attributes.some((a) => a.type === 'JSXAttribute' && /^contenteditable$/i.test(((a.name as Node).name as string) ?? ''))
553
+ if (editable || !canBeTabStop(value)) return
554
+ report(element, node, 'tabStop', { value: value ? text(value).replace(/^\{|\}$/g, '') : '', element: tag.name as string })
555
+ return
556
+ }
557
+
558
+ if (name === 'style' && value?.type === 'JSXExpressionContainer') {
559
+ const object = value.expression as Node
560
+ if (object.type !== 'ObjectExpression') return
561
+ for (const property of object.properties as Node[]) {
562
+ if (property.type !== 'Property') continue
563
+ const key = property.key as Node
564
+ const keyName = key.type === 'Identifier' ? (key.name as string) : stringOf(key)
565
+ if (!keyName || !/^overflow[XY]?$/.test(keyName)) continue
566
+ const setting = stringOf(property.value as Node)
567
+ if (setting === 'auto' || setting === 'scroll') report(element, property, 'scrollStyle', { style: `${keyName}: '${setting}'` }, `style@${keyName}`)
568
+ }
569
+ }
570
+ },
571
+
572
+ Literal(ruleNode: Rule.Node) {
573
+ const node = ruleNode as unknown as Node
574
+ if (typeof node.value !== 'string') return
575
+ const parent = node.parent
576
+ if (parent && /^(Import|Export)/.test(parent.type)) return
577
+ if (parent?.type === 'TSLiteralType' || parent?.type === 'TSExternalModuleReference') return
578
+ classTokens(node, node.value)
579
+ },
580
+
581
+ TemplateElement(ruleNode: Rule.Node) {
582
+ const node = ruleNode as unknown as Node
583
+ const cooked = (node.value as { cooked: string | null }).cooked
584
+ if (cooked && node.parent) classTokens(node.parent, cooked)
585
+ },
586
+
587
+ 'Program:exit'() {
588
+ if (portalCalled) return
589
+ for (const declaration of new Set(portalImports.values())) report(anchorOf(declaration), declaration, 'portal')
590
+ },
591
+ }
592
+ },
593
+ }
@@ -28,7 +28,7 @@ fork freely, owe nothing back, mention it on the package's ticket.
28
28
  | Yes or no, with words beside it that toggle it too | **Checkbox** `label` | words written beside it by hand — they name nothing and toggle nothing |
29
29
  | Switching between views of one thing | **Tabs** | a row of Buttons |
30
30
  | View options behind a `…` | **Menu**, `selected` on the current row | Select |
31
- | Rows of checkboxes that stay open | a list in a **DialogShell** | a Menu — a Checkbox inside a `menuitem` is invalid HTML |
31
+ | Rows of checkboxes that stay open | **Checkbox** `row`, in a list in a **DialogShell** | a Menu — a Checkbox inside a `menuitem` is invalid HTML; a row with `role="option"` drawn by hand |
32
32
 
33
33
  ## Typing
34
34