@assure-one/design-system 1.36.0 → 1.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.
@@ -110,6 +110,7 @@ the list a human has to pick up — spread props, dynamic expressions, conflicti
110
110
  | Id | Class | What it does |
111
111
  | ----- | ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
112
112
  | CM-05 | A | Button family `iconLeft` / `iconRight` → `iconStart` / `iconEnd` (pure rename; both names of one slot, spreads → "could not be transformed") |
113
+ | CM-13 | A | `Tooltip delayMs` → `delayDuration` (pure rename, literal or dynamic value; both names, spreads → "could not be transformed") |
113
114
  | CM-07 | A | `inputSize` → `size` on `Input`, `Textarea`, `SearchInput` (pure rename, literal or dynamic value; both names, spreads → "could not be transformed") |
114
115
  | CM-02 | A | Button explicit default size: `size="md"` on every `Button`, `SubmitButton` and `LinkButton` that passes no `size` (pixel-neutral) |
115
116
  | CM-04 | R | Button family legacy `variant` → `variant` + `intent` pair (`primary` → `solid`, `destructive` → `solid` + `danger`, …); `accent`, `dashed`, dynamic → report |
@@ -118,6 +119,7 @@ the list a human has to pick up — spread props, dynamic expressions, conflicti
118
119
  | CM-12 | R | Button `type="submit"` where the intent is evident (inside `<form>` in the same file, no `onClick`); everything else untyped → report |
119
120
  | CM-10 | R | `DatePicker onChange={e => f(e.target.value)}` → `onValueChange={value => f(value)}` for the recognisable arrow shapes; handlers by reference, other shapes → report |
120
121
  | CM-08 | R | `SearchSelect` → `Combobox`: single-mode call sites rewritten (tag, import, `{id,label}` accessors, placeholder → `aria-label`); multi API, extras and unnamed sites → report |
122
+ | CM-09 | R | `SideDrawer` → `Sheet`: drawers with a `SideDrawer.Header` rewritten (root → `Sheet` + `SheetContent side size`, `onClose` → `onOpenChange`, header → `SheetHeader`/`SheetTitle`/`SheetDescription`, Body/Footer → `SheetBody`/`SheetFooter`, import); no header, dynamic width, spreads → report |
121
123
  | CM-14 | X | hidden-input mirror finder: hidden `<input name>` a consumer added because a design-system control posts nothing |
122
124
  | CM-15 | X | DOM-selector finder: consumer code that depends on the internal DOM of design-system components, mapped to registry `C-DOM-*` ids |
123
125
  | CM-16 | X | `globals.css` analyser: `@source` into the package, duplicate preflight, colliding `@theme` keys, unlayered globals, legacy `var()` |
@@ -149,6 +151,27 @@ the same slot, and which one wins depends on attribute order). Not touched and
149
151
  not reported: elements already on the new names, local components with the
150
152
  same names, test files.
151
153
 
154
+ ### CM-13 — `Tooltip delayMs` → `delayDuration`
155
+
156
+ Class A. W5-12 made `delayMs` a deprecated alias of Radix's `delayDuration`
157
+ on the design-system `Tooltip` (contract C-TOOLTIP-DELAY, removed in 3.0).
158
+ Both name the same milliseconds and `delayDuration` wins when both are
159
+ given, so the rename is behaviour-neutral and a pure rename: only the
160
+ attribute's name is replaced, and its value — a literal or a dynamic
161
+ `delayMs={slow ? 700 : 300}` — keeps its text and formatting. Aliased and
162
+ namespace imports count.
163
+
164
+ | rule | Contract | What happens |
165
+ | --------------- | --------------- | ----------------------------------- |
166
+ | `delay-renamed` | C-TOOLTIP-DELAY | `delayMs={…}` → `delayDuration={…}` |
167
+
168
+ Not touched, and listed under "could not be transformed": an element that
169
+ passes **both** `delayDuration` and `delayMs` (`delayDuration` already wins;
170
+ a rename would make a duplicate attribute — delete `delayMs` by hand), and an
171
+ element with a spread (`<Tooltip delayMs={300} {...props}>` — the spread may
172
+ carry `delayDuration`). Not touched and not reported: elements already on
173
+ `delayDuration`, local components called Tooltip, test files.
174
+
152
175
  ### CM-07 — `inputSize` → `size` on Input, Textarea, SearchInput
153
176
 
154
177
  Class A. W4-05/06/07 put the three text controls on the control scale:
