@estiva-app/ui 0.35.2 → 0.37.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/dist/Panel.d.ts +44 -0
- package/dist/Panel.d.ts.map +1 -0
- package/dist/ScrollArea.d.ts +7 -2
- package/dist/ScrollArea.d.ts.map +1 -1
- package/dist/eslint/escape.d.ts +23 -6
- package/dist/eslint/escape.d.ts.map +1 -1
- package/dist/eslint/index.js +86 -12
- package/dist/eslint/index.js.map +3 -3
- package/dist/eslint/no-handmade-header.d.ts +5 -0
- package/dist/eslint/no-handmade-header.d.ts.map +1 -1
- package/dist/eslint/no-native-title.d.ts +1 -1
- package/dist/eslint/no-native-title.d.ts.map +1 -1
- package/dist/eslint/no-raw-element.d.ts +7 -0
- package/dist/eslint/no-raw-element.d.ts.map +1 -1
- package/dist/eslint/no-restyled-part.d.ts +8 -0
- package/dist/eslint/no-restyled-part.d.ts.map +1 -1
- package/dist/gates/app-checks.d.ts +1 -1
- package/dist/gates/app-checks.d.ts.map +1 -1
- package/dist/gates/{chunk-DRIHHXQK.js → chunk-CC6L4B5U.js} +23 -17
- package/dist/gates/chunk-CC6L4B5U.js.map +7 -0
- package/dist/gates/chunk-SYVKGOWM.js +1131 -0
- package/dist/gates/chunk-SYVKGOWM.js.map +7 -0
- package/dist/gates/ci.d.ts +18 -0
- package/dist/gates/ci.d.ts.map +1 -0
- package/dist/gates/cli.js +20 -3
- package/dist/gates/cli.js.map +2 -2
- package/dist/gates/create-app.d.ts.map +1 -1
- package/dist/gates/create-app.js +1 -1
- package/dist/gates/escapes.d.ts +23 -0
- package/dist/gates/escapes.d.ts.map +1 -0
- package/dist/gates/gate-config.d.ts.map +1 -1
- package/dist/gates/hook.d.ts +2 -0
- package/dist/gates/hook.d.ts.map +1 -1
- package/dist/gates/index.d.ts +5 -1
- package/dist/gates/index.d.ts.map +1 -1
- package/dist/gates/index.js +42 -185
- package/dist/gates/index.js.map +4 -4
- package/dist/gates/name-escapes.d.ts +14 -0
- package/dist/gates/name-escapes.d.ts.map +1 -0
- package/dist/gates/status.d.ts +3 -1
- package/dist/gates/status.d.ts.map +1 -1
- package/dist/gates/token-lint.d.ts.map +1 -1
- package/dist/gates/token-style.d.ts +21 -0
- package/dist/gates/token-style.d.ts.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +76 -49
- package/dist/index.js.map +4 -4
- package/dist/registry/{app-3ALZE6XO.js → app-YMABQII6.js} +3 -3
- package/dist/registry/app.d.ts.map +1 -1
- package/dist/registry/behaviours.d.ts +71 -0
- package/dist/registry/behaviours.d.ts.map +1 -0
- package/dist/registry/{build-NDXMVFQ5.js → build-3EANARLQ.js} +2 -2
- package/dist/registry/build.d.ts.map +1 -1
- package/dist/registry/{chunk-3WJ7ZZ7Z.js → chunk-EVJW26UR.js} +51 -4
- package/dist/registry/chunk-EVJW26UR.js.map +7 -0
- package/dist/registry/{chunk-XVII7P6G.js → chunk-SIB44CES.js} +276 -90
- package/dist/registry/chunk-SIB44CES.js.map +7 -0
- package/dist/registry/{chunk-5K2TRR4X.js → chunk-XK747STJ.js} +18 -3
- package/dist/registry/chunk-XK747STJ.js.map +7 -0
- package/dist/registry/cli.js +7 -6
- package/dist/registry/cli.js.map +2 -2
- package/dist/registry/contract.d.ts +13 -1
- package/dist/registry/contract.d.ts.map +1 -1
- package/dist/registry/index.d.ts +1 -1
- package/dist/registry/index.d.ts.map +1 -1
- package/dist/registry/index.js +11 -5
- package/package.json +4 -1
- package/registry.json +369 -19
- package/skill/estiva-ui.md +2 -1
- package/src/AppShell.mdx +2 -1
- package/src/AttachmentCard.tsx +7 -7
- package/src/Button.tsx +1 -1
- package/src/Card.tsx +2 -2
- package/src/ChipInput.mdx +2 -0
- package/src/ChipInput.tsx +1 -1
- package/src/CommandPalette.tsx +3 -3
- package/src/DialogShell.tsx +1 -1
- package/src/EmptyState.mdx +4 -3
- package/src/Lightbox.tsx +1 -1
- package/src/NavItem.tsx +1 -1
- package/src/Panel.mdx +78 -0
- package/src/Panel.stories.tsx +98 -0
- package/src/Panel.tsx +80 -0
- package/src/PersonTrigger.tsx +1 -1
- package/src/Reaction.tsx +1 -1
- package/src/ScrollArea.mdx +6 -2
- package/src/ScrollArea.stories.tsx +1 -1
- package/src/ScrollArea.tsx +9 -4
- package/src/Select.tsx +1 -1
- package/src/TopBar.mdx +1 -1
- package/src/eslint/escape.ts +43 -14
- package/src/eslint/has-a-page-and-a-story.test.ts +1 -1
- package/src/eslint/index.test.ts +2 -2
- package/src/eslint/no-copied-look.test.ts +1 -1
- package/src/eslint/no-hand-rolled-behaviour.test.ts +3 -3
- package/src/eslint/no-handmade-empty-state.test.ts +2 -2
- package/src/eslint/no-handmade-header.test.ts +11 -2
- package/src/eslint/no-handmade-header.ts +24 -2
- package/src/eslint/no-native-title.test.ts +2 -2
- package/src/eslint/no-native-title.ts +1 -1
- package/src/eslint/no-raw-element.test.ts +47 -15
- package/src/eslint/no-raw-element.ts +53 -3
- package/src/eslint/no-rebuilt-behaviour.test.ts +9 -9
- package/src/eslint/no-restyled-part.test.ts +21 -2
- package/src/eslint/no-restyled-part.ts +30 -0
- package/src/eslint/raw-element-outside-a-wrapper.test.ts +3 -3
- package/src/gates/app-checks.ts +9 -6
- package/src/gates/ci.ts +152 -0
- package/src/gates/cli.ts +19 -3
- package/src/gates/count.ts +1 -1
- package/src/gates/create-app.test.ts +2 -2
- package/src/gates/create-app.ts +22 -16
- package/src/gates/escapes.ts +111 -0
- package/src/gates/gate-config.ts +2 -0
- package/src/gates/gates.test.ts +86 -1
- package/src/gates/hook.ts +38 -4
- package/src/gates/index.ts +5 -1
- package/src/gates/name-escapes.ts +77 -0
- package/src/gates/status.ts +45 -2
- package/src/gates/token-lint.ts +11 -4
- package/src/gates/token-style.ts +79 -0
- package/src/how.test.ts +282 -0
- package/src/index.ts +1 -0
- package/src/pages.test.ts +6 -0
- package/src/registry/app.test.ts +37 -1
- package/src/registry/app.ts +52 -2
- package/src/registry/behaviours.ts +223 -0
- package/src/registry/build.ts +36 -0
- package/src/registry/cli.ts +5 -3
- package/src/registry/contract.test.ts +27 -2
- package/src/registry/contract.ts +28 -3
- package/src/registry/index.ts +1 -1
- package/src/registry/registry.test.ts +18 -4
- package/stories/Choosing.mdx +4 -3
- package/dist/gates/chunk-DBMUQAR6.js +0 -607
- package/dist/gates/chunk-DBMUQAR6.js.map +0 -7
- package/dist/gates/chunk-DRIHHXQK.js.map +0 -7
- package/dist/registry/chunk-3WJ7ZZ7Z.js.map +0 -7
- package/dist/registry/chunk-5K2TRR4X.js.map +0 -7
- package/dist/registry/chunk-XVII7P6G.js.map +0 -7
- /package/dist/registry/{app-3ALZE6XO.js.map → app-YMABQII6.js.map} +0 -0
- /package/dist/registry/{build-NDXMVFQ5.js.map → build-3EANARLQ.js.map} +0 -0
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a part owns, worked out from what it draws (Katerina's ruling B9,
|
|
3
|
+
* 25 September).
|
|
4
|
+
*
|
|
5
|
+
* The plan's Gate 0 asked for "the behaviours it owns — derived from which Base
|
|
6
|
+
* UI parts it imports". Until 0.36.0 only "is built on Base UI" was; the rest
|
|
7
|
+
* was UIG-8's hand list of owners, which is right for the part that does the
|
|
8
|
+
* work and silent for every part built from it: ConfirmDialog draws DialogShell,
|
|
9
|
+
* whose page says it keeps focus inside, and the catalogue listed no focus for
|
|
10
|
+
* ConfirmDialog. Every app entry listed nothing at all.
|
|
11
|
+
*
|
|
12
|
+
* Now a part owns:
|
|
13
|
+
*
|
|
14
|
+
* - what the Base UI parts it draws do, from UIG-8's enumeration (`baseUi` on
|
|
15
|
+
* each behaviour, read from Base UI's source): all of it for a whole part
|
|
16
|
+
* (`Menu.Root`), what it is for a piece (`Menu.Item`);
|
|
17
|
+
* - what the whole of the part it **is** does (floating, focus, keys, scroll):
|
|
18
|
+
* the part it hands back at its root, followed through parts inside parts, in
|
|
19
|
+
* the package and in an app. ConfirmDialog returns a DialogShell, so it keeps
|
|
20
|
+
* focus inside; a page with a menu somewhere in it does not float, and a
|
|
21
|
+
* tooltip's floating stays the tooltip's;
|
|
22
|
+
* - and, in the package, what UIG-8's list says (the floor: a part like `Link`
|
|
23
|
+
* is reachable with Tab without any Base UI in it).
|
|
24
|
+
*
|
|
25
|
+
* Read from the code: a JSX tag inside the part's own declaration, or inside a
|
|
26
|
+
* function of the same file it draws. A root on one branch of a condition
|
|
27
|
+
* still counts, so the list says what a part can be, not what one screen shows.
|
|
28
|
+
*/
|
|
29
|
+
import ts from 'typescript'
|
|
30
|
+
import { OWNED_BEHAVIOURS } from '../eslint/index'
|
|
31
|
+
import type { EntryBehaviour } from './schema'
|
|
32
|
+
|
|
33
|
+
/** `@base-ui/react`, its sub-paths, and the old `@base-ui-components/react` spelling. */
|
|
34
|
+
export const isBaseUi = (specifier: string) => /^@base-ui(?:-components)?\/react(?:\/|$)/.test(specifier)
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* What drawing a Base UI part gives, by behaviour id. The whole part (`Menu.Root`,
|
|
38
|
+
* or a part of one piece: `<Separator>`, `<Toggle>`) does all UIG-8 lists for it;
|
|
39
|
+
* a piece of one (`Menu.Item`, `Toolbar.Button`) only says what it is.
|
|
40
|
+
*/
|
|
41
|
+
export function baseUiBehaviours(part: string, piece?: string): string[] {
|
|
42
|
+
const whole = !piece || piece === 'Root' || piece === 'Provider'
|
|
43
|
+
return OWNED_BEHAVIOURS.filter((owned) => owned.id === 'base-ui' || (owned.baseUi.includes(part) && (whole || owned.id === 'role'))).map((owned) => owned.id)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* What a part hands on to the parts drawn around it: what the whole thing does.
|
|
48
|
+
* Being built on Base UI, a role and a Tab stop stay the inner part's: a banner
|
|
49
|
+
* with a button in it is not itself reachable with Tab.
|
|
50
|
+
*/
|
|
51
|
+
export const HANDED_ON = new Set(['portal', 'press-outside', 'page-keys', 'focus', 'scroll-lock', 'follow', 'scroll'])
|
|
52
|
+
|
|
53
|
+
/** A hint drawn beside a part: its floating is its own, never the part's. */
|
|
54
|
+
export const HINTS = new Set(['Tooltip', 'WithTooltip', 'TooltipProvider', 'PreviewCard'])
|
|
55
|
+
|
|
56
|
+
/** What a part drawn inside another hands on to it. */
|
|
57
|
+
export const handedOn = (name: string, owned: Iterable<string>) => (HINTS.has(name) ? [] : [...owned].filter((id) => HANDED_ON.has(id)))
|
|
58
|
+
|
|
59
|
+
/** Behaviour ids as the catalogue writes them: in UIG-8's order, each once. */
|
|
60
|
+
export function asBehaviours(ids: Iterable<string>): EntryBehaviour[] {
|
|
61
|
+
const held = new Set(ids)
|
|
62
|
+
return OWNED_BEHAVIOURS.filter((owned) => held.has(owned.id)).map((owned) => ({ id: owned.id, behaviour: owned.behaviour }))
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** A tag as written: `Dialog.Popup`, `Button`. */
|
|
66
|
+
function tagText(tag: ts.JsxTagNameExpression): string | null {
|
|
67
|
+
if (ts.isIdentifier(tag)) return tag.text
|
|
68
|
+
if (ts.isPropertyAccessExpression(tag)) {
|
|
69
|
+
const inner = tagText(tag.expression as ts.JsxTagNameExpression)
|
|
70
|
+
return inner && `${inner}.${tag.name.text}`
|
|
71
|
+
}
|
|
72
|
+
return null
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** A tag's name and its piece: `['Dialog', 'Popup']`, `['Button', undefined]`. */
|
|
76
|
+
export const splitTag = (tag: string): [string, string | undefined] => {
|
|
77
|
+
const [root, piece] = tag.split('.')
|
|
78
|
+
return [root, piece]
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Every tag drawn anywhere under `node`, as written. */
|
|
82
|
+
function tagsUnder(node: ts.Node): Set<string> {
|
|
83
|
+
const found = new Set<string>()
|
|
84
|
+
const visit = (n: ts.Node) => {
|
|
85
|
+
if (ts.isJsxOpeningElement(n) || ts.isJsxSelfClosingElement(n)) {
|
|
86
|
+
const tag = tagText(n.tagName)
|
|
87
|
+
if (tag && /^[A-Z]/.test(tag)) found.add(tag)
|
|
88
|
+
}
|
|
89
|
+
ts.forEachChild(n, visit)
|
|
90
|
+
}
|
|
91
|
+
visit(node)
|
|
92
|
+
return found
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* For each name a file declares at its top (a function, a `const`, a class):
|
|
97
|
+
* the tags drawn inside it, with the file's own functions it draws followed, so
|
|
98
|
+
* what is left are names the file imports or declares without a body.
|
|
99
|
+
*/
|
|
100
|
+
export function drawnIn(sf: ts.SourceFile): Map<string, Set<string>> {
|
|
101
|
+
const direct = new Map<string, Set<string>>()
|
|
102
|
+
for (const statement of sf.statements) {
|
|
103
|
+
if ((ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement)) && statement.name) direct.set(statement.name.text, tagsUnder(statement))
|
|
104
|
+
else if (ts.isVariableStatement(statement)) {
|
|
105
|
+
for (const d of statement.declarationList.declarations) if (ts.isIdentifier(d.name) && d.initializer) direct.set(d.name.text, tagsUnder(d.initializer))
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const closed = new Map<string, Set<string>>()
|
|
109
|
+
for (const name of direct.keys()) {
|
|
110
|
+
const out = new Set<string>()
|
|
111
|
+
const seen = new Set([name])
|
|
112
|
+
const stack = [name]
|
|
113
|
+
while (stack.length) {
|
|
114
|
+
for (const tag of direct.get(stack.pop() as string) ?? []) {
|
|
115
|
+
out.add(tag)
|
|
116
|
+
if (direct.has(tag) && !seen.has(tag)) {
|
|
117
|
+
seen.add(tag)
|
|
118
|
+
stack.push(tag)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
closed.set(name, out)
|
|
123
|
+
}
|
|
124
|
+
return closed
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The tags an expression hands back at its top: both sides of a condition, never a fragment's children. */
|
|
128
|
+
function rootsOfExpression(e: ts.Node | undefined, out: Set<string>): void {
|
|
129
|
+
if (!e) return
|
|
130
|
+
if (ts.isParenthesizedExpression(e) || ts.isAsExpression(e) || ts.isSatisfiesExpression(e) || ts.isNonNullExpression(e)) return rootsOfExpression(e.expression, out)
|
|
131
|
+
if (ts.isConditionalExpression(e)) {
|
|
132
|
+
rootsOfExpression(e.whenTrue, out)
|
|
133
|
+
rootsOfExpression(e.whenFalse, out)
|
|
134
|
+
return
|
|
135
|
+
}
|
|
136
|
+
if (ts.isBinaryExpression(e)) {
|
|
137
|
+
if (e.operatorToken.kind !== ts.SyntaxKind.AmpersandAmpersandToken) rootsOfExpression(e.left, out)
|
|
138
|
+
rootsOfExpression(e.right, out)
|
|
139
|
+
return
|
|
140
|
+
}
|
|
141
|
+
// A fragment is several things side by side (a page and the dialog it opens):
|
|
142
|
+
// the part is none of them alone.
|
|
143
|
+
if (ts.isJsxFragment(e)) return
|
|
144
|
+
const tag = ts.isJsxElement(e) ? tagText(e.openingElement.tagName) : ts.isJsxSelfClosingElement(e) ? tagText(e.tagName) : null
|
|
145
|
+
if (tag && /^[A-Z]/.test(tag)) out.add(tag)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** The tags a function hands back: every `return`, or an arrow's body, never a nested function's. */
|
|
149
|
+
function rootsOfFunction(fn: ts.Node): Set<string> {
|
|
150
|
+
const out = new Set<string>()
|
|
151
|
+
if (ts.isArrowFunction(fn) && !ts.isBlock(fn.body)) rootsOfExpression(fn.body, out)
|
|
152
|
+
const visit = (n: ts.Node) => {
|
|
153
|
+
if (n !== fn && (ts.isFunctionLike(n) || ts.isClassLike(n))) return
|
|
154
|
+
if (ts.isReturnStatement(n)) rootsOfExpression(n.expression, out)
|
|
155
|
+
ts.forEachChild(n, visit)
|
|
156
|
+
}
|
|
157
|
+
visit(fn)
|
|
158
|
+
return out
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** The function a declaration's value is: itself, or the one inside `forwardRef(…)`, `memo(…)`. */
|
|
162
|
+
function functionOf(node: ts.Node | undefined): ts.Node | undefined {
|
|
163
|
+
if (!node) return undefined
|
|
164
|
+
if (ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node) || ts.isArrowFunction(node)) return node
|
|
165
|
+
if (ts.isParenthesizedExpression(node) || ts.isAsExpression(node) || ts.isSatisfiesExpression(node)) return functionOf(node.expression)
|
|
166
|
+
if (ts.isCallExpression(node)) for (const argument of node.arguments) {
|
|
167
|
+
const fn = functionOf(argument)
|
|
168
|
+
if (fn) return fn
|
|
169
|
+
}
|
|
170
|
+
return undefined
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* For each function a file declares at its top: the parts it **is** — the tags it
|
|
175
|
+
* hands back at its root, following the file's own functions it hands back.
|
|
176
|
+
* `ConfirmDialog` returns a `DialogShell`, so it is one; a page with a menu
|
|
177
|
+
* somewhere in it is not a menu.
|
|
178
|
+
*/
|
|
179
|
+
export function rootsIn(sf: ts.SourceFile): Map<string, Set<string>> {
|
|
180
|
+
const direct = new Map<string, Set<string>>()
|
|
181
|
+
for (const statement of sf.statements) {
|
|
182
|
+
if (ts.isFunctionDeclaration(statement) && statement.name) direct.set(statement.name.text, rootsOfFunction(statement))
|
|
183
|
+
else if (ts.isVariableStatement(statement)) {
|
|
184
|
+
for (const d of statement.declarationList.declarations) {
|
|
185
|
+
const fn = functionOf(d.initializer)
|
|
186
|
+
if (ts.isIdentifier(d.name) && fn) direct.set(d.name.text, rootsOfFunction(fn))
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
const closed = new Map<string, Set<string>>()
|
|
191
|
+
for (const name of direct.keys()) {
|
|
192
|
+
const out = new Set<string>()
|
|
193
|
+
const seen = new Set([name])
|
|
194
|
+
const stack = [name]
|
|
195
|
+
while (stack.length) {
|
|
196
|
+
for (const tag of direct.get(stack.pop() as string) ?? []) {
|
|
197
|
+
if (direct.has(tag)) {
|
|
198
|
+
if (!seen.has(tag)) {
|
|
199
|
+
seen.add(tag)
|
|
200
|
+
stack.push(tag)
|
|
201
|
+
}
|
|
202
|
+
} else out.add(tag)
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
closed.set(name, out)
|
|
206
|
+
}
|
|
207
|
+
return closed
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Each imported value name → where it came from. */
|
|
211
|
+
export function importsOf(sf: ts.SourceFile): Map<string, { specifier: string; imported: string }> {
|
|
212
|
+
const out = new Map<string, { specifier: string; imported: string }>()
|
|
213
|
+
for (const statement of sf.statements) {
|
|
214
|
+
if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier)) continue
|
|
215
|
+
const clause = statement.importClause
|
|
216
|
+
if (!clause || clause.isTypeOnly) continue
|
|
217
|
+
const specifier = statement.moduleSpecifier.text
|
|
218
|
+
if (clause.name) out.set(clause.name.text, { specifier, imported: 'default' })
|
|
219
|
+
const bindings = clause.namedBindings
|
|
220
|
+
if (bindings && ts.isNamedImports(bindings)) for (const e of bindings.elements) if (!e.isTypeOnly) out.set(e.name.text, { specifier, imported: (e.propertyName ?? e.name).text })
|
|
221
|
+
}
|
|
222
|
+
return out
|
|
223
|
+
}
|
package/src/registry/build.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
|
|
19
19
|
import { join } from 'node:path'
|
|
20
20
|
import ts from 'typescript'
|
|
21
21
|
import { OWNED_BEHAVIOURS } from '../eslint/index'
|
|
22
|
+
import { asBehaviours, baseUiBehaviours, drawnIn, handedOn, importsOf, isBaseUi, rootsIn, splitTag } from './behaviours'
|
|
22
23
|
import { looksOf, parseForLooks } from '../eslint/looks-of'
|
|
23
24
|
import { SCHEMA_VERSION, type EntryBehaviour, type EntryKind, type EntryProp, type EntryVariant, type Registry, type RegistryEntry } from './schema'
|
|
24
25
|
|
|
@@ -654,6 +655,41 @@ export function buildRegistry({ root = process.cwd(), repo = 'estiva-ui' }: Buil
|
|
|
654
655
|
|
|
655
656
|
if (problems.length) throw new Error(`the registry cannot be built:\n ${problems.join('\n ')}`)
|
|
656
657
|
|
|
658
|
+
// What each part owns, from what it draws (B9): the Base UI parts in it, and
|
|
659
|
+
// every part of the package inside it, followed; UIG-8's list is the floor.
|
|
660
|
+
const moduleByName = new Map(values.map((v) => [v.name, v.module]))
|
|
661
|
+
const drawing = new Map<string, { drawn: Map<string, Set<string>>; roots: Map<string, Set<string>>; imports: ReturnType<typeof importsOf> }>()
|
|
662
|
+
const drawingOf = (moduleName: string) => {
|
|
663
|
+
if (!drawing.has(moduleName)) {
|
|
664
|
+
const file = moduleOf(moduleName).file
|
|
665
|
+
const sf = ts.createSourceFile(file, readFileSync(join(root, file), 'utf8'), ts.ScriptTarget.Latest, true, file.endsWith('x') ? ts.ScriptKind.TSX : ts.ScriptKind.TS)
|
|
666
|
+
drawing.set(moduleName, { drawn: drawnIn(sf), roots: rootsIn(sf), imports: importsOf(sf) })
|
|
667
|
+
}
|
|
668
|
+
return drawing.get(moduleName)!
|
|
669
|
+
}
|
|
670
|
+
const owns = (name: string, seen: Set<string>): Set<string> => {
|
|
671
|
+
const out = new Set(behavioursOf(name).map((b) => b.id))
|
|
672
|
+
const moduleName = moduleByName.get(name)
|
|
673
|
+
if (!moduleName || seen.has(name)) return out
|
|
674
|
+
seen.add(name)
|
|
675
|
+
const { drawn, roots, imports } = drawingOf(moduleName)
|
|
676
|
+
// The Base UI parts it draws, anywhere in it.
|
|
677
|
+
for (const tag of drawn.get(name) ?? []) {
|
|
678
|
+
const [local, piece] = splitTag(tag)
|
|
679
|
+
const from = imports.get(local)
|
|
680
|
+
if (from && isBaseUi(from.specifier)) for (const id of baseUiBehaviours(from.imported, piece)) out.add(id)
|
|
681
|
+
}
|
|
682
|
+
// The parts it is: what it hands back at its root.
|
|
683
|
+
for (const tag of roots.get(name) ?? []) {
|
|
684
|
+
const [local] = splitTag(tag)
|
|
685
|
+
const from = imports.get(local)
|
|
686
|
+
const part = from?.specifier.startsWith('./') ? from.imported : !from && local !== name ? local : undefined
|
|
687
|
+
if (part && moduleByName.has(part)) for (const id of handedOn(part, owns(part, seen))) out.add(id)
|
|
688
|
+
}
|
|
689
|
+
return out
|
|
690
|
+
}
|
|
691
|
+
for (const entry of entries) if (entry.kind === 'component') entry.ownsBehaviours = asBehaviours(owns(entry.name, new Set()))
|
|
692
|
+
|
|
657
693
|
entries.sort((a, b) => a.name.localeCompare(b.name))
|
|
658
694
|
|
|
659
695
|
return {
|
package/src/registry/cli.ts
CHANGED
|
@@ -278,7 +278,10 @@ async function main(): Promise<number> {
|
|
|
278
278
|
const registry = await buildApp(root, value('repo'))
|
|
279
279
|
process.stdout.write(`${summary(registry)}\n`)
|
|
280
280
|
// The usage-page contract and the story links (UIG-19): the one copy, run by every app's `gate` job.
|
|
281
|
-
|
|
281
|
+
// A When not may name a part of the package or of this app (B8).
|
|
282
|
+
const pkg = readPackageRegistry()
|
|
283
|
+
const known = new Set([...pkg.entries, ...registry.entries].map((e) => e.name))
|
|
284
|
+
const broken = contractProblems(registry, root, known)
|
|
282
285
|
if (broken.length) {
|
|
283
286
|
process.stderr.write(`${broken.length === 1 ? 'a part breaks' : `${broken.length} parts break`} the usage-page contract:\n ${broken.join('\n ')}\n`)
|
|
284
287
|
} else {
|
|
@@ -291,9 +294,8 @@ async function main(): Promise<number> {
|
|
|
291
294
|
const mapped = checkStoryMap(root, registry, linked.headings)
|
|
292
295
|
// What the pages name must still be there (R14): parts under When and When not,
|
|
293
296
|
// and the stories a Seen in line sends a reader to, here or in the package.
|
|
294
|
-
const pkg = readPackageRegistry()
|
|
295
297
|
const stale = nameProblems(registry, root, {
|
|
296
|
-
known
|
|
298
|
+
known,
|
|
297
299
|
...(linked.titles ? { titles: linked.titles } : {}),
|
|
298
300
|
packageIds: pkg.entries.flatMap((e) => [e.storyId, e.docsId].filter((id): id is string => Boolean(id))),
|
|
299
301
|
})
|
|
@@ -18,7 +18,7 @@ import { tmpdir } from 'node:os'
|
|
|
18
18
|
import { dirname, join } from 'node:path'
|
|
19
19
|
import { afterAll, describe, expect, it } from 'vitest'
|
|
20
20
|
import { buildAppRegistry } from './app'
|
|
21
|
-
import { contractProblems, hasSeenIn, linkProblems, nameProblems, pageProblem } from './contract'
|
|
21
|
+
import { contractProblems, hasSeenIn, linkProblems, nameProblems, pageProblem, whenNotProblem } from './contract'
|
|
22
22
|
|
|
23
23
|
const made: string[] = []
|
|
24
24
|
afterAll(() => {
|
|
@@ -53,6 +53,8 @@ const PAGE = [
|
|
|
53
53
|
'',
|
|
54
54
|
'A word.',
|
|
55
55
|
'',
|
|
56
|
+
'No alternative: a count has only this badge.',
|
|
57
|
+
'',
|
|
56
58
|
'## How',
|
|
57
59
|
'',
|
|
58
60
|
'```tsx',
|
|
@@ -78,7 +80,7 @@ describe('a usage page', () => {
|
|
|
78
80
|
})
|
|
79
81
|
|
|
80
82
|
it('fails with its sections out of order', () => {
|
|
81
|
-
const swapped = PAGE.replace('## When not\n\nA word.\n', '').replace('## What it owns', '## When not\n\nA word.\n\n## What it owns')
|
|
83
|
+
const swapped = PAGE.replace('## When not\n\nA word.\n\nNo alternative: a count has only this badge.\n', '').replace('## What it owns', '## When not\n\nA word.\n\n## What it owns')
|
|
82
84
|
expect(pageProblem(swapped)).toMatch(/^sections out of order/)
|
|
83
85
|
})
|
|
84
86
|
|
|
@@ -93,6 +95,18 @@ describe('a usage page', () => {
|
|
|
93
95
|
expect(pageProblem(later)).toBe('no code under "How"')
|
|
94
96
|
})
|
|
95
97
|
|
|
98
|
+
// B8: When not names the part to use instead, or says there is none.
|
|
99
|
+
it('asks When not to name another real part, or to say there is none', () => {
|
|
100
|
+
const known = new Set(['Badge', 'Once', 'Chip'])
|
|
101
|
+
const whenNot = (body: string) => PAGE.replace('A word.\n\nNo alternative: a count has only this badge.', body)
|
|
102
|
+
expect(whenNotProblem(PAGE, ['Badge'], known)).toBeNull()
|
|
103
|
+
expect(whenNotProblem(whenNot('A word. That is a **Chip**.'), ['Badge'], known)).toBeNull()
|
|
104
|
+
expect(whenNotProblem(whenNot('- A word.\n\nNo alternative: a count has only this.'), ['Badge'], known)).toBeNull()
|
|
105
|
+
expect(whenNotProblem(whenNot('A word.'), ['Badge'], known)).toMatch(/^"When not" names no other part/)
|
|
106
|
+
// Its own name, a name nothing has, and "No alternative:" mid-line do not count.
|
|
107
|
+
expect(whenNotProblem(whenNot('Not **Badge** twice, nor a `Widget`. See: No alternative: here.'), ['Badge'], known)).not.toBeNull()
|
|
108
|
+
})
|
|
109
|
+
|
|
96
110
|
it('reads a Windows checkout', () => {
|
|
97
111
|
expect(pageProblem(PAGE.replace(/\n/g, '\r\n'))).toBeNull()
|
|
98
112
|
})
|
|
@@ -136,6 +150,17 @@ describe('an app against the contract', () => {
|
|
|
136
150
|
expect(contractProblems(buildAppRegistry({ root: dir, repo: 'fixture' }), dir)).toEqual([])
|
|
137
151
|
})
|
|
138
152
|
|
|
153
|
+
it("links a part with no story to its page by the page's title, and names one that links to nothing (B10)", () => {
|
|
154
|
+
const seen = PAGE.replace('A small count beside a name.\n', 'A small count beside a name.\n\n**Seen in** — *Parts/Card*, beside the title.\n')
|
|
155
|
+
const dir = app({ 'src/Badge.tsx': part('Badge'), 'src/Badge.mdx': seen })
|
|
156
|
+
const registry = buildAppRegistry({ root: dir, repo: 'fixture' })
|
|
157
|
+
expect(registry.entries.find((e) => e.name === 'Badge')).toMatchObject({ docsId: 'parts-badge--docs', storyId: null })
|
|
158
|
+
writeFileSync(join(dir, 'src/Badge.mdx'), seen.replace("<Meta title='Parts/Badge' />", '<Meta />'))
|
|
159
|
+
expect(contractProblems(buildAppRegistry({ root: dir, repo: 'fixture' }), dir)).toEqual([
|
|
160
|
+
'Badge (reusable, src/Badge.tsx) links to nothing in Storybook: give src/Badge.mdx a `<Meta title="…" />`, or give the part a story',
|
|
161
|
+
])
|
|
162
|
+
})
|
|
163
|
+
|
|
139
164
|
it("counts a story file named after the part that draws its view, from the part's own file", () => {
|
|
140
165
|
const dir = app({
|
|
141
166
|
'src/Panel.tsx': `${part('Panel')}\n/** The half that only draws. */\nexport function PanelView() {\n return <div>view</div>\n}\n`,
|
package/src/registry/contract.ts
CHANGED
|
@@ -87,7 +87,7 @@ export function linkProblems(registry: Registry, ids: ReadonlySet<string>): stri
|
|
|
87
87
|
*
|
|
88
88
|
* `root` is the app's own folder: the one its catalogue's paths are relative to.
|
|
89
89
|
*/
|
|
90
|
-
export function contractProblems(registry: Registry, root: string): string[] {
|
|
90
|
+
export function contractProblems(registry: Registry, root: string, known: ReadonlySet<string> = new Set(registry.entries.map((e) => e.name))): string[] {
|
|
91
91
|
const problems: string[] = []
|
|
92
92
|
for (const entry of registry.entries) {
|
|
93
93
|
const cls = entry.app?.class
|
|
@@ -103,9 +103,16 @@ export function contractProblems(registry: Registry, root: string): string[] {
|
|
|
103
103
|
const text = readFileSync(join(root, page), 'utf8')
|
|
104
104
|
const problem = pageProblem(text)
|
|
105
105
|
if (problem) problems.push(`${where}: ${page} has ${problem}`)
|
|
106
|
+
const whenNot = whenNotProblem(text, [entry.name], known)
|
|
107
|
+
if (whenNot) problems.push(`${where}: ${page}: ${whenNot}`)
|
|
106
108
|
if (entry.storyId === null && !hasSeenIn(text)) {
|
|
107
109
|
problems.push(`${where} is drawn nowhere: give it a story, or write a "**Seen in**" line on ${page} naming the stories that draw it, or why none can`)
|
|
108
110
|
}
|
|
111
|
+
// A reusable part links to its page or a story (Katerina's ruling B10, 25 September):
|
|
112
|
+
// the catalogue reads the page's `<Meta title>`, so a page without one leaves it unlinked.
|
|
113
|
+
if (entry.storyId === null && entry.docsId === null) {
|
|
114
|
+
problems.push(`${where} links to nothing in Storybook: give ${page} a \`<Meta title="…" />\`, or give the part a story`)
|
|
115
|
+
}
|
|
109
116
|
}
|
|
110
117
|
return problems
|
|
111
118
|
}
|
|
@@ -118,8 +125,26 @@ const toStoryId = (title: string) =>
|
|
|
118
125
|
.replace(/^-+|-+$/g, '')
|
|
119
126
|
|
|
120
127
|
/** The names a page's section writes as a part: bold or in backticks, starting with a capital. */
|
|
121
|
-
function namedParts(section: string): string[] {
|
|
122
|
-
return [...section.matchAll(
|
|
128
|
+
export function namedParts(section: string): string[] {
|
|
129
|
+
return [...section.matchAll(/\*\*`?([A-Z][A-Za-z0-9]+)`?\*\*|`([A-Z][A-Za-z0-9]+)`/g)].map((m) => m[1] ?? m[2])
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The line a "When not" starts where no part stands in (Katerina's ruling B8). */
|
|
133
|
+
export const NO_ALTERNATIVE = /^No alternative:/m
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Why a page's `When not` breaks the ruling B8 (25 September), or null. The plan
|
|
137
|
+
* asked for "When not — naming the alternative"; CI checked only that the
|
|
138
|
+
* heading was there, and 29 pages across the three repos named nothing. Now the
|
|
139
|
+
* section names a real part other than the page's own (`own`), from `known`, or
|
|
140
|
+
* a line of it starts "No alternative:" and says why there is none.
|
|
141
|
+
*/
|
|
142
|
+
export function whenNotProblem(source: string, own: readonly string[], known: ReadonlySet<string>): string | null {
|
|
143
|
+
const section = source.replace(/\r\n/g, '\n').split(/^##\s+When not\s*$/m)[1]?.split(/^##\s/m)[0]
|
|
144
|
+
// A missing section is pageProblem's to report.
|
|
145
|
+
if (section === undefined || NO_ALTERNATIVE.test(section)) return null
|
|
146
|
+
if (namedParts(section).some((name) => known.has(name) && !own.includes(name))) return null
|
|
147
|
+
return '"When not" names no other part: name the part to use instead, as **Name** or `Name`, or start a line "No alternative:" that says why there is none'
|
|
123
148
|
}
|
|
124
149
|
|
|
125
150
|
/**
|
package/src/registry/index.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
export { buildRegistry, readIndexExports, serializeRegistry, type BuildOptions } from './build'
|
|
14
14
|
export { buildAppRegistry, type AppBuildOptions } from './app'
|
|
15
|
-
export { CONTRACT_KINDS, PAGE_SECTIONS, contractProblems, hasSeenIn, linkProblems, pageProblem } from './contract'
|
|
15
|
+
export { CONTRACT_KINDS, NO_ALTERNATIVE, PAGE_SECTIONS, contractProblems, hasSeenIn, linkProblems, namedParts, pageProblem, whenNotProblem } from './contract'
|
|
16
16
|
export { docsLink, findInRegistries, findInRegistry, formatFindings, type Finding } from './find'
|
|
17
17
|
export {
|
|
18
18
|
CLASSES,
|
|
@@ -45,16 +45,16 @@ describe('the registry builds', () => {
|
|
|
45
45
|
expect(registry.entries.map((one) => one.name).sort()).toEqual(values.map((one) => one.name).sort())
|
|
46
46
|
})
|
|
47
47
|
|
|
48
|
-
it('counts
|
|
48
|
+
it('counts 88 names over 58 files: 81 components, 6 helpers and 1 hook (Panel joined on 26 September)', () => {
|
|
49
49
|
// The reconciliation GATES.md §24 explains. If this changes, that changes.
|
|
50
50
|
const kinds = registry.entries.reduce<Record<string, number>>((all, one) => ({ ...all, [one.kind]: (all[one.kind] ?? 0) + 1 }), {})
|
|
51
|
-
expect(kinds).toEqual({ component:
|
|
52
|
-
expect(new Set(registry.entries.map((one) => one.sourceFile)).size).toBe(
|
|
51
|
+
expect(kinds).toEqual({ component: 81, helper: 6, hook: 1 })
|
|
52
|
+
expect(new Set(registry.entries.map((one) => one.sourceFile)).size).toBe(58)
|
|
53
53
|
})
|
|
54
54
|
|
|
55
55
|
it('gives every entry a purpose, from its own page or from the comment above it', () => {
|
|
56
56
|
expect(registry.entries.filter((one) => one.purpose.trim() === '')).toEqual([])
|
|
57
|
-
expect(registry.entries.filter((one) => one.purposeFrom === 'page').length).toBe(
|
|
57
|
+
expect(registry.entries.filter((one) => one.purposeFrom === 'page').length).toBe(58)
|
|
58
58
|
expect(registry.entries.filter((one) => one.purposeFrom === 'comment').length).toBe(30)
|
|
59
59
|
})
|
|
60
60
|
|
|
@@ -80,6 +80,20 @@ describe('the registry builds', () => {
|
|
|
80
80
|
expect(entry('Card').ownsBehaviours).toEqual([])
|
|
81
81
|
})
|
|
82
82
|
|
|
83
|
+
// B9: worked out from what a part draws and what it is, not only UIG-8's owners.
|
|
84
|
+
it('gives a part what the part it is owns, and a Base UI piece only what it is', () => {
|
|
85
|
+
const ids = (name: string) => entry(name).ownsBehaviours.map((owned) => owned.id)
|
|
86
|
+
// ConfirmDialog returns a DialogShell: it keeps focus inside and stops the page scrolling.
|
|
87
|
+
expect(ids('ConfirmDialog')).toEqual(expect.arrayContaining(['portal', 'focus', 'scroll-lock', 'press-outside']))
|
|
88
|
+
// MenuItem draws Menu.Item: a menu item, not a floating menu.
|
|
89
|
+
expect(ids('MenuItem')).toEqual(expect.arrayContaining(['base-ui', 'role']))
|
|
90
|
+
expect(ids('MenuItem')).not.toContain('portal')
|
|
91
|
+
// A tooltip's floating stays the tooltip's.
|
|
92
|
+
expect(ids('NavItem')).not.toContain('portal')
|
|
93
|
+
// Being built on Base UI, a role and a Tab stop are not handed on.
|
|
94
|
+
expect(ids('EmptyState')).not.toContain('tab-stop')
|
|
95
|
+
})
|
|
96
|
+
|
|
83
97
|
it('reads the variants from the props type, following a local alias', () => {
|
|
84
98
|
// ButtonVariant and ButtonSize are aliases; the props type points at them.
|
|
85
99
|
expect(entry('Button').variants).toEqual([
|
package/stories/Choosing.mdx
CHANGED
|
@@ -75,7 +75,7 @@ fork freely, owe nothing back, mention it on the package's ticket.
|
|
|
75
75
|
| You need | Reach for |
|
|
76
76
|
|---|---|
|
|
77
77
|
| Label–value rows (a details rail) | **Property** — `row` and `stacked` |
|
|
78
|
-
| The bar across the top of a column: its title, and the buttons that act on the whole column | **ContainerHeader** —
|
|
78
|
+
| The bar across the top of a column: its title, and the buttons that act on the whole column | **ContainerHeader** — inside the package's columns; in an app, the header comes with **Panel** or **ListColumn** |
|
|
79
79
|
| A box that can hold more than fits — a list that grows with its data counts, even when today's data fits | **ScrollArea** — its bar is drawn over the content and takes no width |
|
|
80
80
|
| A status, a count or a category, in a small pill | **Chip** — six colour types; the colour is the message, the label makes it legible |
|
|
81
81
|
| One thing — a conversation, a project, an object — as a box you can pick | **Card** — the fill by what it sits on; `href` for a link, `hover="fill"` in a feed |
|
|
@@ -115,11 +115,12 @@ the **floating** TopBar goes with the **Rail** and the content card.
|
|
|
115
115
|
| A 64px icon strip | **Rail**, filled with **RailItem** tiles |
|
|
116
116
|
| The menu button and the logo | the TopBar's `menu` and `logo` slots — collapsing stays yours |
|
|
117
117
|
| The list beside the thing that is open | **ListColumn** — 290px, its name in a **ContainerHeader**, the list under it scrolling |
|
|
118
|
+
| A panel of a page that is not a list — details, a thread, what is open — with a body that must never be cut off | **Panel** — a header, a body that scrolls whenever it is taller, and a `footer` that stays put |
|
|
118
119
|
|
|
119
120
|
## Deliberately not in the package
|
|
120
121
|
|
|
121
122
|
- Anything that knows your **product's objects** — its documents, threads,
|
|
122
123
|
files, tickets. Build it in your app, out of these parts, and keep its
|
|
123
124
|
stories in your app's own Storybook.
|
|
124
|
-
- Page layouts beyond the list column (**ListColumn**) and a
|
|
125
|
-
(**
|
|
125
|
+
- Page layouts beyond the list column (**ListColumn**) and a panel
|
|
126
|
+
(**Panel**) — split views, side rails — not here yet.
|