@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,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
|
+
}
|
package/stories/Choosing.mdx
CHANGED
|
@@ -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
|
|