@@ -440,6 +463,36 @@ reported for a human.
440
463
  Not touched: elements already on `Combobox` (idempotency), local components
441
464
  with the same name, test files.
442
465
 
466
+ ### CM-09 — `SideDrawer` → `Sheet`
467
+
468
+ Class R. W5-08 made `SideDrawer` a deprecated adapter over `Sheet` (contract
469
+ C-SIDEDRAWER, removed in 3.0). A drawer **with a `SideDrawer.Header`** is
470
+ rewritten: the root becomes `Sheet` with `onOpenChange={(open) => !open &&
471
+ onClose()}` (a handler by reference is called, an arrow with an expression
472
+ body is inlined, anything else is called through `(expr)()`); the children
473
+ move into a `SheetContent` that carries the `side` and the mapped `size`
474
+ (`sm` → `sm`, `md` → `lg`; `lg` and the omitted default, 50vw, become `xl`
475
+ and are reported); the header's `title` and `subtitle` become `SheetTitle` and
476
+ `SheetDescription` inside `SheetHeader`; `SideDrawer.Body` / `.Footer` become
477
+ `SheetBody` / `SheetFooter` with their `className`. A header that had its own
478
+ `onClose` button hands the job to the sheet's built-in close button; one that
479
+ had none gets `showCloseButton={false}`. The named import follows once no
480
+ `SideDrawer` element is left in the file.
481
+
482
+ | rule | Contract | What happens |
483
+ | ------------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------ |
484
+ | `renamed` | C-SIDEDRAWER | root, content, parts and import rewritten (applied, low) |
485
+ | `width-approximate` | C-SIDEDRAWER | `width="lg"` or no `width` (50vw) → `size="xl"` (760px); set `--ds-sheet-width` for an exact width (applied, **medium**) |
486
+ | `header-close` | C-SIDEDRAWER | the header's close button is now the sheet's built-in one, last child of the content (applied, low) |
487
+ | `rendering` | C-SIDEDRAWER | the Sheet parts pad differently (`p-6` header and footer, `px-6` body) and draw no rules between them (applied, low) |
488
+ | `no-header` | C-SIDEDRAWER | a drawer without `SideDrawer.Header` has no title to become the `SheetTitle`: left alone (review, high) |
489
+ | `width-dynamic` | C-SIDEDRAWER | `width={expr}` has no `size` step: left alone (review, high) |
490
+ | `spread-props` | C-SIDEDRAWER | `{...props}` on the drawer or a part may carry any of the above: left alone (review, medium) |
491
+
492
+ Not touched: elements already on `Sheet` (idempotency), local components with
493
+ the same name, test files. The rewritten children are re-indented one level;
494
+ run the project's formatter afterwards.
495
+
443
496
  ### CM-15 — DOM-selector finder
444
497
 
445
498
  Report-only. It never modifies a file; the runner throws if it tries. What it
@@ -4,6 +4,7 @@
4
4
  */
