@estiva-app/ui 0.18.0 → 0.20.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/AttachmentCard.d.ts.map +1 -1
- package/dist/Breadcrumb.d.ts.map +1 -1
- package/dist/Button.d.ts +6 -1
- package/dist/Button.d.ts.map +1 -1
- package/dist/Card.d.ts +6 -1
- package/dist/Card.d.ts.map +1 -1
- package/dist/Checkbox.d.ts +16 -1
- package/dist/Checkbox.d.ts.map +1 -1
- package/dist/DialogShell.d.ts.map +1 -1
- package/dist/Divider.d.ts +7 -0
- package/dist/Divider.d.ts.map +1 -1
- package/dist/IconButton.d.ts +12 -2
- package/dist/IconButton.d.ts.map +1 -1
- package/dist/IdentityMenu.d.ts.map +1 -1
- package/dist/Link.d.ts +6 -1
- package/dist/Link.d.ts.map +1 -1
- package/dist/Menu.d.ts.map +1 -1
- package/dist/Popover.d.ts.map +1 -1
- package/dist/ReactionPicker.d.ts.map +1 -1
- package/dist/ScrollArea.d.ts +6 -0
- package/dist/ScrollArea.d.ts.map +1 -1
- package/dist/SectionLabel.d.ts +6 -1
- package/dist/SectionLabel.d.ts.map +1 -1
- package/dist/Tooltip.d.ts.map +1 -1
- package/dist/eslint/index.d.ts +4 -0
- package/dist/eslint/index.d.ts.map +1 -1
- package/dist/eslint/index.js +968 -3
- 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/eslint/no-restyled-part.d.ts +81 -0
- package/dist/eslint/no-restyled-part.d.ts.map +1 -0
- package/dist/index.js +300 -268
- package/dist/index.js.map +4 -4
- package/package.json +1 -1
- package/src/AttachmentCard.tsx +7 -2
- package/src/Banner.tsx +2 -2
- package/src/Breadcrumb.tsx +9 -4
- package/src/Button.mdx +7 -0
- package/src/Button.stories.tsx +4 -1
- package/src/Button.tsx +10 -2
- package/src/Card.mdx +7 -0
- package/src/Card.stories.tsx +11 -0
- package/src/Card.tsx +8 -0
- 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/CommandPalette.tsx +1 -1
- package/src/ConfirmDialog.tsx +2 -2
- package/src/DialogShell.tsx +6 -7
- package/src/Divider.mdx +3 -0
- package/src/Divider.tsx +12 -2
- package/src/EditableText.mdx +5 -0
- package/src/IconButton.mdx +9 -0
- package/src/IconButton.stories.tsx +10 -1
- package/src/IconButton.tsx +22 -3
- package/src/IdentityMenu.tsx +4 -1
- package/src/Link.mdx +6 -0
- package/src/Link.stories.tsx +12 -0
- package/src/Link.tsx +7 -2
- package/src/Menu.stories.tsx +1 -1
- package/src/Menu.test.tsx +2 -2
- package/src/Menu.tsx +6 -3
- package/src/Person.stories.tsx +5 -5
- package/src/Popover.test.tsx +2 -2
- package/src/Popover.tsx +3 -2
- package/src/PreviewCard.tsx +1 -1
- package/src/Property.stories.tsx +2 -2
- package/src/ReactionPicker.tsx +6 -3
- package/src/ScrollArea.stories.tsx +38 -6
- package/src/ScrollArea.tsx +7 -1
- package/src/SectionLabel.mdx +3 -2
- package/src/SectionLabel.stories.tsx +3 -0
- package/src/SectionLabel.tsx +7 -2
- package/src/Tooltip.tsx +12 -14
- package/src/eslint/index.test.ts +58 -9
- package/src/eslint/index.ts +19 -3
- package/src/eslint/no-rebuilt-behaviour.test.ts +276 -0
- package/src/eslint/no-rebuilt-behaviour.ts +593 -0
- package/src/eslint/no-restyled-part.test.ts +301 -0
- package/src/eslint/no-restyled-part.ts +647 -0
- package/stories/Choosing.mdx +1 -1
|
@@ -0,0 +1,647 @@
|
|
|
1
|
+
import { existsSync, readFileSync, statSync } from 'node:fs'
|
|
2
|
+
import { dirname, join, resolve } from 'node:path'
|
|
3
|
+
import type { Rule, Scope } from 'eslint'
|
|
4
|
+
import { ESCAPE_MESSAGES, isEscaped } from './escape'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A part of the package is placed from outside, never restyled (UIG-9; Katerina's
|
|
8
|
+
* ruling B8 of 13 September, "only position and spacing pass into our
|
|
9
|
+
* components").
|
|
10
|
+
*
|
|
11
|
+
* Every part takes `className`, and some take a second class prop for an inner
|
|
12
|
+
* box (`contentClassName`, `bodyClassName`…). Through either, an app could push
|
|
13
|
+
* a colour, a text size or a border into a part and quietly undo it: the token
|
|
14
|
+
* lint only asks whether a class is a real token, never whether the part should
|
|
15
|
+
* take it. This rule reads every class passed into a part and lets through only
|
|
16
|
+
* placement — where the part sits and how much room it takes.
|
|
17
|
+
*
|
|
18
|
+
* **Which elements are parts: by where they come from, never by name.** No list
|
|
19
|
+
* of names is kept, so a part added to the package later is covered the day it
|
|
20
|
+
* is added.
|
|
21
|
+
*
|
|
22
|
+
* - In an app: anything imported from `@estiva-app/ui`, and anything imported
|
|
23
|
+
* from the app's own files that is one of those — a re-export (Peek's
|
|
24
|
+
* `components/ui/Button.tsx`), a wrapper that hands its props on to a part
|
|
25
|
+
* (`{...rest}`), or a component that hands its own `className` on to a part
|
|
26
|
+
* (Peek's `ConversationCard` into `Card`). Relative imports and the apps' `@/`
|
|
27
|
+
* alias are followed; `@/` is the `src` folder beside the nearest
|
|
28
|
+
* `package.json`.
|
|
29
|
+
* - Inside the package (Katerina, 17 September): a name `src/index.ts` exports,
|
|
30
|
+
* imported from a sibling file or declared in the same file.
|
|
31
|
+
*
|
|
32
|
+
* **What it reads:** a string, a template, `cn()`/`clsx()` and their arguments,
|
|
33
|
+
* both sides of a condition, a `const` in the same file and the values of a
|
|
34
|
+
* class map read from one. A class the code works out while it runs passes: the
|
|
35
|
+
* rule cannot read it.
|
|
36
|
+
*
|
|
37
|
+
* `EmptyState` takes no padding either (Katerina's ruling 6 of 14 September):
|
|
38
|
+
* its room comes from the box its rows live in.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------- the classes
|
|
42
|
+
|
|
43
|
+
/** Where a part sits and how much room it takes. Read on the utility, after its variants. */
|
|
44
|
+
export const PLACEMENT: { what: string; pattern: RegExp }[] = [
|
|
45
|
+
{ what: 'space around', pattern: /^m[xytrblse]?-/ },
|
|
46
|
+
{ what: 'space inside', pattern: /^p[xytrblse]?-/ },
|
|
47
|
+
{ what: 'width and height', pattern: /^(?:w|h|size|min-w|min-h|max-w|max-h)-/ },
|
|
48
|
+
{ what: 'shown, and how it lays out', pattern: /^(?:hidden|block|inline|inline-block|inline-flex|inline-grid|flex|grid|contents|flow-root)$/ },
|
|
49
|
+
{ what: 'its place in a row or a grid', pattern: /^(?:grow|shrink)$|^(?:flex|basis|grow|shrink|order|gap|gap-x|gap-y|space-x|space-y|col|row|grid-cols|grid-rows|grid-flow|auto-cols|auto-rows)-/ },
|
|
50
|
+
{ what: 'alignment', pattern: /^(?:self|justify|justify-items|justify-self|items|place-content|place-items|place-self)-|^content-(?!\[)/ },
|
|
51
|
+
{ what: 'position', pattern: /^(?:static|relative|absolute|fixed|sticky)$|^(?:inset|inset-x|inset-y|top|right|bottom|left|start|end|z)-/ },
|
|
52
|
+
// Draws nothing: it names the part, so what is inside can answer its hover (`group-hover:`).
|
|
53
|
+
{ what: 'a name for hover', pattern: /^(?:group|peer)(?:\/[\w-]+)?$/ },
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* A variant that draws another box or reaches inside the part: `before:`,
|
|
58
|
+
* `after:`, `[&>*]:`, `*:`… A placement class behind one of these is not the
|
|
59
|
+
* part's placement.
|
|
60
|
+
*/
|
|
61
|
+
const REACHING_VARIANT = /^\[|^(?:before|after|placeholder|file|marker|selection|first-letter|first-line|backdrop|\*|\*\*)$/
|
|
62
|
+
|
|
63
|
+
/** A class list's variants and its utility: `sm:[&>*]:shrink-0` → `['sm', '[&>*]']`, `shrink-0`. */
|
|
64
|
+
export function splitClass(token: string): { variants: string[]; utility: string } {
|
|
65
|
+
const variants: string[] = []
|
|
66
|
+
let current = ''
|
|
67
|
+
let depth = 0
|
|
68
|
+
for (const character of token) {
|
|
69
|
+
if (character === '[') depth += 1
|
|
70
|
+
if (character === ']') depth -= 1
|
|
71
|
+
if (character === ':' && depth === 0) {
|
|
72
|
+
variants.push(current)
|
|
73
|
+
current = ''
|
|
74
|
+
} else current += character
|
|
75
|
+
}
|
|
76
|
+
return { variants, utility: current.replace(/^!/, '').replace(/!$/, '').replace(/^-/, '') }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Whether a class only places the part. */
|
|
80
|
+
export function isPlacement(token: string): boolean {
|
|
81
|
+
const { variants, utility } = splitClass(token)
|
|
82
|
+
if (variants.some((variant) => REACHING_VARIANT.test(variant))) return false
|
|
83
|
+
return PLACEMENT.some(({ pattern }) => pattern.test(utility))
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const isPadding = (token: string) => {
|
|
87
|
+
const { variants, utility } = splitClass(token)
|
|
88
|
+
return !variants.some((variant) => REACHING_VARIANT.test(variant)) && /^p[xytrblse]?-/.test(utility)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The props a part has for how it looks, named in the message. Held to the
|
|
93
|
+
* package's source by `no-restyled-part.test.ts`: every prop here exists, and
|
|
94
|
+
* every look prop an exported part has is here. A part missing from this table
|
|
95
|
+
* has none, and the message says to ask for one.
|
|
96
|
+
*/
|
|
97
|
+
export const PART_LOOK_PROPS: Record<string, string[]> = {
|
|
98
|
+
AppShell: ['variant'],
|
|
99
|
+
Avatar: ['size'],
|
|
100
|
+
AvatarGroup: ['size'],
|
|
101
|
+
Banner: ['tone'],
|
|
102
|
+
Button: ['variant', 'size'],
|
|
103
|
+
Card: ['fill', 'hover', 'attention', 'selected', 'active', 'hovered', 'quietUntilHover', 'unreadable', 'clip'],
|
|
104
|
+
Checkbox: ['row'],
|
|
105
|
+
Chip: ['type'],
|
|
106
|
+
Divider: ['tone', 'orientation'],
|
|
107
|
+
EmptyState: ['scope'],
|
|
108
|
+
FieldLine: ['tone'],
|
|
109
|
+
IconButton: ['variant', 'pressed', 'glow'],
|
|
110
|
+
InputChip: ['truncate'],
|
|
111
|
+
Link: ['variant', 'truncate'],
|
|
112
|
+
MenuItem: ['size', 'selected', 'destructive'],
|
|
113
|
+
MenuSub: ['selected'],
|
|
114
|
+
NavItem: ['active'],
|
|
115
|
+
Person: ['size'],
|
|
116
|
+
ProgressBar: ['variant'],
|
|
117
|
+
Property: ['layout'],
|
|
118
|
+
RailItem: ['active'],
|
|
119
|
+
Reaction: ['pressed'],
|
|
120
|
+
ReactionPicker: ['surface'],
|
|
121
|
+
SectionLabel: ['tone'],
|
|
122
|
+
Select: ['size'],
|
|
123
|
+
Tabs: ['size'],
|
|
124
|
+
TextInput: ['size'],
|
|
125
|
+
Toast: ['type'],
|
|
126
|
+
Toolbar: ['surface'],
|
|
127
|
+
ToolbarButton: ['variant', 'pressed', 'glow'],
|
|
128
|
+
TopBar: ['variant'],
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The class props of a part: `className`, and the ones for an inner box. */
|
|
132
|
+
const CLASS_PROP = /^(?:className|[a-z][A-Za-z]*ClassName)$/
|
|
133
|
+
|
|
134
|
+
// ---------------------------------------------------------------- the nodes
|
|
135
|
+
|
|
136
|
+
interface Node {
|
|
137
|
+
type: string
|
|
138
|
+
range: [number, number]
|
|
139
|
+
loc?: NonNullable<Rule.Node['loc']>
|
|
140
|
+
parent?: Node
|
|
141
|
+
[key: string]: unknown
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const child = (node: Node, key: string) => node[key] as Node | null | undefined
|
|
145
|
+
const children = (node: Node, key: string) => (node[key] as (Node | null)[] | undefined) ?? []
|
|
146
|
+
const nameOf = (node: Node | null | undefined): string | undefined =>
|
|
147
|
+
node?.type === 'Identifier' || node?.type === 'JSXIdentifier' ? (node.name as string) : node?.type === 'Literal' && typeof node.value === 'string' ? node.value : undefined
|
|
148
|
+
|
|
149
|
+
/** Walk every node under `root`, skipping the parser's bookkeeping keys. */
|
|
150
|
+
function walk(root: Node, visit: (node: Node) => void): void {
|
|
151
|
+
const stack: Node[] = [root]
|
|
152
|
+
while (stack.length) {
|
|
153
|
+
const node = stack.pop() as Node
|
|
154
|
+
visit(node)
|
|
155
|
+
for (const key of Object.keys(node)) {
|
|
156
|
+
if (key === 'parent' || key === 'loc' || key === 'range' || key === 'tokens' || key === 'comments') continue
|
|
157
|
+
const value = node[key]
|
|
158
|
+
if (Array.isArray(value)) for (const item of value) { if (item && typeof (item as Node).type === 'string') stack.push(item as Node) }
|
|
159
|
+
else if (value && typeof (value as Node).type === 'string') stack.push(value as Node)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ---------------------------------------------------------------- the parts
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* What a name is to this rule: the part it draws, and which of the caller's
|
|
168
|
+
* class props reach which of the part's (`all` for a part, a re-export or a
|
|
169
|
+
* wrapper; a map for a component that hands on its own `className`). `via` is
|
|
170
|
+
* the app's own component in between, for the message.
|
|
171
|
+
*/
|
|
172
|
+
export interface PartBinding {
|
|
173
|
+
part: string
|
|
174
|
+
props: 'all' | Record<string, string>
|
|
175
|
+
via?: string
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
interface Parser {
|
|
179
|
+
parseForESLint?: (code: string, options: unknown) => { ast: Node }
|
|
180
|
+
parse?: (code: string, options: unknown) => Node
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
interface ModuleFacts {
|
|
184
|
+
mtime: number
|
|
185
|
+
exports: Map<string, PartBinding>
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const PACKAGE = '@estiva-app/ui'
|
|
189
|
+
const moduleCache = new Map<string, ModuleFacts>()
|
|
190
|
+
const packageDirCache = new Map<string, string | null>()
|
|
191
|
+
const packagePartsCache = new Map<string, { mtime: number; parts: Set<string> }>()
|
|
192
|
+
|
|
193
|
+
function parseFile(parser: Parser, filePath: string): Node | null {
|
|
194
|
+
const code = readFileSync(filePath, 'utf8')
|
|
195
|
+
const options = { filePath, ecmaVersion: 'latest', sourceType: 'module', ecmaFeatures: { jsx: filePath.endsWith('x') }, range: true, loc: true }
|
|
196
|
+
try {
|
|
197
|
+
if (parser.parseForESLint) return parser.parseForESLint(code, options).ast
|
|
198
|
+
if (parser.parse) return parser.parse(code, options)
|
|
199
|
+
} catch {
|
|
200
|
+
// A file that does not parse is its own lint's business; it hands on no parts.
|
|
201
|
+
}
|
|
202
|
+
return null
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** The nearest folder holding a `package.json`, from a file. */
|
|
206
|
+
function packageDirOf(file: string): string | null {
|
|
207
|
+
const start = dirname(resolve(file))
|
|
208
|
+
if (packageDirCache.has(start)) return packageDirCache.get(start) ?? null
|
|
209
|
+
let dir = start
|
|
210
|
+
for (;;) {
|
|
211
|
+
if (existsSync(join(dir, 'package.json'))) break
|
|
212
|
+
const up = dirname(dir)
|
|
213
|
+
if (up === dir) {
|
|
214
|
+
dir = ''
|
|
215
|
+
break
|
|
216
|
+
}
|
|
217
|
+
dir = up
|
|
218
|
+
}
|
|
219
|
+
packageDirCache.set(start, dir || null)
|
|
220
|
+
return dir || null
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function resolveModule(fromFile: string, specifier: string): string | null {
|
|
224
|
+
let base: string
|
|
225
|
+
if (specifier.startsWith('.')) base = resolve(dirname(fromFile), specifier)
|
|
226
|
+
else if (specifier.startsWith('@/')) {
|
|
227
|
+
const dir = packageDirOf(fromFile)
|
|
228
|
+
if (!dir) return null
|
|
229
|
+
base = join(dir, 'src', specifier.slice(2))
|
|
230
|
+
} else return null
|
|
231
|
+
for (const candidate of [base, `${base}.tsx`, `${base}.ts`, join(base, 'index.tsx'), join(base, 'index.ts')]) {
|
|
232
|
+
if (existsSync(candidate) && statSync(candidate).isFile()) return candidate
|
|
233
|
+
}
|
|
234
|
+
return null
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** The names this package's `src/index.ts` exports, when the file lives in `@estiva-app/ui` itself. */
|
|
238
|
+
function packagePartsFor(file: string, parser: Parser): Set<string> | null {
|
|
239
|
+
const dir = packageDirOf(file)
|
|
240
|
+
if (!dir) return null
|
|
241
|
+
let name: string | undefined
|
|
242
|
+
try {
|
|
243
|
+
name = (JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) as { name?: string }).name
|
|
244
|
+
} catch {
|
|
245
|
+
return null
|
|
246
|
+
}
|
|
247
|
+
if (name !== PACKAGE) return null
|
|
248
|
+
const index = join(dir, 'src', 'index.ts')
|
|
249
|
+
if (!existsSync(index)) return null
|
|
250
|
+
const mtime = statSync(index).mtimeMs
|
|
251
|
+
const cached = packagePartsCache.get(index)
|
|
252
|
+
if (cached && cached.mtime === mtime) return cached.parts
|
|
253
|
+
const parts = new Set<string>()
|
|
254
|
+
const ast = parseFile(parser, index)
|
|
255
|
+
for (const statement of ast ? children(ast, 'body') : []) {
|
|
256
|
+
if (statement?.type !== 'ExportNamedDeclaration' || statement.exportKind === 'type') continue
|
|
257
|
+
for (const specifier of children(statement, 'specifiers')) {
|
|
258
|
+
const exported = nameOf(child(specifier as Node, 'exported'))
|
|
259
|
+
if (specifier?.exportKind !== 'type' && exported && /^[A-Z][a-z]/.test(exported)) parts.add(exported)
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
packagePartsCache.set(index, { mtime, parts })
|
|
263
|
+
return parts
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** A function component declared in a module: its name, and the function. */
|
|
267
|
+
function functionComponents(program: Node): Map<string, Node> {
|
|
268
|
+
const found = new Map<string, Node>()
|
|
269
|
+
const unwrap = (node: Node | null | undefined): Node | null => {
|
|
270
|
+
let current = node
|
|
271
|
+
while (current?.type === 'CallExpression' && children(current, 'arguments').length) {
|
|
272
|
+
const callee = child(current, 'callee')
|
|
273
|
+
const text = callee?.type === 'Identifier' ? nameOf(callee) : callee?.type === 'MemberExpression' ? nameOf(child(callee, 'property')) : undefined
|
|
274
|
+
if (text !== 'forwardRef' && text !== 'memo') return null
|
|
275
|
+
current = children(current, 'arguments')[0]
|
|
276
|
+
}
|
|
277
|
+
return current?.type === 'ArrowFunctionExpression' || current?.type === 'FunctionExpression' ? current : null
|
|
278
|
+
}
|
|
279
|
+
walk(program, (node) => {
|
|
280
|
+
if (node.type === 'FunctionDeclaration') {
|
|
281
|
+
const name = nameOf(child(node, 'id'))
|
|
282
|
+
if (name && /^[A-Z]/.test(name)) found.set(name, node)
|
|
283
|
+
} else if (node.type === 'VariableDeclarator') {
|
|
284
|
+
const name = nameOf(child(node, 'id'))
|
|
285
|
+
const fn = unwrap(child(node, 'init'))
|
|
286
|
+
if (name && /^[A-Z]/.test(name) && fn) found.set(name, fn)
|
|
287
|
+
}
|
|
288
|
+
})
|
|
289
|
+
return found
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* The parts a module's names stand for. `ownFile` is the file being linted: its
|
|
294
|
+
* imports are read the same way, so a wrapper declared beside its use counts.
|
|
295
|
+
*/
|
|
296
|
+
function moduleParts(file: string, program: Node, parser: Parser, stack: Set<string>): { locals: Map<string, PartBinding | 'namespace'>; exports: Map<string, PartBinding> } {
|
|
297
|
+
const locals = new Map<string, PartBinding | 'namespace'>()
|
|
298
|
+
const packageParts = packagePartsFor(file, parser)
|
|
299
|
+
const body = children(program, 'body')
|
|
300
|
+
|
|
301
|
+
const exportsOf = (specifier: string): Map<string, PartBinding> | 'package' | null => {
|
|
302
|
+
if (specifier === PACKAGE) return 'package'
|
|
303
|
+
const target = resolveModule(file, specifier)
|
|
304
|
+
if (!target) return null
|
|
305
|
+
if (packageParts) return 'package'
|
|
306
|
+
return moduleExports(target, parser, stack)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
for (const statement of body) {
|
|
310
|
+
if (statement?.type !== 'ImportDeclaration' || statement.importKind === 'type') continue
|
|
311
|
+
const source = exportsOf(String((child(statement, 'source') as Node).value))
|
|
312
|
+
if (!source) continue
|
|
313
|
+
for (const specifier of children(statement, 'specifiers')) {
|
|
314
|
+
if (!specifier || specifier.importKind === 'type') continue
|
|
315
|
+
const local = nameOf(child(specifier, 'local'))
|
|
316
|
+
if (!local) continue
|
|
317
|
+
if (specifier.type === 'ImportNamespaceSpecifier') {
|
|
318
|
+
if (source === 'package' && !packageParts) locals.set(local, 'namespace')
|
|
319
|
+
continue
|
|
320
|
+
}
|
|
321
|
+
const imported = specifier.type === 'ImportDefaultSpecifier' ? 'default' : nameOf(child(specifier, 'imported'))
|
|
322
|
+
if (!imported) continue
|
|
323
|
+
if (source === 'package') {
|
|
324
|
+
const isPart = packageParts ? packageParts.has(imported) : /^[A-Z]/.test(imported)
|
|
325
|
+
if (isPart) locals.set(local, { part: imported, props: 'all' })
|
|
326
|
+
} else if (source.has(imported)) locals.set(local, source.get(imported) as PartBinding)
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Inside the package, a part used in the file that declares it.
|
|
331
|
+
const components = functionComponents(program)
|
|
332
|
+
if (packageParts) for (const name of components.keys()) if (packageParts.has(name) && !locals.has(name)) locals.set(name, { part: name, props: 'all' })
|
|
333
|
+
|
|
334
|
+
// In an app, a component that hands its props, or its own className, on to a part.
|
|
335
|
+
if (!packageParts) {
|
|
336
|
+
let changed = true
|
|
337
|
+
for (let round = 0; changed && round < 10; round += 1) {
|
|
338
|
+
changed = false
|
|
339
|
+
for (const [name, fn] of components) {
|
|
340
|
+
const binding = handsOnTo(fn, (tag) => bindingOfTag(tag, locals))
|
|
341
|
+
if (!binding) continue
|
|
342
|
+
const next = { ...binding, via: name }
|
|
343
|
+
const previous = locals.get(name)
|
|
344
|
+
if (!previous || JSON.stringify(previous) !== JSON.stringify(next)) {
|
|
345
|
+
if (previous && previous !== 'namespace' && !previous.via) continue
|
|
346
|
+
locals.set(name, next)
|
|
347
|
+
changed = true
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const exports = new Map<string, PartBinding>()
|
|
354
|
+
const localBinding = (name: string | undefined) => {
|
|
355
|
+
const binding = name ? locals.get(name) : undefined
|
|
356
|
+
return binding && binding !== 'namespace' ? binding : undefined
|
|
357
|
+
}
|
|
358
|
+
for (const statement of body) {
|
|
359
|
+
if (!statement) continue
|
|
360
|
+
if (statement.type === 'ExportNamedDeclaration') {
|
|
361
|
+
const declaration = child(statement, 'declaration')
|
|
362
|
+
if (declaration?.type === 'FunctionDeclaration') {
|
|
363
|
+
const name = nameOf(child(declaration, 'id'))
|
|
364
|
+
const binding = localBinding(name)
|
|
365
|
+
if (name && binding) exports.set(name, binding)
|
|
366
|
+
} else if (declaration?.type === 'VariableDeclaration') {
|
|
367
|
+
for (const declarator of children(declaration, 'declarations')) {
|
|
368
|
+
const name = nameOf(child(declarator as Node, 'id'))
|
|
369
|
+
const init = child(declarator as Node, 'init')
|
|
370
|
+
const binding = localBinding(name) ?? (init?.type === 'Identifier' ? localBinding(nameOf(init)) : undefined)
|
|
371
|
+
if (name && binding) exports.set(name, binding)
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
const source = child(statement, 'source')
|
|
375
|
+
const from = source ? exportsOf(String(source.value)) : null
|
|
376
|
+
for (const specifier of children(statement, 'specifiers')) {
|
|
377
|
+
if (!specifier || specifier.exportKind === 'type') continue
|
|
378
|
+
const local = nameOf(child(specifier, 'local'))
|
|
379
|
+
const exported = nameOf(child(specifier, 'exported'))
|
|
380
|
+
if (!local || !exported) continue
|
|
381
|
+
if (!source) {
|
|
382
|
+
const binding = localBinding(local)
|
|
383
|
+
if (binding) exports.set(exported, binding)
|
|
384
|
+
} else if (from === 'package') {
|
|
385
|
+
if (/^[A-Z]/.test(local)) exports.set(exported, { part: local, props: 'all' })
|
|
386
|
+
} else if (from?.has(local)) exports.set(exported, from.get(local) as PartBinding)
|
|
387
|
+
}
|
|
388
|
+
} else if (statement.type === 'ExportAllDeclaration' && !child(statement, 'exported')) {
|
|
389
|
+
const from = exportsOf(String((child(statement, 'source') as Node).value))
|
|
390
|
+
if (from && from !== 'package') for (const [name, binding] of from) exports.set(name, binding)
|
|
391
|
+
} else if (statement.type === 'ExportDefaultDeclaration') {
|
|
392
|
+
const declaration = child(statement, 'declaration')
|
|
393
|
+
const name = declaration?.type === 'Identifier' ? nameOf(declaration) : nameOf(child(declaration as Node, 'id'))
|
|
394
|
+
const binding = localBinding(name)
|
|
395
|
+
if (binding) exports.set('default', binding)
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
return { locals, exports }
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function moduleExports(file: string, parser: Parser, stack: Set<string>): Map<string, PartBinding> {
|
|
402
|
+
const mtime = statSync(file).mtimeMs
|
|
403
|
+
const cached = moduleCache.get(file)
|
|
404
|
+
if (cached && cached.mtime === mtime) return cached.exports
|
|
405
|
+
if (stack.has(file)) return new Map()
|
|
406
|
+
stack.add(file)
|
|
407
|
+
const program = parseFile(parser, file)
|
|
408
|
+
const exports = program ? moduleParts(file, program, parser, stack).exports : new Map<string, PartBinding>()
|
|
409
|
+
stack.delete(file)
|
|
410
|
+
moduleCache.set(file, { mtime, exports })
|
|
411
|
+
return exports
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function bindingOfTag(tag: Node | null | undefined, locals: Map<string, PartBinding | 'namespace'>): PartBinding | undefined {
|
|
415
|
+
if (!tag) return undefined
|
|
416
|
+
if (tag.type === 'JSXIdentifier') {
|
|
417
|
+
const binding = locals.get(tag.name as string)
|
|
418
|
+
return binding && binding !== 'namespace' ? binding : undefined
|
|
419
|
+
}
|
|
420
|
+
if (tag.type === 'JSXMemberExpression') {
|
|
421
|
+
const object = child(tag, 'object')
|
|
422
|
+
const property = nameOf(child(tag, 'property'))
|
|
423
|
+
if (object?.type === 'JSXIdentifier' && locals.get(object.name as string) === 'namespace' && property) return { part: property, props: 'all' }
|
|
424
|
+
}
|
|
425
|
+
return undefined
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Whether a function component hands its props on to a part: its rest or its
|
|
430
|
+
* whole props spread onto one (a wrapper), or its own class props passed into
|
|
431
|
+
* one's class props (a component that places a part by its caller's word).
|
|
432
|
+
*/
|
|
433
|
+
function handsOnTo(fn: Node, bindingOf: (tag: Node | null | undefined) => PartBinding | undefined): PartBinding | undefined {
|
|
434
|
+
const param = children(fn, 'params')[0]
|
|
435
|
+
if (!param) return undefined
|
|
436
|
+
let restName: string | undefined
|
|
437
|
+
let propsName: string | undefined
|
|
438
|
+
const classParams = new Map<string, string>()
|
|
439
|
+
if (param.type === 'ObjectPattern') {
|
|
440
|
+
for (const property of children(param, 'properties')) {
|
|
441
|
+
if (!property) continue
|
|
442
|
+
if (property.type === 'RestElement') restName = nameOf(child(property, 'argument'))
|
|
443
|
+
else if (property.type === 'Property') {
|
|
444
|
+
const key = nameOf(child(property, 'key'))
|
|
445
|
+
let value = child(property, 'value')
|
|
446
|
+
if (value?.type === 'AssignmentPattern') value = child(value, 'left')
|
|
447
|
+
const local = nameOf(value)
|
|
448
|
+
if (key && local && CLASS_PROP.test(key)) classParams.set(local, key)
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
} else if (param.type === 'Identifier') propsName = nameOf(param)
|
|
452
|
+
|
|
453
|
+
let wrapper: PartBinding | undefined
|
|
454
|
+
const forwarded: { part?: string; props: Record<string, string> } = { props: {} }
|
|
455
|
+
const body = child(fn, 'body')
|
|
456
|
+
if (!body) return undefined
|
|
457
|
+
walk(body, (node) => {
|
|
458
|
+
if (wrapper || node.type !== 'JSXOpeningElement') return
|
|
459
|
+
const binding = bindingOf(child(node, 'name'))
|
|
460
|
+
if (!binding) return
|
|
461
|
+
for (const attribute of children(node, 'attributes')) {
|
|
462
|
+
if (!attribute) continue
|
|
463
|
+
if (attribute.type === 'JSXSpreadAttribute') {
|
|
464
|
+
const argument = nameOf(child(attribute, 'argument'))
|
|
465
|
+
if (argument && (argument === restName || argument === propsName)) wrapper = { part: binding.part, props: binding.props }
|
|
466
|
+
continue
|
|
467
|
+
}
|
|
468
|
+
const prop = nameOf(child(attribute, 'name'))
|
|
469
|
+
if (!prop || !CLASS_PROP.test(prop)) continue
|
|
470
|
+
const partProp = binding.props === 'all' ? prop : binding.props[prop]
|
|
471
|
+
if (!partProp) continue
|
|
472
|
+
walk(attribute, (inner) => {
|
|
473
|
+
let callerProp: string | undefined
|
|
474
|
+
if (inner.type === 'Identifier') callerProp = classParams.get(inner.name as string)
|
|
475
|
+
if (inner.type === 'MemberExpression' && nameOf(child(inner, 'object')) === propsName) {
|
|
476
|
+
const key = nameOf(child(inner, 'property'))
|
|
477
|
+
if (key && CLASS_PROP.test(key)) callerProp = key
|
|
478
|
+
}
|
|
479
|
+
if (!callerProp) return
|
|
480
|
+
if (forwarded.part && forwarded.part !== binding.part) return
|
|
481
|
+
forwarded.part = binding.part
|
|
482
|
+
forwarded.props[callerProp] = partProp
|
|
483
|
+
})
|
|
484
|
+
}
|
|
485
|
+
})
|
|
486
|
+
if (wrapper) return wrapper
|
|
487
|
+
return forwarded.part ? { part: forwarded.part, props: forwarded.props } : undefined
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// ---------------------------------------------------------------- reading a class list
|
|
491
|
+
|
|
492
|
+
const CLASS_FUNCTIONS = new Set(['cn', 'clsx', 'cx', 'twMerge', 'classNames'])
|
|
493
|
+
|
|
494
|
+
/** The `const` a name stands for in this file, if it is one. */
|
|
495
|
+
function constantOf(identifier: Node, scope: Scope.Scope | null): Node | undefined {
|
|
496
|
+
let current = scope
|
|
497
|
+
while (current) {
|
|
498
|
+
const variable = current.set.get(identifier.name as string)
|
|
499
|
+
if (variable) {
|
|
500
|
+
const definition = variable.defs[0] as unknown as { type: string; node: Node; parent?: Node } | undefined
|
|
501
|
+
if (variable.defs.length !== 1 || definition?.type !== 'Variable') return undefined
|
|
502
|
+
if ((definition.parent as Node | undefined)?.kind !== 'const') return undefined
|
|
503
|
+
return child(definition.node, 'init') ?? undefined
|
|
504
|
+
}
|
|
505
|
+
current = current.upper
|
|
506
|
+
}
|
|
507
|
+
return undefined
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Every class the code spells out for a value, as far as it can be read in this
|
|
512
|
+
* file. `asMap` reads an object as a class map (its values) rather than as a
|
|
513
|
+
* `clsx` object (its keys).
|
|
514
|
+
*/
|
|
515
|
+
export function classesOf(value: Node | null | undefined, scopeOf: (node: Node) => Scope.Scope | null, asMap = false, seen = new Set<Node>()): string[] {
|
|
516
|
+
if (!value || seen.has(value) || seen.size > 200) return []
|
|
517
|
+
seen.add(value)
|
|
518
|
+
const read = (node: Node | null | undefined, map = false) => classesOf(node, scopeOf, map, seen)
|
|
519
|
+
const words = (text: unknown) => (typeof text === 'string' ? text.split(/\s+/).filter(Boolean) : [])
|
|
520
|
+
switch (value.type) {
|
|
521
|
+
case 'Literal':
|
|
522
|
+
return words(value.value)
|
|
523
|
+
case 'TemplateLiteral':
|
|
524
|
+
return [...children(value, 'quasis').flatMap((quasi) => words((quasi?.value as { cooked?: string }).cooked)), ...children(value, 'expressions').flatMap((e) => read(e))]
|
|
525
|
+
case 'JSXExpressionContainer':
|
|
526
|
+
case 'TSAsExpression':
|
|
527
|
+
case 'TSSatisfiesExpression':
|
|
528
|
+
case 'TSNonNullExpression':
|
|
529
|
+
case 'ChainExpression':
|
|
530
|
+
return read(child(value, 'expression'), asMap)
|
|
531
|
+
case 'ConditionalExpression':
|
|
532
|
+
return [...read(child(value, 'consequent')), ...read(child(value, 'alternate'))]
|
|
533
|
+
case 'LogicalExpression':
|
|
534
|
+
return value.operator === '&&' ? read(child(value, 'right')) : [...read(child(value, 'left')), ...read(child(value, 'right'))]
|
|
535
|
+
case 'BinaryExpression':
|
|
536
|
+
return value.operator === '+' ? [...read(child(value, 'left')), ...read(child(value, 'right'))] : []
|
|
537
|
+
case 'ArrayExpression':
|
|
538
|
+
return children(value, 'elements').flatMap((e) => read(e))
|
|
539
|
+
case 'ObjectExpression':
|
|
540
|
+
return children(value, 'properties').flatMap((property) => {
|
|
541
|
+
if (property?.type !== 'Property') return []
|
|
542
|
+
return asMap ? read(child(property, 'value'), true) : property.computed ? [] : words(nameOf(child(property, 'key')))
|
|
543
|
+
})
|
|
544
|
+
case 'CallExpression': {
|
|
545
|
+
const callee = child(value, 'callee')
|
|
546
|
+
return callee?.type === 'Identifier' && CLASS_FUNCTIONS.has(callee.name as string) ? children(value, 'arguments').flatMap((a) => read(a)) : []
|
|
547
|
+
}
|
|
548
|
+
case 'Identifier': {
|
|
549
|
+
const constant = constantOf(value, scopeOf(value))
|
|
550
|
+
return constant ? read(constant, asMap) : []
|
|
551
|
+
}
|
|
552
|
+
case 'MemberExpression': {
|
|
553
|
+
const object = child(value, 'object')
|
|
554
|
+
const target = object?.type === 'Identifier' ? constantOf(object, scopeOf(object)) : object
|
|
555
|
+
const map = target?.type === 'TSAsExpression' || target?.type === 'TSSatisfiesExpression' ? child(target, 'expression') : target
|
|
556
|
+
if (map?.type !== 'ObjectExpression') return []
|
|
557
|
+
const property = child(value, 'property')
|
|
558
|
+
const key = value.computed ? (property?.type === 'Literal' ? String(property.value) : undefined) : nameOf(property)
|
|
559
|
+
return children(map, 'properties').flatMap((entry) => {
|
|
560
|
+
if (entry?.type !== 'Property') return []
|
|
561
|
+
if (key !== undefined && nameOf(child(entry, 'key')) !== key) return []
|
|
562
|
+
return read(child(entry, 'value'), true)
|
|
563
|
+
})
|
|
564
|
+
}
|
|
565
|
+
default:
|
|
566
|
+
return []
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// ---------------------------------------------------------------- the rule
|
|
571
|
+
|
|
572
|
+
const code = (items: string[]) => items.map((item) => `\`${item}\``).join(' ')
|
|
573
|
+
const or = (items: string[]) => (items.length === 1 ? code(items) : `${items.slice(0, -1).map((i) => `\`${i}\``).join(', ')} or \`${items[items.length - 1]}\``)
|
|
574
|
+
|
|
575
|
+
export const noRestyledPart: Rule.RuleModule = {
|
|
576
|
+
meta: {
|
|
577
|
+
type: 'problem',
|
|
578
|
+
docs: { description: 'Only placement passes into a part of @estiva-app/ui through a class prop' },
|
|
579
|
+
schema: [],
|
|
580
|
+
messages: {
|
|
581
|
+
restyled:
|
|
582
|
+
'{{classes}} on {{where}} changes how it looks. A part of @estiva-app/ui is placed from outside, never restyled: only space, size, flex and grid, and position pass in.{{use}} A look for what is around it goes on your own element around it.',
|
|
583
|
+
emptyStatePadding:
|
|
584
|
+
'{{classes}} pads {{where}}. EmptyState takes no padding (Katerina, 14 September): its room comes from the box its rows live in, so put the padding on that box.',
|
|
585
|
+
...ESCAPE_MESSAGES,
|
|
586
|
+
},
|
|
587
|
+
},
|
|
588
|
+
create(context) {
|
|
589
|
+
const parser = (context.languageOptions?.parser ?? {}) as Parser
|
|
590
|
+
const file = context.filename
|
|
591
|
+
const sourceCode = context.sourceCode
|
|
592
|
+
const scopeOf = (node: Node) => sourceCode.getScope(node as unknown as Parameters<typeof sourceCode.getScope>[0])
|
|
593
|
+
let locals: Map<string, PartBinding | 'namespace'> | undefined
|
|
594
|
+
const escapes = new Map<Node, boolean>()
|
|
595
|
+
const escaped = (anchor: Node) => {
|
|
596
|
+
if (!escapes.has(anchor)) escapes.set(anchor, isEscaped(context, anchor))
|
|
597
|
+
return escapes.get(anchor) === true
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
const check = (element: Node, binding: PartBinding, prop: string, value: Node | null | undefined, at: Node) => {
|
|
601
|
+
const partProp = binding.props === 'all' ? prop : binding.props[prop]
|
|
602
|
+
if (!partProp || !value) return
|
|
603
|
+
const classes = classesOf(value, scopeOf)
|
|
604
|
+
const handedOn = binding.props === 'all' ? 'its props' : `its \`${prop}\``
|
|
605
|
+
const via = binding.via && binding.via !== binding.part ? `\`${binding.via}\` (it hands ${handedOn} to \`${binding.part}\`)` : `\`${binding.part}\``
|
|
606
|
+
const where = `${via}${partProp === 'className' ? '' : `'s \`${partProp}\``}`
|
|
607
|
+
const restyled = [...new Set(classes.filter((token) => !isPlacement(token)))]
|
|
608
|
+
const padding = binding.part === 'EmptyState' && binding.props === 'all' ? [...new Set(classes.filter(isPadding))] : []
|
|
609
|
+
if (!restyled.length && !padding.length) return
|
|
610
|
+
if (escaped(element)) return
|
|
611
|
+
if (restyled.length) {
|
|
612
|
+
const props = PART_LOOK_PROPS[binding.part]
|
|
613
|
+
const use = props ? ` Use its ${or(props)}.` : ` \`${binding.part}\` has no prop for this yet: ask Katerina, and it gets made in @estiva-app/ui.`
|
|
614
|
+
context.report({ loc: at.loc as NonNullable<Rule.Node['loc']>, messageId: 'restyled', data: { classes: code(restyled), where, use } })
|
|
615
|
+
}
|
|
616
|
+
if (padding.length) context.report({ loc: at.loc as NonNullable<Rule.Node['loc']>, messageId: 'emptyStatePadding', data: { classes: code(padding), where } })
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
return {
|
|
620
|
+
Program(program) {
|
|
621
|
+
locals = moduleParts(file, program as unknown as Node, parser, new Set([file])).locals
|
|
622
|
+
},
|
|
623
|
+
JSXOpeningElement(ruleNode: Rule.Node) {
|
|
624
|
+
const element = ruleNode as unknown as Node
|
|
625
|
+
const binding = locals && bindingOfTag(child(element, 'name'), locals)
|
|
626
|
+
if (!binding) return
|
|
627
|
+
for (const attribute of children(element, 'attributes')) {
|
|
628
|
+
if (!attribute) continue
|
|
629
|
+
if (attribute.type === 'JSXAttribute') {
|
|
630
|
+
const prop = nameOf(child(attribute, 'name'))
|
|
631
|
+
if (prop && CLASS_PROP.test(prop)) check(element, binding, prop, child(attribute, 'value'), attribute)
|
|
632
|
+
continue
|
|
633
|
+
}
|
|
634
|
+
// `{...{ className }}`, or a `const` object spread onto the part.
|
|
635
|
+
let spread = child(attribute, 'argument')
|
|
636
|
+
if (spread?.type === 'Identifier') spread = constantOf(spread, scopeOf(spread))
|
|
637
|
+
while (spread?.type === 'TSAsExpression' || spread?.type === 'TSSatisfiesExpression') spread = child(spread, 'expression')
|
|
638
|
+
if (spread?.type !== 'ObjectExpression') continue
|
|
639
|
+
for (const property of children(spread, 'properties')) {
|
|
640
|
+
const key = property?.type === 'Property' && !property.computed ? nameOf(child(property, 'key')) : undefined
|
|
641
|
+
if (key && CLASS_PROP.test(key)) check(element, binding, key, child(property as Node, 'value'), property as Node)
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
},
|
|
645
|
+
}
|
|
646
|
+
},
|
|
647
|
+
}
|
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
|
|