@assure-one/design-system 1.34.0 → 1.36.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 +53 -0
- package/codemods/README.md +225 -10
- package/codemods/lib/jsx-edit.mjs +63 -4
- package/codemods/lib/registry.mjs +7 -0
- package/codemods/transforms/cm-04-button-variant-intent.mjs +223 -0
- package/codemods/transforms/cm-05-button-icon-slots.mjs +117 -0
- package/codemods/transforms/cm-06-tone-to-intent.mjs +190 -0
- package/codemods/transforms/cm-07-input-size.mjs +104 -0
- package/codemods/transforms/cm-08-search-select-to-combobox.mjs +236 -0
- package/codemods/transforms/cm-10-date-picker-value-change.mjs +416 -0
- package/codemods/transforms/cm-14-hidden-mirrors.mjs +2 -2
- package/codemods/transforms/cm-19-progress-explicit-intent.mjs +192 -0
- package/dist/css/components.css +1 -1
- package/dist/css/legacy-aliases.css +5 -0
- package/dist/css/tokens.css +5 -0
- package/dist/design-system-provider-cPUklDJv.d.ts +220 -0
- package/dist/icons/index.d.ts +3 -0
- package/dist/icons/index.js +1833 -0
- package/dist/icons/index.js.map +1 -0
- package/dist/index-CQwzTm0v.d.ts +509 -0
- package/dist/index.d.ts +2967 -823
- package/dist/index.js +7644 -3062
- package/dist/index.js.map +1 -1
- package/dist/next/index.d.ts +20 -0
- package/dist/next/index.js +16 -0
- package/dist/next/index.js.map +1 -0
- package/dist/styles.css +1 -1
- package/dist/testing/index.cjs +133 -11
- package/dist/testing/index.d.cts +98 -2
- package/dist/testing/index.d.ts +98 -2
- package/dist/testing/index.js +132 -12
- package/docs/components.md +601 -0
- package/docs/components.registry.json +1586 -0
- package/docs/for-ai-agents.md +153 -0
- package/package.json +21 -3
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CM-08 — `SearchSelect` → `Combobox` (class R; plan §29 seq 10, registry
|
|
3
|
+
* `C-SEARCHSELECT`, lands with W4-12).
|
|
4
|
+
*
|
|
5
|
+
* W4-12 turned `SearchSelect` into a deprecated adapter over `Combobox`. The
|
|
6
|
+
* single-mode call sites (33 of 40 uses in the products) are a mechanical
|
|
7
|
+
* rewrite; the multi API and the SearchSelect-only props are not, and are
|
|
8
|
+
* reported for a human.
|
|
9
|
+
*
|
|
10
|
+
* ## What it rewrites (single mode: `value` / `onValueChange`, no spread)
|
|
11
|
+
*
|
|
12
|
+
* - the tag `SearchSelect` → `Combobox`, and the named import with it when
|
|
13
|
+
* the file has no other `SearchSelect` element left
|
|
14
|
+
* - `options` keeps its expression; `getOptionValue={(o) => o.id}` and
|
|
15
|
+
* `getOptionLabel={(o) => o.label}` are inserted after it, so the `{ id,
|
|
16
|
+
* label }` records need no remapping (target architecture §32 accessors)
|
|
17
|
+
* - `placeholder="…"` named the SearchSelect input; a Combobox is named by a
|
|
18
|
+
* Field label or `aria-label`, so a literal placeholder is copied into
|
|
19
|
+
* `aria-label` when the element has none
|
|
20
|
+
*
|
|
21
|
+
* ## What it reports
|
|
22
|
+
*
|
|
23
|
+
* | rule | action | severity | why |
|
|
24
|
+
* | -------------------- | ------- | -------- | ----------------------------------------------------------------------------------- |
|
|
25
|
+
* | `renamed` | applied | low | tag, import and accessors rewritten |
|
|
26
|
+
* | `clear-null` | applied | medium | the rewritten element's Combobox reports `null` on clear where SearchSelect passed `""`; check the handler |
|
|
27
|
+
* | `rendering` | applied / review | low | SearchSelect opened on focus, below the field, never flipping; Combobox flips to stay in the viewport and opens on click/typing (plan §29: flagged per call site — `applied` on a rewritten element, `review` on one left alone) |
|
|
28
|
+
* | `option-fields` | applied | low | `description` / `icon` / `color` on the records are not read through the accessors; move them to `renderOption` if used |
|
|
29
|
+
* | `multi-api` | review | high | `selected` / `onSelect` / `onRemove` → `multiple` + `value: string[]` + `onValueChange`; the state shape changes |
|
|
30
|
+
* | `unmapped-prop` | review | high | `groupFilter` / `pinnedGroups` / `showGroupCounts` / `onCreateNew` / `createNewLabel` / `descriptionVariant` / `closeOnSelect` have no Combobox prop |
|
|
31
|
+
* | `accessible-name` | review | high | no literal placeholder and no `aria-label`: the element is left alone — a Combobox without a name fires the W4-02 development error |
|
|
32
|
+
* | `spread-props` | review | medium | `{...props}` may carry any of the above |
|
|
33
|
+
* | `option-type` | review | low | `SearchSelectOption` is still imported; the Combobox shape is `Option` |
|
|
34
|
+
*
|
|
35
|
+
* Elements already on `Combobox` are not touched (idempotency). Local
|
|
36
|
+
* components with the same name are not touched; test files are skipped.
|
|
37
|
+
*/
|
|
38
|
+
import { analyseForms } from "../lib/forms.mjs";
|
|
39
|
+
import { applyEdits, insertAttribute, openingOf } from "../lib/jsx-edit.mjs";
|
|
40
|
+
|
|
41
|
+
export const meta = {
|
|
42
|
+
id: "CM-08",
|
|
43
|
+
title: "SearchSelect → Combobox (single mode automatic; multi and extras reported)",
|
|
44
|
+
class: "R",
|
|
45
|
+
oneShot: false,
|
|
46
|
+
requires: { codemods: [], dsVersion: null },
|
|
47
|
+
parses: ["code"],
|
|
48
|
+
includeTests: false,
|
|
49
|
+
usesTypeScript: true,
|
|
50
|
+
usesPostcss: false,
|
|
51
|
+
registryIds: ["C-SEARCHSELECT"],
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export const RULES = {
|
|
55
|
+
renamed: { action: "applied", severity: "low" },
|
|
56
|
+
"clear-null": { action: "applied", severity: "medium" },
|
|
57
|
+
rendering: { action: "applied", severity: "low" },
|
|
58
|
+
"option-fields": { action: "applied", severity: "low" },
|
|
59
|
+
"multi-api": { action: "review", severity: "high" },
|
|
60
|
+
"unmapped-prop": { action: "review", severity: "high" },
|
|
61
|
+
"accessible-name": { action: "review", severity: "high" },
|
|
62
|
+
"spread-props": { action: "review", severity: "medium" },
|
|
63
|
+
"option-type": { action: "review", severity: "low" },
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/** Props the Combobox takes under the same name and meaning. */
|
|
67
|
+
export const MAPPED_PROPS = new Set([
|
|
68
|
+
"options",
|
|
69
|
+
"value",
|
|
70
|
+
"onValueChange",
|
|
71
|
+
"placeholder",
|
|
72
|
+
"className",
|
|
73
|
+
"disabled",
|
|
74
|
+
"onCreate",
|
|
75
|
+
"id",
|
|
76
|
+
"aria-label",
|
|
77
|
+
"aria-labelledby",
|
|
78
|
+
"aria-describedby",
|
|
79
|
+
]);
|
|
80
|
+
export const MULTI_PROPS = new Set(["selected", "onSelect", "onRemove"]);
|
|
81
|
+
export const UNMAPPED_PROPS = new Set([
|
|
82
|
+
"groupFilter",
|
|
83
|
+
"pinnedGroups",
|
|
84
|
+
"showGroupCounts",
|
|
85
|
+
"onCreateNew",
|
|
86
|
+
"createNewLabel",
|
|
87
|
+
"descriptionVariant",
|
|
88
|
+
"closeOnSelect",
|
|
89
|
+
]);
|
|
90
|
+
|
|
91
|
+
const ACCESSORS = "getOptionValue={(o) => o.id} getOptionLabel={(o) => o.label}";
|
|
92
|
+
|
|
93
|
+
export function transform(file, { ts }) {
|
|
94
|
+
const facts = analyseForms(ts, file.source, file.rel);
|
|
95
|
+
const findings = [];
|
|
96
|
+
const edits = [];
|
|
97
|
+
let remaining = 0;
|
|
98
|
+
let renamedAny = false;
|
|
99
|
+
|
|
100
|
+
for (const el of facts.elements) {
|
|
101
|
+
if (!el.isDs || el.base !== "SearchSelect" || el.component !== el.base) continue;
|
|
102
|
+
const base = {
|
|
103
|
+
line: el.line,
|
|
104
|
+
registryId: "C-SEARCHSELECT",
|
|
105
|
+
match: `<${el.tag}>`,
|
|
106
|
+
component: el.component,
|
|
107
|
+
gate: null,
|
|
108
|
+
detail: {},
|
|
109
|
+
};
|
|
110
|
+
const add = (rule, detail = {}, override = {}) =>
|
|
111
|
+
findings.push({
|
|
112
|
+
...base,
|
|
113
|
+
rule,
|
|
114
|
+
...RULES[rule],
|
|
115
|
+
...override,
|
|
116
|
+
detail: { ...base.detail, ...detail },
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const names = [...el.props.keys()];
|
|
120
|
+
const multi = names.filter((n) => MULTI_PROPS.has(n));
|
|
121
|
+
const unmapped = names.filter((n) => UNMAPPED_PROPS.has(n));
|
|
122
|
+
let blocked = false;
|
|
123
|
+
if (el.spread) {
|
|
124
|
+
add("spread-props");
|
|
125
|
+
blocked = true;
|
|
126
|
+
}
|
|
127
|
+
if (multi.length) {
|
|
128
|
+
add("multi-api", { props: multi });
|
|
129
|
+
blocked = true;
|
|
130
|
+
}
|
|
131
|
+
if (unmapped.length) {
|
|
132
|
+
add("unmapped-prop", { props: unmapped });
|
|
133
|
+
blocked = true;
|
|
134
|
+
}
|
|
135
|
+
if (blocked) {
|
|
136
|
+
remaining += 1;
|
|
137
|
+
add("rendering", {}, { action: "review" });
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// A Combobox without an accessible name fires the W4-02 development
|
|
142
|
+
// error at once, so an element whose name cannot be derived (no literal
|
|
143
|
+
// placeholder, no aria-label) is left for a human rather than rewritten.
|
|
144
|
+
const placeholder = el.props.get("placeholder");
|
|
145
|
+
const hasName = el.props.has("aria-label") || el.props.has("aria-labelledby");
|
|
146
|
+
const literal =
|
|
147
|
+
placeholder && !placeholder.expression && placeholder.literals.length === 1
|
|
148
|
+
? placeholder.literals[0]
|
|
149
|
+
: null;
|
|
150
|
+
if (!hasName && literal === null) {
|
|
151
|
+
remaining += 1;
|
|
152
|
+
add("accessible-name");
|
|
153
|
+
add("rendering", {}, { action: "review" });
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const opening = openingOf(ts, el.node);
|
|
158
|
+
const sf = facts.sf;
|
|
159
|
+
edits.push({
|
|
160
|
+
pos: opening.tagName.getStart(sf),
|
|
161
|
+
end: opening.tagName.getEnd(),
|
|
162
|
+
text: "Combobox",
|
|
163
|
+
});
|
|
164
|
+
if (ts.isJsxElement(el.node)) {
|
|
165
|
+
const closing = el.node.closingElement.tagName;
|
|
166
|
+
edits.push({ pos: closing.getStart(sf), end: closing.getEnd(), text: "Combobox" });
|
|
167
|
+
}
|
|
168
|
+
if (el.props.has("options")) {
|
|
169
|
+
edits.push(insertAttribute(ts, sf, opening, ACCESSORS, { after: "options" }));
|
|
170
|
+
}
|
|
171
|
+
if (!hasName) {
|
|
172
|
+
edits.push(
|
|
173
|
+
insertAttribute(ts, sf, opening, `aria-label=${JSON.stringify(literal)}`, {
|
|
174
|
+
after: "placeholder",
|
|
175
|
+
}),
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
renamedAny = true;
|
|
179
|
+
add("renamed");
|
|
180
|
+
if (el.props.has("onValueChange")) add("clear-null");
|
|
181
|
+
add("option-fields");
|
|
182
|
+
add("rendering");
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// The import: `SearchSelect` → `Combobox` when no element is left on the
|
|
186
|
+
// old name; a `SearchSelectOption` type import is reported, never rewritten.
|
|
187
|
+
if (renamedAny) {
|
|
188
|
+
const sf = facts.sf;
|
|
189
|
+
for (const stmt of sf.statements) {
|
|
190
|
+
if (!ts.isImportDeclaration(stmt) || !ts.isStringLiteral(stmt.moduleSpecifier)) continue;
|
|
191
|
+
if (!stmt.moduleSpecifier.text.startsWith("@assure-one/design-system")) continue;
|
|
192
|
+
const bindings = stmt.importClause?.namedBindings;
|
|
193
|
+
if (!bindings || !ts.isNamedImports(bindings)) continue;
|
|
194
|
+
const hasCombobox = bindings.elements.some(
|
|
195
|
+
(e) => (e.propertyName ?? e.name).text === "Combobox",
|
|
196
|
+
);
|
|
197
|
+
for (const element of bindings.elements) {
|
|
198
|
+
const imported = (element.propertyName ?? element.name).text;
|
|
199
|
+
if (imported === "SearchSelectOption") {
|
|
200
|
+
findings.push({
|
|
201
|
+
line: facts.lineOf(element),
|
|
202
|
+
registryId: "C-SEARCHSELECT",
|
|
203
|
+
match: "SearchSelectOption",
|
|
204
|
+
component: "SearchSelect",
|
|
205
|
+
gate: null,
|
|
206
|
+
rule: "option-type",
|
|
207
|
+
...RULES["option-type"],
|
|
208
|
+
detail: {},
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
if (imported !== "SearchSelect" || element.propertyName || remaining > 0) continue;
|
|
212
|
+
if (hasCombobox) {
|
|
213
|
+
// Drop the specifier: `Combobox` is already imported.
|
|
214
|
+
const list = bindings.elements;
|
|
215
|
+
const index = list.indexOf(element);
|
|
216
|
+
const from = index === 0 ? element.getStart(sf) : list[index - 1].getEnd();
|
|
217
|
+
const end = index === 0 && list.length > 1 ? list[1].getStart(sf) : element.getEnd();
|
|
218
|
+
edits.push({ pos: from, end, text: "" });
|
|
219
|
+
} else {
|
|
220
|
+
edits.push({
|
|
221
|
+
pos: element.name.getStart(sf),
|
|
222
|
+
end: element.name.getEnd(),
|
|
223
|
+
text: "Combobox",
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return {
|
|
231
|
+
output: edits.length ? applyEdits(file.source, edits) : file.source,
|
|
232
|
+
findings,
|
|
233
|
+
notTransformed: [],
|
|
234
|
+
parseErrors: facts.parseErrors,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CM-10 — `DatePicker onChange={e => f(e.target.value)}` →
|
|
3
|
+
* `onValueChange={value => f(value)}` (class R; plan §29 seq 9, registry
|
|
4
|
+
* `C-DATE-FAKEEVENT`).
|
|
5
|
+
*
|
|
6
|
+
* W4-18 gave `DatePicker` the value callback of target architecture §25,
|
|
7
|
+
* `onValueChange(iso | null)`, and kept the legacy fake-event `onChange`
|
|
8
|
+
* (`{ target: { name, value } }`, `value` the ISO string or `""` when
|
|
9
|
+
* cleared) as a deprecated alias that fires after it. The event was never a
|
|
10
|
+
* DOM event: every consumer handler unwraps `.target.value` and nothing else,
|
|
11
|
+
* so the handlers whose shape makes that visible are rewritten and every
|
|
12
|
+
* other handler is listed for a human.
|
|
13
|
+
*
|
|
14
|
+
* ## What it rewrites
|
|
15
|
+
*
|
|
16
|
+
* Only an inline arrow function whose parameter is used **exclusively** as
|
|
17
|
+
* the value, in one of these shapes:
|
|
18
|
+
*
|
|
19
|
+
* | handler | becomes |
|
|
20
|
+
* | --------------------------------------------- | ------------------------------------ |
|
|
21
|
+
* | `(e) => f(e.target.value)` (typed or not) | `(value) => f(value)` |
|
|
22
|
+
* | `e => setX(e.target.value)` | `value => setX(value)` |
|
|
23
|
+
* | `({ target }) => f(target.value)` | `(value) => f(value)` |
|
|
24
|
+
* | `({ target: { value } }) => f(value)` | `(value) => f(value)` |
|
|
25
|
+
* | `() => refetch()` | unchanged body, attribute renamed |
|
|
26
|
+
*
|
|
27
|
+
* The parameter is renamed `value` (`v`, then `next`, when the handler
|
|
28
|
+
* already binds that name); the body keeps its text and formatting, and only
|
|
29
|
+
* the `.target.value` accesses are replaced. `e.target.value` typed as a
|
|
30
|
+
* string becomes `string | null`: **a cleared picker now passes `null`, not
|
|
31
|
+
* `""`** — that is why the rewrite is reviewed (severity medium) and why a
|
|
32
|
+
* handler that reads a member of the value (`e.target.value.trim()`) is not
|
|
33
|
+
* rewritten: it would throw on clear.
|
|
34
|
+
*
|
|
35
|
+
* ## What it reports and leaves alone
|
|
36
|
+
*
|
|
37
|
+
* | rule | action | severity | when |
|
|
38
|
+
* | ---------------------- | ------- | -------- | -------------------------------------------------------------------------------------- |
|
|
39
|
+
* | `fake-event-unwrapped` | applied | medium | one of the shapes above was rewritten; confirm `null` on clear is what the state wants |
|
|
40
|
+
* | `handler-reference` | review | high | the handler is passed by reference (`onChange={handleChange}`, `{field.onChange}`, a factory call): its body is elsewhere |
|
|
41
|
+
* | `handler-shape` | review | high | an inline handler that reads more than `.target.value` (`e.target.name`, the event itself, a member of the value) or is not an arrow |
|
|
42
|
+
* | `has-value-change` | review | low | `onValueChange` is already wired; `onChange` still fires after it — delete it by hand |
|
|
43
|
+
* | `spread-props` | review | medium | `{...props}` may carry either callback; which one wins depends on attribute order |
|
|
44
|
+
*
|
|
45
|
+
* `DateRangePicker` is not touched: its `onChange` is already a value
|
|
46
|
+
* callback. Local components called `DatePicker` and test files are skipped,
|
|
47
|
+
* as by the scanner that measures C-DATE-FAKEEVENT.
|
|
48
|
+
*/
|
|
49
|
+
import { analyseForms } from "../lib/forms.mjs";
|
|
50
|
+
import { applyEdits, attributeNamed, openingOf, renameAttribute } from "../lib/jsx-edit.mjs";
|
|
51
|
+
|
|
52
|
+
export const meta = {
|
|
53
|
+
id: "CM-10",
|
|
54
|
+
title: "DatePicker: fake-event onChange → onValueChange",
|
|
55
|
+
class: "R",
|
|
56
|
+
oneShot: false,
|
|
57
|
+
requires: { codemods: [], dsVersion: null },
|
|
58
|
+
parses: ["code"],
|
|
59
|
+
includeTests: false,
|
|
60
|
+
usesTypeScript: true,
|
|
61
|
+
usesPostcss: false,
|
|
62
|
+
registryIds: ["C-DATE-FAKEEVENT"],
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** The design-system components with the fake-event `onChange` (W4-18). */
|
|
66
|
+
export const FAKE_EVENT_PICKERS = new Set(["DatePicker"]);
|
|
67
|
+
|
|
68
|
+
export const LEGACY_PROP = "onChange";
|
|
69
|
+
export const PROP = "onValueChange";
|
|
70
|
+
|
|
71
|
+
/** Rule → what the report says a human must do with it. */
|
|
72
|
+
export const RULES = {
|
|
73
|
+
"fake-event-unwrapped": { action: "applied", severity: "medium" },
|
|
74
|
+
"handler-reference": { action: "review", severity: "high" },
|
|
75
|
+
"handler-shape": { action: "review", severity: "high" },
|
|
76
|
+
"has-value-change": { action: "review", severity: "low" },
|
|
77
|
+
"spread-props": { action: "review", severity: "medium" },
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const NAME_CANDIDATES = ["value", "v", "next"];
|
|
81
|
+
|
|
82
|
+
const unparenthesize = (ts, node) => {
|
|
83
|
+
let current = node;
|
|
84
|
+
while (current && ts.isParenthesizedExpression(current)) current = current.expression;
|
|
85
|
+
return current;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Every identifier the node binds or reads, by text. Member names, object
|
|
90
|
+
* keys, JSX attribute names and destructuring keys are not bindings and are
|
|
91
|
+
* excluded.
|
|
92
|
+
*/
|
|
93
|
+
function identifierNames(ts, node, out = new Set(), skip = () => false) {
|
|
94
|
+
if (skip(node)) return out;
|
|
95
|
+
if (ts.isIdentifier(node)) {
|
|
96
|
+
const parent = node.parent;
|
|
97
|
+
const isMemberName = parent && ts.isPropertyAccessExpression(parent) && parent.name === node;
|
|
98
|
+
const isKey =
|
|
99
|
+
parent &&
|
|
100
|
+
(ts.isPropertyAssignment(parent) ||
|
|
101
|
+
ts.isPropertySignature(parent) ||
|
|
102
|
+
ts.isJsxAttribute(parent)) &&
|
|
103
|
+
parent.name === node;
|
|
104
|
+
const isBindingKey = parent && ts.isBindingElement(parent) && parent.propertyName === node;
|
|
105
|
+
if (!isMemberName && !isKey && !isBindingKey) out.add(node.text);
|
|
106
|
+
}
|
|
107
|
+
ts.forEachChild(node, (child) => {
|
|
108
|
+
identifierNames(ts, child, out, skip);
|
|
109
|
+
});
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Whether a nested function inside `node` binds `name` again (then a reference is ambiguous). */
|
|
114
|
+
function rebinds(ts, node, name) {
|
|
115
|
+
let found = false;
|
|
116
|
+
const visit = (n) => {
|
|
117
|
+
if (found) return;
|
|
118
|
+
if (ts.isArrowFunction(n) || ts.isFunctionExpression(n) || ts.isFunctionDeclaration(n)) {
|
|
119
|
+
for (const p of n.parameters) {
|
|
120
|
+
if (identifierNames(ts, p.name).has(name)) found = true;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (ts.isVariableDeclaration(n) && identifierNames(ts, n.name).has(name)) found = true;
|
|
124
|
+
ts.forEachChild(n, visit);
|
|
125
|
+
};
|
|
126
|
+
visit(node);
|
|
127
|
+
return found;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The identifier references to `name` inside `node` (reads, not member names or keys). */
|
|
131
|
+
function referencesTo(ts, node, name) {
|
|
132
|
+
const refs = [];
|
|
133
|
+
const visit = (n) => {
|
|
134
|
+
if (ts.isIdentifier(n) && n.text === name) {
|
|
135
|
+
const parent = n.parent;
|
|
136
|
+
const isMemberName = parent && ts.isPropertyAccessExpression(parent) && parent.name === n;
|
|
137
|
+
const isKey = parent && ts.isPropertyAssignment(parent) && parent.name === n;
|
|
138
|
+
// A shorthand property `{ e }` reads the binding.
|
|
139
|
+
if (!isMemberName && !isKey) refs.push(n);
|
|
140
|
+
}
|
|
141
|
+
ts.forEachChild(n, visit);
|
|
142
|
+
};
|
|
143
|
+
visit(node);
|
|
144
|
+
return refs;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const isAccessOf = (ts, node, member) =>
|
|
148
|
+
node && ts.isPropertyAccessExpression(node) && node.name.text === member;
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Whether the expression `node` is itself read further — a member, a call, an
|
|
152
|
+
* index — which would throw on `null`.
|
|
153
|
+
*/
|
|
154
|
+
function isReadFurther(ts, node) {
|
|
155
|
+
const parent = node.parent;
|
|
156
|
+
if (!parent) return false;
|
|
157
|
+
if (ts.isPropertyAccessExpression(parent) && parent.expression === node) return true;
|
|
158
|
+
if (ts.isElementAccessExpression(parent) && parent.expression === node) return true;
|
|
159
|
+
if (ts.isCallExpression(parent) && parent.expression === node) return true;
|
|
160
|
+
if (ts.isNonNullExpression(parent)) return isReadFurther(ts, parent);
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Classifies the handler expression of `onChange`.
|
|
166
|
+
*
|
|
167
|
+
* @returns {{ kind: "reference" } | { kind: "other", reason: string } |
|
|
168
|
+
* { kind: "unwrap", shape: string, param: Node, name: string, keepName: boolean, values: Node[] }}
|
|
169
|
+
*/
|
|
170
|
+
export function analyseHandler(ts, expression) {
|
|
171
|
+
const expr = unparenthesize(ts, expression);
|
|
172
|
+
if (!expr) return { kind: "other", reason: "empty expression" };
|
|
173
|
+
if (
|
|
174
|
+
ts.isIdentifier(expr) ||
|
|
175
|
+
ts.isPropertyAccessExpression(expr) ||
|
|
176
|
+
ts.isElementAccessExpression(expr) ||
|
|
177
|
+
ts.isCallExpression(expr) ||
|
|
178
|
+
ts.isNonNullExpression(expr) ||
|
|
179
|
+
ts.isAsExpression(expr)
|
|
180
|
+
) {
|
|
181
|
+
return { kind: "reference" };
|
|
182
|
+
}
|
|
183
|
+
if (ts.isFunctionExpression(expr)) {
|
|
184
|
+
return { kind: "other", reason: "a `function` expression; only arrow functions are rewritten" };
|
|
185
|
+
}
|
|
186
|
+
if (!ts.isArrowFunction(expr)) {
|
|
187
|
+
return { kind: "other", reason: "not an arrow function" };
|
|
188
|
+
}
|
|
189
|
+
if (expr.parameters.length === 0) {
|
|
190
|
+
return {
|
|
191
|
+
kind: "unwrap",
|
|
192
|
+
shape: "ignores-event",
|
|
193
|
+
param: null,
|
|
194
|
+
name: null,
|
|
195
|
+
keepName: false,
|
|
196
|
+
values: [],
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
if (expr.parameters.length > 1) {
|
|
200
|
+
return { kind: "other", reason: "more than one parameter" };
|
|
201
|
+
}
|
|
202
|
+
const param = expr.parameters[0];
|
|
203
|
+
if (param.dotDotDotToken || param.initializer) {
|
|
204
|
+
return { kind: "other", reason: "a rest or defaulted parameter" };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const body = expr.body;
|
|
208
|
+
/**
|
|
209
|
+
* The `.value` reads of the binding `local` inside the body. `depth` is how
|
|
210
|
+
* many member accesses separate the binding from the value: 2 for the event
|
|
211
|
+
* (`e.target.value`), 1 for the target (`target.value`), 0 for the value.
|
|
212
|
+
* Every read must be exactly that access and nothing beyond it.
|
|
213
|
+
*/
|
|
214
|
+
const valueReads = (local, depth) => {
|
|
215
|
+
if (rebinds(ts, body, local))
|
|
216
|
+
return { reason: `\`${local}\` is bound again inside the handler` };
|
|
217
|
+
const values = [];
|
|
218
|
+
for (const ref of referencesTo(ts, body, local)) {
|
|
219
|
+
let node = ref;
|
|
220
|
+
for (const member of ["target", "value"].slice(2 - depth)) {
|
|
221
|
+
const access = node.parent;
|
|
222
|
+
if (!isAccessOf(ts, access, member) || access.expression !== node) {
|
|
223
|
+
return {
|
|
224
|
+
reason: `reads \`${node.getText()}\` as more than \`${node.getText()}.${member}\``,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
node = access;
|
|
228
|
+
}
|
|
229
|
+
if (isReadFurther(ts, node)) {
|
|
230
|
+
return {
|
|
231
|
+
reason: `reads a member of the value (\`${node.parent.getText()}\`); the new callback passes \`null\` when cleared`,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
values.push(node);
|
|
235
|
+
}
|
|
236
|
+
return { values };
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
if (ts.isIdentifier(param.name)) {
|
|
240
|
+
const local = param.name.text;
|
|
241
|
+
const result = valueReads(local, 2);
|
|
242
|
+
if (result.reason) return { kind: "other", reason: result.reason };
|
|
243
|
+
return {
|
|
244
|
+
kind: "unwrap",
|
|
245
|
+
shape: result.values.length ? "event-target-value" : "ignores-event",
|
|
246
|
+
param,
|
|
247
|
+
name: local,
|
|
248
|
+
keepName: false,
|
|
249
|
+
values: result.values,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (ts.isObjectBindingPattern(param.name)) {
|
|
254
|
+
const elements = param.name.elements;
|
|
255
|
+
if (elements.length !== 1) return { kind: "other", reason: "destructures more than `target`" };
|
|
256
|
+
const [element] = elements;
|
|
257
|
+
if (element.dotDotDotToken || element.initializer) {
|
|
258
|
+
return { kind: "other", reason: "a rest or defaulted destructuring" };
|
|
259
|
+
}
|
|
260
|
+
const key = element.propertyName ? element.propertyName.getText() : element.name.getText();
|
|
261
|
+
if (key !== "target")
|
|
262
|
+
return { kind: "other", reason: `destructures \`${key}\`, not \`target\`` };
|
|
263
|
+
|
|
264
|
+
if (ts.isIdentifier(element.name)) {
|
|
265
|
+
// `({ target })` or `({ target: t })`: every read must be `t.value`.
|
|
266
|
+
const result = valueReads(element.name.text, 1);
|
|
267
|
+
if (result.reason) return { kind: "other", reason: result.reason };
|
|
268
|
+
return {
|
|
269
|
+
kind: "unwrap",
|
|
270
|
+
shape: "target-value",
|
|
271
|
+
param,
|
|
272
|
+
name: element.name.text,
|
|
273
|
+
keepName: false,
|
|
274
|
+
values: result.values,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
if (ts.isObjectBindingPattern(element.name)) {
|
|
278
|
+
// `({ target: { value } })` or `({ target: { value: v } })`.
|
|
279
|
+
const inner = element.name.elements;
|
|
280
|
+
if (inner.length !== 1 || inner[0].dotDotDotToken || inner[0].initializer) {
|
|
281
|
+
return { kind: "other", reason: "destructures more than `target.value`" };
|
|
282
|
+
}
|
|
283
|
+
const innerKey = inner[0].propertyName
|
|
284
|
+
? inner[0].propertyName.getText()
|
|
285
|
+
: inner[0].name.getText();
|
|
286
|
+
if (innerKey !== "value" || !ts.isIdentifier(inner[0].name)) {
|
|
287
|
+
return {
|
|
288
|
+
kind: "other",
|
|
289
|
+
reason: `destructures \`target.${innerKey}\`, not \`target.value\``,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
const local = inner[0].name.text;
|
|
293
|
+
const result = valueReads(local, 0);
|
|
294
|
+
if (result.reason) return { kind: "other", reason: result.reason };
|
|
295
|
+
return {
|
|
296
|
+
kind: "unwrap",
|
|
297
|
+
shape: "destructured-value",
|
|
298
|
+
param,
|
|
299
|
+
name: local,
|
|
300
|
+
keepName: true,
|
|
301
|
+
values: [],
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return { kind: "other", reason: "an unrecognised parameter shape" };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* A parameter name that nothing in the enclosing function (the component
|
|
310
|
+
* rendering the picker) binds or reads, so the new parameter shadows
|
|
311
|
+
* nothing: `value`, else `v`, else `next`.
|
|
312
|
+
*/
|
|
313
|
+
export function freshName(ts, arrow) {
|
|
314
|
+
const isFunction = (n) =>
|
|
315
|
+
ts.isArrowFunction(n) ||
|
|
316
|
+
ts.isFunctionExpression(n) ||
|
|
317
|
+
ts.isFunctionDeclaration(n) ||
|
|
318
|
+
ts.isMethodDeclaration(n);
|
|
319
|
+
const ancestors = new Set([arrow]);
|
|
320
|
+
let scope = arrow.parent;
|
|
321
|
+
while (scope && !ts.isSourceFile(scope) && !isFunction(scope)) {
|
|
322
|
+
ancestors.add(scope);
|
|
323
|
+
scope = scope.parent;
|
|
324
|
+
}
|
|
325
|
+
// Sibling functions bind their own names; what they read is bound in the
|
|
326
|
+
// scope and seen at its declaration.
|
|
327
|
+
const used = identifierNames(
|
|
328
|
+
ts,
|
|
329
|
+
scope ?? arrow,
|
|
330
|
+
new Set(),
|
|
331
|
+
(n) => isFunction(n) && n !== scope && !ancestors.has(n),
|
|
332
|
+
);
|
|
333
|
+
return NAME_CANDIDATES.find((name) => !used.has(name)) ?? "nextValue";
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
export function transform(file, { ts }) {
|
|
337
|
+
const facts = analyseForms(ts, file.source, file.rel);
|
|
338
|
+
const findings = [];
|
|
339
|
+
const edits = [];
|
|
340
|
+
|
|
341
|
+
for (const el of facts.elements) {
|
|
342
|
+
if (!el.isDs || !FAKE_EVENT_PICKERS.has(el.base) || el.component !== el.base) continue;
|
|
343
|
+
const legacy = el.props.get(LEGACY_PROP);
|
|
344
|
+
if (!legacy) continue;
|
|
345
|
+
|
|
346
|
+
const base = {
|
|
347
|
+
registryId: "C-DATE-FAKEEVENT",
|
|
348
|
+
match: `<${el.tag} ${LEGACY_PROP}>`,
|
|
349
|
+
component: el.component,
|
|
350
|
+
gate: null,
|
|
351
|
+
};
|
|
352
|
+
const review = (rule, detail) =>
|
|
353
|
+
findings.push({ ...base, line: legacy.line, rule, ...RULES[rule], detail });
|
|
354
|
+
|
|
355
|
+
if (el.props.has(PROP)) {
|
|
356
|
+
review("has-value-change", {
|
|
357
|
+
reason: `\`${PROP}\` is already passed; delete \`${LEGACY_PROP}\``,
|
|
358
|
+
});
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
if (el.spread) {
|
|
362
|
+
review("spread-props", { reason: `the spread may carry \`${LEGACY_PROP}\` or \`${PROP}\`` });
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const opening = openingOf(ts, el.node);
|
|
367
|
+
const attr = attributeNamed(ts, opening, LEGACY_PROP);
|
|
368
|
+
const init = attr?.initializer;
|
|
369
|
+
const expression = init && ts.isJsxExpression(init) ? init.expression : null;
|
|
370
|
+
if (!expression) {
|
|
371
|
+
review("handler-shape", { reason: "no handler expression" });
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
const handler = analyseHandler(ts, expression);
|
|
375
|
+
if (handler.kind === "reference") {
|
|
376
|
+
review("handler-reference", { handler: expression.getText(facts.sf) });
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
if (handler.kind === "other") {
|
|
380
|
+
review("handler-shape", { reason: handler.reason });
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const arrow = unparenthesize(ts, expression);
|
|
385
|
+
const name = handler.keepName ? handler.name : freshName(ts, arrow);
|
|
386
|
+
edits.push(renameAttribute(ts, facts.sf, opening, LEGACY_PROP, PROP));
|
|
387
|
+
if (handler.param) {
|
|
388
|
+
edits.push({
|
|
389
|
+
pos: handler.param.getStart(facts.sf),
|
|
390
|
+
end: handler.param.getEnd(),
|
|
391
|
+
text: name,
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
for (const valueNode of handler.values) {
|
|
395
|
+
edits.push({ pos: valueNode.getStart(facts.sf), end: valueNode.getEnd(), text: name });
|
|
396
|
+
}
|
|
397
|
+
findings.push({
|
|
398
|
+
...base,
|
|
399
|
+
line: legacy.line,
|
|
400
|
+
rule: "fake-event-unwrapped",
|
|
401
|
+
...RULES["fake-event-unwrapped"],
|
|
402
|
+
detail: {
|
|
403
|
+
shape: handler.shape,
|
|
404
|
+
param: name,
|
|
405
|
+
cleared: 'the new callback passes `null` where `onChange` passed `""`',
|
|
406
|
+
},
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return {
|
|
411
|
+
output: edits.length ? applyEdits(file.source, edits) : file.source,
|
|
412
|
+
findings,
|
|
413
|
+
notTransformed: [],
|
|
414
|
+
parseErrors: facts.parseErrors,
|
|
415
|
+
};
|
|
416
|
+
}
|
|
@@ -97,9 +97,9 @@ export const NAME_CAPABILITY = {
|
|
|
97
97
|
SearchSelect: "planned", // W4-12 (adapter over Combobox)
|
|
98
98
|
TeamMemberSelect: "planned", // W4-08/W4-12
|
|
99
99
|
MultiSelectField: "planned", // W4-08
|
|
100
|
-
ToggleGroup: "
|
|
100
|
+
ToggleGroup: "yes", // W4-16: FormBridge (`name?: string`)
|
|
101
101
|
PhoneCountryInput: "planned", // W4-04
|
|
102
|
-
OtpInput: "
|
|
102
|
+
OtpInput: "yes", // src/primitives/otp-input.tsx — the `input-otp` input carries `name` (optional since W4-23: a Field can supply it)
|
|
103
103
|
Questions: "planned", // W4-04 (answer widgets)
|
|
104
104
|
};
|
|
105
105
|
|