5
5
  export const CODEMODS = [
6
6
  { id: "CM-05", module: "../transforms/cm-05-button-icon-slots.mjs" },
7
+ { id: "CM-13", module: "../transforms/cm-13-tooltip-delay.mjs" },
7
8
  { id: "CM-07", module: "../transforms/cm-07-input-size.mjs" },
8
9
  { id: "CM-02", module: "../transforms/cm-02-button-explicit-size.mjs" },
9
10
  { id: "CM-04", module: "../transforms/cm-04-button-variant-intent.mjs" },
@@ -12,6 +13,7 @@ export const CODEMODS = [
12
13
  { id: "CM-12", module: "../transforms/cm-12-button-type-submit.mjs" },
13
14
  { id: "CM-10", module: "../transforms/cm-10-date-picker-value-change.mjs" },
14
15
  { id: "CM-08", module: "../transforms/cm-08-search-select-to-combobox.mjs" },
16
+ { id: "CM-09", module: "../transforms/cm-09-side-drawer-to-sheet.mjs" },
15
17
  { id: "CM-14", module: "../transforms/cm-14-hidden-mirrors.mjs" },
16
18
  { id: "CM-15", module: "../transforms/cm-15-dom-selectors.mjs" },
17
19
  { id: "CM-16", module: "../transforms/cm-16-globals-css.mjs" },
@@ -0,0 +1,348 @@
1
+ /**
2
+ * CM-09 — `SideDrawer` → `Sheet` (class R; plan §29 seq 11, registry
3
+ * `C-SIDEDRAWER`, lands with W5-08).
4
+ *
5
+ * W5-08 turned `SideDrawer` into a deprecated adapter over `Sheet`. A drawer
6
+ * with a `SideDrawer.Header` is a mechanical rewrite onto the Sheet parts;
7
+ * what has no Sheet equivalent is reported for a human.
8
+ *
9
+ * ## What it rewrites
10
+ *
11
+ * ```tsx
12
+ * <SideDrawer open={open} onClose={onClose} width="md" side="left">
13
+ * <SideDrawer.Header title={name} subtitle="…" onClose={onClose} />
14
+ * <SideDrawer.Body className="…">…</SideDrawer.Body>
15
+ * <SideDrawer.Footer>…</SideDrawer.Footer>
16
+ * </SideDrawer>
17
+ * ```
18
+ *
19
+ * becomes
20
+ *
21
+ * ```tsx
22
+ * <Sheet open={open} onOpenChange={(open) => !open && onClose()}>
23
+ * <SheetContent side="left" size="lg">
24
+ * <SheetHeader>
25
+ * <SheetTitle>{name}</SheetTitle>
26
+ * <SheetDescription>…</SheetDescription>
27
+ * </SheetHeader>
28
+ * <SheetBody className="…">…</SheetBody>
29
+ * <SheetFooter>…</SheetFooter>
30
+ * </SheetContent>
31
+ * </Sheet>
32
+ * ```
33
+ *
34
+ * - `onClose` → `onOpenChange`: a handler by reference is called, an arrow
35
+ * with an expression body is inlined, anything else is called through
36
+ * `(expr)()`
37
+ * - `width` → `size` on the content: `sm` → `sm` (420px), `md` → `lg`
38
+ * (640px); `lg` (50vw) and the omitted default have no step and become
39
+ * `xl` (760px), reported as `width-approximate`
40
+ * - `side` moves to the content
41
+ * - `SideDrawer.Header title subtitle` → `SheetHeader` with `SheetTitle` and
42
+ * `SheetDescription`; the header's `onClose` is dropped because the sheet's
43
+ * built-in close button (the last child of the content) closes through
44
+ * `onOpenChange`; a header without `onClose` gets `showCloseButton={false}`
45
+ * - `SideDrawer.Body` → `SheetBody`, `SideDrawer.Footer` → `SheetFooter`
46
+ * (`className` kept)
47
+ * - the named import follows when no `SideDrawer` element is left
48
+ *
49
+ * ## What it reports
50
+ *
51
+ * | rule | action | severity | why |
52
+ * | ------------------- | ------- | -------- | ---------------------------------------------------------------------------- |
53
+ * | `renamed` | applied | low | root, content, parts and import rewritten |
54
+ * | `width-approximate` | applied | medium | `lg` / default (50vw) → `xl` (760px); set `--ds-sheet-width` for an exact width |
55
+ * | `header-close` | applied | low | the header's close button is now the sheet's built-in one |
56
+ * | `rendering` | applied | low | Sheet parts pad differently (`p-6` header/footer, `px-6` body; no rules between them) |
57
+ * | `no-header` | review | high | a drawer without `SideDrawer.Header` has no title to become the `SheetTitle` |
58
+ * | `width-dynamic` | review | high | `width={expr}` cannot be mapped to a `size` step |
59
+ * | `spread-props` | review | medium | `{...props}` on the drawer or a part may carry any of the above |
60
+ *
61
+ * Elements already on `Sheet` are not touched (idempotency). Local components
62
+ * with the same name are not touched; test files are skipped.
63
+ */
64
+ import { analyseForms, isWithin } from "../lib/forms.mjs";
65
+ import { applyEdits, openingOf } from "../lib/jsx-edit.mjs";
66
+
67
+ export const meta = {
68
+ id: "CM-09",
69
+ title: "SideDrawer → Sheet (drawers with a header rewritten; the rest reported)",
70
+ class: "R",
71
+ oneShot: false,
72
+ requires: { codemods: [], dsVersion: null },
73
+ parses: ["code"],
74
+ includeTests: false,
75
+ usesTypeScript: true,
76
+ usesPostcss: false,
77
+ registryIds: ["C-SIDEDRAWER"],
78
+ };
79
+
80
+ export const RULES = {
81
+ renamed: { action: "applied", severity: "low" },
82
+ "width-approximate": { action: "applied", severity: "medium" },
83
+ "header-close": { action: "applied", severity: "low" },
84
+ rendering: { action: "applied", severity: "low" },
85
+ "no-header": { action: "review", severity: "high" },
86
+ "width-dynamic": { action: "review", severity: "high" },
87
+ "spread-props": { action: "review", severity: "medium" },
88
+ };
89
+
90
+ /** `width` preset → `size` step. `lg` (50vw) has no step; `xl` is the nearest. */
91
+ export const WIDTH_TO_SIZE = { sm: "sm", md: "lg", lg: "xl" };
92
+
93
+ const PART_TO_SHEET = {
94
+ "SideDrawer.Header": "SheetHeader",
95
+ "SideDrawer.Body": "SheetBody",
96
+ "SideDrawer.Footer": "SheetFooter",
97
+ };
98
+
99
+ /** Children text for a title/subtitle attribute: a literal becomes text, an expression stays. */
100
+ function childrenOf(ts, sf, attr) {
101
+ const init = attr.initializer;
102
+ if (!init) return "";
103
+ if (ts.isStringLiteral(init)) {
104
+ return /[{}<>]/.test(init.text) ? `{${init.getText(sf)}}` : init.text;
105
+ }
106
+ return init.getText(sf);
107
+ }
108
+
109
+ /** The `onOpenChange` handler that calls the drawer's `onClose` on close. */
110
+ function closeHandler(ts, sf, attr) {
111
+ const init = attr.initializer;
112
+ const inner = init && ts.isJsxExpression(init) ? init.expression : null;
113
+ if (!inner) return null;
114
+ if (ts.isIdentifier(inner) || ts.isPropertyAccessExpression(inner)) {
115
+ return `(open) => !open && ${inner.getText(sf)}()`;
116
+ }
117
+ if (ts.isArrowFunction(inner) && inner.parameters.length === 0 && !ts.isBlock(inner.body)) {
118
+ return `(open) => !open && ${inner.body.getText(sf)}`;
119
+ }
120
+ return `(open) => !open && (${inner.getText(sf)})()`;
121
+ }
122
+
123
+ const attrsOf = (ts, sf, opening) => {
124
+ const map = new Map();
125
+ for (const attr of opening.attributes.properties) {
126
+ if (ts.isJsxAttribute(attr)) map.set(attr.name.getText(sf), attr);
127
+ }
128
+ return map;
129
+ };
130
+
131
+ const indentOf = (sf, node) => {
132
+ const text = sf.text;
133
+ const lineStart = text.lastIndexOf("\n", node.getStart(sf)) + 1;
134
+ return /^[ \t]*/.exec(text.slice(lineStart, node.getStart(sf)))[0];
135
+ };
136
+
137
+ export function transform(file, { ts }) {
138
+ const facts = analyseForms(ts, file.source, file.rel);
139
+ const sf = facts.sf;
140
+ const findings = [];
141
+ const edits = [];
142
+ const needed = new Set();
143
+ let remaining = 0;
144
+ let renamedAny = false;
145
+
146
+ const drawers = facts.elements.filter(
147
+ (el) => el.isDs && el.base === "SideDrawer" && el.component === "SideDrawer",
148
+ );
149
+
150
+ for (const el of drawers) {
151
+ const base = {
152
+ line: el.line,
153
+ registryId: "C-SIDEDRAWER",
154
+ match: `<${el.tag}>`,
155
+ component: el.component,
156
+ gate: null,
157
+ detail: {},
158
+ };
159
+ const add = (rule, detail = {}) =>
160
+ findings.push({ ...base, rule, ...RULES[rule], detail: { ...base.detail, ...detail } });
161
+
162
+ const parts = facts.elements.filter(
163
+ (p) =>
164
+ p.isDs &&
165
+ p.base === "SideDrawer" &&
166
+ p.component !== "SideDrawer" &&
167
+ isWithin(facts.elements, p.index, el.index) &&
168
+ !drawers.some(
169
+ (other) =>
170
+ other !== el &&
171
+ isWithin(facts.elements, p.index, other.index) &&
172
+ isWithin(facts.elements, other.index, el.index),
173
+ ),
174
+ );
175
+ const header = parts.find((p) => p.component === "SideDrawer.Header") ?? null;
176
+ const width = el.props.get("width");
177
+
178
+ let blocked = false;
179
+ if (el.spread || parts.some((p) => p.spread)) {
180
+ add("spread-props");
181
+ blocked = true;
182
+ }
183
+ if (width && width.expression) {
184
+ add("width-dynamic");
185
+ blocked = true;
186
+ }
187
+ if (!header || ts.isJsxElement(header.node)) {
188
+ add("no-header");
189
+ blocked = true;
190
+ }
191
+ if (blocked || !ts.isJsxElement(el.node)) {
192
+ remaining += 1;
193
+ continue;
194
+ }
195
+
196
+ // ── The drawer's own opening tag → <Sheet open onOpenChange> ─────────
197
+ const opening = el.node.openingElement;
198
+ const attrs = attrsOf(ts, sf, opening);
199
+ const inner = [];
200
+ inner.push({ pos: opening.tagName.getStart(sf), end: opening.tagName.getEnd(), text: "Sheet" });
201
+ const closing = el.node.closingElement.tagName;
202
+ inner.push({ pos: closing.getStart(sf), end: closing.getEnd(), text: "Sheet" });
203
+
204
+ const onClose = attrs.get("onClose");
205
+ if (onClose) {
206
+ const handler = closeHandler(ts, sf, onClose);
207
+ if (handler) {
208
+ inner.push({
209
+ pos: onClose.getStart(sf),
210
+ end: onClose.getEnd(),
211
+ text: `onOpenChange={${handler}}`,
212
+ });
213
+ }
214
+ }
215
+ const removeAttr = (name) => {
216
+ const list = opening.attributes.properties;
217
+ const index = list.findIndex((a) => ts.isJsxAttribute(a) && a.name.getText(sf) === name);
218
+ if (index < 0) return;
219
+ const from = index === 0 ? opening.attributes.pos : list[index - 1].getEnd();
220
+ inner.push({ pos: from, end: list[index].getEnd(), text: "" });
221
+ };
222
+ removeAttr("width");
223
+ removeAttr("side");
224
+
225
+ // ── The content: side, size, showCloseButton ─────────────────────────
226
+ const preset = width ? width.literals[0] : "lg";
227
+ const size = WIDTH_TO_SIZE[preset] ?? "xl";
228
+ const contentAttrs = [];
229
+ const side = attrs.get("side");
230
+ contentAttrs.push(side ? `side=${side.initializer.getText(sf)}` : 'side="right"');
231
+ contentAttrs.push(`size="${size}"`);
232
+ const headerAttrs = attrsOf(ts, sf, header.node);
233
+ if (!headerAttrs.has("onClose")) contentAttrs.push("showCloseButton={false}");
234
+ needed.add("Sheet").add("SheetContent").add("SheetHeader").add("SheetTitle");
235
+
236
+ // ── SideDrawer.Header → SheetHeader with title and description ───────
237
+ const headerIndent = indentOf(sf, header.node);
238
+ const headerRows = [];
239
+ headerRows.push(`<SheetTitle>${childrenOf(ts, sf, headerAttrs.get("title"))}</SheetTitle>`);
240
+ const subtitle = headerAttrs.get("subtitle");
241
+ if (subtitle) {
242
+ needed.add("SheetDescription");
243
+ const value = childrenOf(ts, sf, subtitle);
244
+ headerRows.push(
245
+ subtitle.initializer && ts.isJsxExpression(subtitle.initializer)
246
+ ? `{${subtitle.initializer.expression.getText(sf)} ? <SheetDescription>${value}</SheetDescription> : null}`
247
+ : `<SheetDescription>${value}</SheetDescription>`,
248
+ );
249
+ }
250
+ const headerClass = headerAttrs.get("className");
251
+ const headerOpen = `<SheetHeader${headerClass ? ` ${headerClass.getText(sf)}` : ""}>`;
252
+ inner.push({
253
+ pos: header.node.getStart(sf),
254
+ end: header.node.getEnd(),
255
+ text: [
256
+ headerOpen,
257
+ ...headerRows.map((row) => `${headerIndent} ${row}`),
258
+ `${headerIndent}</SheetHeader>`,
259
+ ].join("\n"),
260
+ });
261
+
262
+ // ── Body / Footer → SheetBody / SheetFooter ──────────────────────────
263
+ for (const part of parts) {
264
+ if (part === header) continue;
265
+ const to = PART_TO_SHEET[part.component];
266
+ if (!to) continue;
267
+ needed.add(to);
268
+ const partOpening = openingOf(ts, part.node);
269
+ inner.push({
270
+ pos: partOpening.tagName.getStart(sf),
271
+ end: partOpening.tagName.getEnd(),
272
+ text: to,
273
+ });
274
+ if (ts.isJsxElement(part.node)) {
275
+ const tag = part.node.closingElement.tagName;
276
+ inner.push({ pos: tag.getStart(sf), end: tag.getEnd(), text: to });
277
+ }
278
+ }
279
+
280
+ // ── Assemble: apply the inner edits to the element's own text, indent
281
+ // the children one level and wrap them in <SheetContent>. ──────────────
282
+ const start = el.node.getStart(sf);
283
+ const end = el.node.getEnd();
284
+ const local = inner.map((e) => ({ ...e, pos: e.pos - start, end: e.end - start }));
285
+ const text = applyEdits(file.source.slice(start, end), local);
286
+ // Where the (edited) opening tag ends: its original length plus what the
287
+ // edits inside it added or removed.
288
+ const openingEnd = opening.getEnd() - start;
289
+ const openEnd = local
290
+ .filter((e) => e.end <= openingEnd)
291
+ .reduce((at, e) => at + e.text.length - (e.end - e.pos), openingEnd);
292
+ const closeStart = text.lastIndexOf("</Sheet>");
293
+ const indent = indentOf(sf, el.node);
294
+ const children = text.slice(openEnd, closeStart).replace(/\n(?=[^\n])/g, "\n ");
295
+ const trimmed = children.replace(/\s+$/, "");
296
+ edits.push({
297
+ pos: start,
298
+ end,
299
+ text: `${text.slice(0, openEnd)}\n${indent} <SheetContent ${contentAttrs.join(" ")}>${trimmed}\n${indent} </SheetContent>\n${indent}${text.slice(closeStart)}`,
300
+ });
301
+
302
+ renamedAny = true;
303
+ add("renamed");
304
+ if (WIDTH_TO_SIZE[preset] === "xl" || !WIDTH_TO_SIZE[preset]) {
305
+ add("width-approximate", { width: preset, size });
306
+ }
307
+ if (headerAttrs.has("onClose")) {
308
+ add("header-close", {
309
+ header: headerAttrs.get("onClose").initializer?.getText(sf) ?? null,
310
+ drawer: onClose?.initializer?.getText(sf) ?? null,
311
+ });
312
+ }
313
+ add("rendering");
314
+ }
315
+
316
+ // The import: `SideDrawer` → the Sheet parts the file now uses, when no
317
+ // element is left on the old name.
318
+ if (renamedAny && remaining === 0) {
319
+ for (const stmt of sf.statements) {
320
+ if (!ts.isImportDeclaration(stmt) || !ts.isStringLiteral(stmt.moduleSpecifier)) continue;
321
+ if (!stmt.moduleSpecifier.text.startsWith("@assure-one/design-system")) continue;
322
+ const bindings = stmt.importClause?.namedBindings;
323
+ if (!bindings || !ts.isNamedImports(bindings)) continue;
324
+ const present = new Set(bindings.elements.map((e) => (e.propertyName ?? e.name).text));
325
+ for (const element of bindings.elements) {
326
+ const imported = (element.propertyName ?? element.name).text;
327
+ if (imported !== "SideDrawer" || element.propertyName) continue;
328
+ const names = [...needed].filter((n) => !present.has(n)).sort();
329
+ if (names.length) {
330
+ edits.push({ pos: element.getStart(sf), end: element.getEnd(), text: names.join(", ") });
331
+ } else {
332
+ const list = bindings.elements;
333
+ const index = list.indexOf(element);
334
+ const from = index === 0 ? element.getStart(sf) : list[index - 1].getEnd();
335
+ const to = index === 0 && list.length > 1 ? list[1].getStart(sf) : element.getEnd();
336
+ edits.push({ pos: from, end: to, text: "" });
337
+ }
338
+ }
339
+ }
340
+ }
341
+
342
+ return {
343
+ output: edits.length ? applyEdits(file.source, edits) : file.source,
344
+ findings,
345
+ notTransformed: [],
346
+ parseErrors: facts.parseErrors,
347
+ };
348
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * CM-13 — `Tooltip delayMs` → `delayDuration` (class A; plan §29 seq 2,
3
+ * registry `C-TOOLTIP-DELAY`).
4
+ *
5
+ * W5-12 made `delayMs` a deprecated alias of Radix's `delayDuration` on the
6
+ * design-system `Tooltip` (`TooltipProps` in `src/primitives/tooltip.tsx`):
7
+ * both name the same milliseconds and `delayDuration` wins when both are
8
+ * given, so the rename is behaviour-neutral and a pure rename — only the
9
+ * attribute's name is replaced; a literal or a dynamic `delayMs={x}` keeps
10
+ * its value and its formatting.
11
+ *
12
+ * | rule | registry | what happens |
13
+ * | --------------- | --------------- | ----------------------------------- |
14
+ * | `delay-renamed` | C-TOOLTIP-DELAY | `delayMs={…}` → `delayDuration={…}` |
15
+ *
16
+ * ## What it leaves alone, and lists under "could not be transformed"
17
+ *
18
+ * - An element that passes **both** `delayDuration` and `delayMs`:
19
+ * `delayDuration` already wins in the component, and a rename would make a
20
+ * duplicate attribute. A human deletes `delayMs`.
21
+ * - An element with a spread (`<Tooltip delayMs={300} {...props}>`): the
22
+ * spread may carry `delayDuration`, and which one wins depends on attribute
23
+ * order — renaming the literal could change it.
24
+ *
25
+ * Elements already on `delayDuration` are not touched (idempotency). Local
26
+ * components called Tooltip are not touched; test files are skipped, as by
27
+ * the scanner that measures C-TOOLTIP-DELAY.
28
+ */
29
+ import { analyseForms } from "../lib/forms.mjs";
30
+ import { applyEdits, openingOf, renameAttribute } from "../lib/jsx-edit.mjs";
31
+
32
+ export const meta = {
33
+ id: "CM-13",
34
+ title: "Tooltip: delayMs → delayDuration",
35
+ class: "A",
36
+ oneShot: false,
37
+ requires: { codemods: [], dsVersion: null },
38
+ parses: ["code"],
39
+ includeTests: false,
40
+ usesTypeScript: true,
41
+ usesPostcss: false,
42
+ registryIds: ["C-TOOLTIP-DELAY"],
43
+ };
44
+
45
+ /** The design-system component that accepts the deprecated `delayMs` (W5-12). */
46
+ export const DELAYED_COMPONENTS = new Set(["Tooltip"]);
47
+
48
+ export const LEGACY_PROP = "delayMs";
49
+ export const PROP = "delayDuration";
50
+
51
+ export function transform(file, { ts }) {
52
+ const facts = analyseForms(ts, file.source, file.rel);
53
+ const findings = [];
54
+ const notTransformed = [];
55
+ const edits = [];
56
+
57
+ for (const el of facts.elements) {
58
+ if (!el.isDs || !DELAYED_COMPONENTS.has(el.base) || el.component !== el.base) continue;
59
+ if (!el.props.has(LEGACY_PROP)) continue;
60
+
61
+ if (el.props.has(PROP)) {
62
+ notTransformed.push({
63
+ line: el.line,
64
+ reason: "both-names",
65
+ detail: `<${el.tag}> passes \`${LEGACY_PROP}\` and \`${PROP}\` — \`${PROP}\` already wins; delete \`${LEGACY_PROP}\` by hand`,
66
+ });
67
+ continue;
68
+ }
69
+ if (el.spread) {
70
+ notTransformed.push({
71
+ line: el.line,
72
+ reason: "spread-props",
73
+ detail: `<${el.tag} {…}> — the spread may carry \`${PROP}\`; rename by hand once you know it does not`,
74
+ });
75
+ continue;
76
+ }
77
+
78
+ const edit = renameAttribute(ts, facts.sf, openingOf(ts, el.node), LEGACY_PROP, PROP);
79
+ if (!edit) continue;
80
+ edits.push(edit);
81
+ findings.push({
82
+ line: el.props.get(LEGACY_PROP).line,
83
+ registryId: "C-TOOLTIP-DELAY",
84
+ rule: "delay-renamed",
85
+ match: `<${el.tag} ${LEGACY_PROP}>`,
86
+ component: el.component,
87
+ action: "applied",
88
+ gate: null,
89
+ detail: { from: LEGACY_PROP, to: PROP, value: el.props.get(LEGACY_PROP).text },
90
+ });
91
+ }
92
+
93
+ return {
94
+ output: edits.length ? applyEdits(file.source, edits) : file.source,
95
+ findings,
96
+ notTransformed,
97
+ parseErrors: facts.parseErrors,
98
+ };
99
+ }