@planningcenter/tapestry-migration-cli 3.4.1 → 3.6.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.
Files changed (43) hide show
  1. package/README.md +59 -0
  2. package/dist/tapestry-react-shim.cjs +5 -1
  3. package/package.json +3 -3
  4. package/src/availableComponents.ts +14 -0
  5. package/src/components/button/transforms/tooltipToWrapper.test.ts +50 -3
  6. package/src/components/button/transforms/tooltipToWrapper.ts +18 -0
  7. package/src/components/dropdown/index.test.ts +67 -0
  8. package/src/components/dropdown/index.ts +4 -0
  9. package/src/components/dropdown/transforms/placementIdToMenu.test.ts +6 -1
  10. package/src/components/dropdown/transforms/placementIdToMenu.ts +1 -1
  11. package/src/components/dropdown/transforms/variantToKind.test.ts +292 -0
  12. package/src/components/dropdown/transforms/variantToKind.ts +138 -0
  13. package/src/components/dropdown/transforms/wrapIconTrigger.test.ts +336 -0
  14. package/src/components/dropdown/transforms/wrapIconTrigger.ts +233 -0
  15. package/src/components/flex/index.test.ts +140 -0
  16. package/src/components/flex/index.ts +40 -0
  17. package/src/components/flex/transforms/alignmentToAlign.test.ts +185 -0
  18. package/src/components/flex/transforms/alignmentToAlign.ts +73 -0
  19. package/src/components/flex/transforms/axisToDirection.test.ts +140 -0
  20. package/src/components/flex/transforms/axisToDirection.ts +51 -0
  21. package/src/components/flex/transforms/distributionToJustify.test.ts +214 -0
  22. package/src/components/flex/transforms/distributionToJustify.ts +76 -0
  23. package/src/components/flex/transforms/innerRefToRef.test.ts +100 -0
  24. package/src/components/flex/transforms/innerRefToRef.ts +14 -0
  25. package/src/components/flex/transforms/moveFlexImport.test.ts +202 -0
  26. package/src/components/flex/transforms/moveFlexImport.ts +14 -0
  27. package/src/components/flex/transforms/setDefaultAxis.test.ts +114 -0
  28. package/src/components/flex/transforms/setDefaultAxis.ts +29 -0
  29. package/src/components/flex/transforms/spacingToGap.test.ts +176 -0
  30. package/src/components/flex/transforms/spacingToGap.ts +49 -0
  31. package/src/components/select/index.ts +2 -0
  32. package/src/components/select/transforms/onChangeSignature.test.ts +224 -0
  33. package/src/components/select/transforms/onChangeSignature.ts +172 -0
  34. package/src/components/shared/actions/resolveConstantAttributeValue.test.ts +122 -0
  35. package/src/components/shared/actions/resolveConstantAttributeValue.ts +31 -0
  36. package/src/components/toggle-switch/index.ts +2 -0
  37. package/src/components/toggle-switch/transforms/onClickToOnChange.test.ts +210 -0
  38. package/src/components/toggle-switch/transforms/onClickToOnChange.ts +71 -0
  39. package/src/index.test.ts +79 -0
  40. package/src/index.ts +34 -18
  41. package/src/migrationList.ts +22 -0
  42. package/src/utils/componentLabel.test.ts +61 -0
  43. package/src/utils/componentLabel.ts +15 -0
@@ -0,0 +1,138 @@
1
+ import { JSXAttribute, JSXElement, Transform } from "jscodeshift"
2
+
3
+ import { addAttribute } from "../../shared/actions/addAttribute"
4
+ import { addComment } from "../../shared/actions/addComment"
5
+ import { getAttribute } from "../../shared/actions/getAttribute"
6
+ import { removeAttribute } from "../../shared/actions/removeAttribute"
7
+ import { hasAttribute } from "../../shared/conditions/hasAttribute"
8
+ import { orConditions } from "../../shared/conditions/orConditions"
9
+ import { attributeTransformFactory } from "../../shared/transformFactories/attributeTransformFactory"
10
+
11
+ const THEME_KIND_MAP: Record<string, Record<string, string>> = {
12
+ default: {
13
+ default: "neutral",
14
+ fill: "neutral",
15
+ naked: "ghost",
16
+ outline: "secondary",
17
+ },
18
+ error: {
19
+ default: "delete",
20
+ fill: "delete",
21
+ naked: "ghost-delete",
22
+ outline: "secondary-delete",
23
+ },
24
+ neutral: {
25
+ default: "neutral",
26
+ fill: "neutral",
27
+ naked: "ghost",
28
+ outline: "secondary",
29
+ },
30
+ primary: {
31
+ default: "primary",
32
+ fill: "primary",
33
+ naked: "ghost-interaction",
34
+ outline: "secondary-interaction",
35
+ },
36
+ }
37
+
38
+ const KNOWN_TRIGGER_BUTTONS = new Set(["DropdownButton", "DropdownIconButton"])
39
+
40
+ function normalizeTheme(value: string): string {
41
+ if (value === "destroy" || value === "delete") return "error"
42
+ if (value === "info" || value === "success" || value === "interaction")
43
+ return "primary"
44
+ return value
45
+ }
46
+
47
+ function normalizeVariant(value: string): string {
48
+ if (value === "ghost") return "naked"
49
+ if (value === "solid") return "fill"
50
+ return value
51
+ }
52
+
53
+ function getStaticStringValue(
54
+ attr: JSXAttribute | undefined,
55
+ defaultValue: string
56
+ ): string | null {
57
+ if (!attr || !attr.value) return defaultValue
58
+ if (attr.value.type === "StringLiteral") return attr.value.value
59
+ return null
60
+ }
61
+
62
+ function mapToKind(theme: string, variant: string): string | null {
63
+ return (
64
+ THEME_KIND_MAP[normalizeTheme(theme)]?.[normalizeVariant(variant)] ?? null
65
+ )
66
+ }
67
+
68
+ function findDropdownTrigger(element: JSXElement): JSXElement | undefined {
69
+ return (element.children ?? []).find(
70
+ (child): child is JSXElement =>
71
+ child.type === "JSXElement" &&
72
+ child.openingElement.name.type === "JSXIdentifier" &&
73
+ child.openingElement.name.name === "DropdownTrigger"
74
+ )
75
+ }
76
+
77
+ function findFirstJSXElementChild(element: JSXElement): JSXElement | undefined {
78
+ return (element.children ?? []).find(
79
+ (child): child is JSXElement => child.type === "JSXElement"
80
+ )
81
+ }
82
+
83
+ const transform: Transform = attributeTransformFactory({
84
+ condition: orConditions(hasAttribute("variant"), hasAttribute("theme")),
85
+ targetComponent: "Dropdown",
86
+ targetPackage: "@planningcenter/tapestry-react",
87
+ transform: (element, { j, source }) => {
88
+ const variantAttr = getAttribute({ element, name: "variant" })
89
+ const themeAttr = getAttribute({ element, name: "theme" })
90
+
91
+ const variantValue = getStaticStringValue(variantAttr, "fill")
92
+ const themeValue = getStaticStringValue(themeAttr, "default")
93
+
94
+ if (variantValue === null || themeValue === null) return false
95
+
96
+ const mappedKind = mapToKind(themeValue, variantValue)
97
+ if (!mappedKind) return false
98
+
99
+ const triggerChild = findDropdownTrigger(element)
100
+ if (!triggerChild) return false
101
+
102
+ const buttonChild = findFirstJSXElementChild(triggerChild)
103
+ if (!buttonChild) return false
104
+
105
+ const buttonName =
106
+ buttonChild.openingElement.name.type === "JSXIdentifier"
107
+ ? buttonChild.openingElement.name.name
108
+ : null
109
+
110
+ if (variantAttr) removeAttribute("variant", { element, j, source })
111
+ if (themeAttr) removeAttribute("theme", { element, j, source })
112
+
113
+ const scope =
114
+ variantAttr && themeAttr
115
+ ? "variant/theme"
116
+ : themeAttr
117
+ ? "theme"
118
+ : "variant"
119
+
120
+ const existingKind = getAttribute({ element: buttonChild, name: "kind" })
121
+
122
+ if (buttonName && KNOWN_TRIGGER_BUTTONS.has(buttonName) && !existingKind) {
123
+ addAttribute({ element: buttonChild, j, name: "kind", value: mappedKind })
124
+ } else {
125
+ addComment({
126
+ element,
127
+ j,
128
+ scope,
129
+ source,
130
+ text: `add kind="${mappedKind}" to the trigger button manually`,
131
+ })
132
+ }
133
+
134
+ return true
135
+ },
136
+ })
137
+
138
+ export default transform
@@ -0,0 +1,336 @@
1
+ import jscodeshift from "jscodeshift"
2
+ import { describe, expect, it } from "vitest"
3
+
4
+ import transform from "./wrapIconTrigger"
5
+
6
+ const j = jscodeshift.withParser("tsx")
7
+
8
+ function applyTransform(source: string): string | null {
9
+ const fileInfo = { path: "test.tsx", source }
10
+ return transform(
11
+ fileInfo,
12
+ { j, jscodeshift: j, report: () => {}, stats: () => {} },
13
+ {}
14
+ ) as string | null
15
+ }
16
+
17
+ describe("wrapIconTrigger transform", () => {
18
+ describe("object-form icon", () => {
19
+ it("converts icon object + title to DropdownTrigger + DropdownIconButton", () => {
20
+ const input = `
21
+ import { Dropdown } from "@planningcenter/tapestry-react"
22
+
23
+ export default function Test() {
24
+ return (
25
+ <Dropdown icon={{ name: "general.pencil" }} title="Edit options">
26
+ <DropdownMenu />
27
+ </Dropdown>
28
+ )
29
+ }
30
+ `.trim()
31
+
32
+ const result = applyTransform(input)
33
+ expect(result).toContain("<DropdownTrigger>")
34
+ expect(result).toContain("</DropdownTrigger>")
35
+ expect(result).toContain("<DropdownIconButton")
36
+ expect(result).toContain('aria-label="Edit options"')
37
+ expect(result).toContain("Symbol")
38
+ expect(result).toContain("#pencil")
39
+ expect(result).toContain("aria-hidden")
40
+ expect(result).not.toContain('icon={{ name: "general.pencil" }}')
41
+ expect(result).not.toContain('title="Edit options"')
42
+ })
43
+
44
+ it("adds Symbol, sprite, DropdownIconButton, and DropdownTrigger imports", () => {
45
+ const input = `
46
+ import { Dropdown } from "@planningcenter/tapestry-react"
47
+
48
+ export default function Test() {
49
+ return <Dropdown icon={{ name: "general.pencil" }} title="Edit"><DropdownMenu /></Dropdown>
50
+ }
51
+ `.trim()
52
+
53
+ const result = applyTransform(input)
54
+ expect(result).toContain('from "@planningcenter/icons/symbol"')
55
+ expect(result).toContain(
56
+ 'from "@planningcenter/icons/sprites/general.svg"'
57
+ )
58
+ expect(result).toContain("DropdownIconButton")
59
+ expect(result).toContain("DropdownTrigger")
60
+ expect(result).toContain('from "@planningcenter/tapestry"')
61
+ })
62
+
63
+ it("merges DropdownIconButton and DropdownTrigger into an existing tapestry import", () => {
64
+ const input = `
65
+ import { Dropdown, DropdownMenu } from "@planningcenter/tapestry-react"
66
+ import { DropdownAction } from "@planningcenter/tapestry"
67
+
68
+ export default function Test() {
69
+ return (
70
+ <Dropdown icon={{ name: "general.pencil" }} title="Edit">
71
+ <DropdownMenu />
72
+ </Dropdown>
73
+ )
74
+ }
75
+ `.trim()
76
+
77
+ const result = applyTransform(input)
78
+ expect(result).toContain("DropdownAction")
79
+ expect(result).toContain("DropdownIconButton")
80
+ expect(result).toContain("DropdownTrigger")
81
+ const tapestryImportMatches = result?.match(
82
+ /from "@planningcenter\/tapestry"/g
83
+ )
84
+ expect(tapestryImportMatches?.length).toBe(1)
85
+ })
86
+ })
87
+
88
+ describe("string-form icon", () => {
89
+ it("converts string icon to Symbol with correct id", () => {
90
+ const input = `
91
+ import { Dropdown } from "@planningcenter/tapestry-react"
92
+
93
+ export default function Test() {
94
+ return <Dropdown icon="general.pencil" title="Edit"><DropdownMenu /></Dropdown>
95
+ }
96
+ `.trim()
97
+
98
+ const result = applyTransform(input)
99
+ expect(result).toContain("Symbol")
100
+ expect(result).toContain("#pencil")
101
+ expect(result).not.toContain('"general.pencil"')
102
+ })
103
+ })
104
+
105
+ describe("icon name conversion", () => {
106
+ it("converts camelCase icon name to kebab-case symbol id", () => {
107
+ const input = `
108
+ import { Dropdown } from "@planningcenter/tapestry-react"
109
+
110
+ export default function Test() {
111
+ return <Dropdown icon={{ name: "general.downCaret" }} title="Sort"><DropdownMenu /></Dropdown>
112
+ }
113
+ `.trim()
114
+
115
+ const result = applyTransform(input)
116
+ expect(result).toContain("#down-caret")
117
+ expect(result).not.toContain("downCaret")
118
+ })
119
+
120
+ it("converts bare icon name (no collection) to general collection", () => {
121
+ const input = `
122
+ import { Dropdown } from "@planningcenter/tapestry-react"
123
+
124
+ export default function Test() {
125
+ return <Dropdown icon="pencil" title="Edit"><DropdownMenu /></Dropdown>
126
+ }
127
+ `.trim()
128
+
129
+ const result = applyTransform(input)
130
+ expect(result).toContain("#pencil")
131
+ expect(result).toContain("general")
132
+ })
133
+
134
+ it("uses the correct sprite for a non-general collection", () => {
135
+ const input = `
136
+ import { Dropdown } from "@planningcenter/tapestry-react"
137
+
138
+ export default function Test() {
139
+ return <Dropdown icon={{ name: "services.pencilEdit" }} title="Edit"><DropdownMenu /></Dropdown>
140
+ }
141
+ `.trim()
142
+
143
+ const result = applyTransform(input)
144
+ expect(result).toContain("#pencil-edit")
145
+ expect(result).toContain(
146
+ 'from "@planningcenter/icons/sprites/services.svg"'
147
+ )
148
+ expect(result).not.toContain("general.svg")
149
+ expect(result).not.toContain(
150
+ 'from "@planningcenter/icons/sprites/general.svg"'
151
+ )
152
+ })
153
+ })
154
+
155
+ describe("TODO comment on icon attribute", () => {
156
+ it("attaches install/verify TODO to the icon attribute", () => {
157
+ const input = `
158
+ import { Dropdown } from "@planningcenter/tapestry-react"
159
+
160
+ export default function Test() {
161
+ return <Dropdown icon={{ name: "general.pencil" }} title="Edit"><DropdownMenu /></Dropdown>
162
+ }
163
+ `.trim()
164
+
165
+ const result = applyTransform(input)
166
+ expect(result).toContain("TODO: tapestry-migration (icon):")
167
+ expect(result).toContain("@planningcenter/icons")
168
+ })
169
+ })
170
+
171
+ describe("dynamic icon fallback", () => {
172
+ it("passes dynamic icon as-is and attaches a verify TODO to the icon attribute", () => {
173
+ const input = `
174
+ import { Dropdown } from "@planningcenter/tapestry-react"
175
+
176
+ export default function Test() {
177
+ return <Dropdown icon={myIcon} title="Edit"><DropdownMenu /></Dropdown>
178
+ }
179
+ `.trim()
180
+
181
+ const result = applyTransform(input)
182
+ expect(result).toContain("<DropdownTrigger>")
183
+ expect(result).toContain("DropdownIconButton")
184
+ expect(result).toContain("myIcon")
185
+ expect(result).toContain("TODO: tapestry-migration (icon):")
186
+ expect(result).not.toContain('from "@planningcenter/icons/symbol"')
187
+ expect(result).not.toContain("general.svg")
188
+ })
189
+ })
190
+
191
+ describe("aria-label handling", () => {
192
+ it("uses title value as aria-label", () => {
193
+ const input = `
194
+ import { Dropdown } from "@planningcenter/tapestry-react"
195
+
196
+ export default function Test() {
197
+ return <Dropdown icon={{ name: "general.pencil" }} title="Bulk edit options"><DropdownMenu /></Dropdown>
198
+ }
199
+ `.trim()
200
+
201
+ const result = applyTransform(input)
202
+ expect(result).toContain('aria-label="Bulk edit options"')
203
+ })
204
+
205
+ it("uses dynamic title expression as aria-label", () => {
206
+ const input = `
207
+ import { Dropdown } from "@planningcenter/tapestry-react"
208
+
209
+ export default function Test() {
210
+ return <Dropdown icon={{ name: "general.pencil" }} title={menuTitle}><DropdownMenu /></Dropdown>
211
+ }
212
+ `.trim()
213
+
214
+ const result = applyTransform(input)
215
+ expect(result).toContain("aria-label={menuTitle}")
216
+ })
217
+
218
+ it("emits TODO and omits aria-label when no title is present", () => {
219
+ const input = `
220
+ import { Dropdown } from "@planningcenter/tapestry-react"
221
+
222
+ export default function Test() {
223
+ return <Dropdown icon={{ name: "general.pencil" }}><DropdownMenu /></Dropdown>
224
+ }
225
+ `.trim()
226
+
227
+ const result = applyTransform(input)
228
+ expect(result).toContain("DropdownIconButton")
229
+ expect(result).toContain("TODO: tapestry-migration (icon):")
230
+ expect(result).toContain("aria-label")
231
+ expect(result).not.toContain("aria-label=")
232
+ })
233
+ })
234
+
235
+ describe("prop handling", () => {
236
+ it("preserves other Dropdown props", () => {
237
+ const input = `
238
+ import { Dropdown } from "@planningcenter/tapestry-react"
239
+
240
+ export default function Test() {
241
+ return (
242
+ <Dropdown
243
+ icon={{ name: "general.pencil" }}
244
+ title="Edit"
245
+ placement="bottom-start"
246
+ disabled={disabled}
247
+ >
248
+ <DropdownMenu />
249
+ </Dropdown>
250
+ )
251
+ }
252
+ `.trim()
253
+
254
+ const result = applyTransform(input)
255
+ expect(result).toContain('placement="bottom-start"')
256
+ expect(result).toContain("disabled={disabled}")
257
+ })
258
+
259
+ it("does not touch the tapestry-react import", () => {
260
+ const input = `
261
+ import { Dropdown } from "@planningcenter/tapestry-react"
262
+
263
+ export default function Test() {
264
+ return <Dropdown icon={{ name: "general.pencil" }} title="Edit"><DropdownMenu /></Dropdown>
265
+ }
266
+ `.trim()
267
+
268
+ const result = applyTransform(input)
269
+ expect(result).toContain('from "@planningcenter/tapestry-react"')
270
+ })
271
+ })
272
+
273
+ describe("Symbol import conflict", () => {
274
+ it("uses aliased name when Symbol is already imported from another package", () => {
275
+ const input = `
276
+ import { Dropdown } from "@planningcenter/tapestry-react"
277
+ import { Symbol } from "some-other-package"
278
+
279
+ export default function Test() {
280
+ return <Dropdown icon={{ name: "general.pencil" }} title="Edit"><DropdownMenu /></Dropdown>
281
+ }
282
+ `.trim()
283
+
284
+ const result = applyTransform(input)
285
+ expect(result).toContain("<TSymbol")
286
+ expect(result).toContain("Symbol as TSymbol")
287
+ expect(result).not.toContain("icon={<Symbol")
288
+ })
289
+ })
290
+
291
+ describe("no changes scenarios", () => {
292
+ it("returns null when icon is absent", () => {
293
+ const input = `
294
+ import { Dropdown } from "@planningcenter/tapestry-react"
295
+
296
+ export default function Test() {
297
+ return <Dropdown title="Actions"><DropdownMenu /></Dropdown>
298
+ }
299
+ `.trim()
300
+
301
+ expect(applyTransform(input)).toBe(null)
302
+ })
303
+
304
+ it("returns null when triggerElement is present (deferred to wrapTrigger)", () => {
305
+ const input = `
306
+ import { Dropdown } from "@planningcenter/tapestry-react"
307
+
308
+ export default function Test() {
309
+ return (
310
+ <Dropdown icon={{ name: "general.pencil" }} triggerElement={<MyButton />}>
311
+ <DropdownMenu />
312
+ </Dropdown>
313
+ )
314
+ }
315
+ `.trim()
316
+
317
+ expect(applyTransform(input)).toBe(null)
318
+ })
319
+
320
+ it("returns null when Dropdown is not imported from tapestry-react", () => {
321
+ const input = `
322
+ import { Dropdown } from "some-other-library"
323
+
324
+ export default function Test() {
325
+ return <Dropdown icon={{ name: "general.pencil" }} title="Edit"><DropdownMenu /></Dropdown>
326
+ }
327
+ `.trim()
328
+
329
+ expect(applyTransform(input)).toBe(null)
330
+ })
331
+
332
+ it("returns null for empty file", () => {
333
+ expect(applyTransform("")).toBe(null)
334
+ })
335
+ })
336
+ })
@@ -0,0 +1,233 @@
1
+ import { Collection, JSCodeshift, JSXAttribute, Transform } from "jscodeshift"
2
+
3
+ import { addComment } from "../../shared/actions/addComment"
4
+ import { addCommentToAttribute } from "../../shared/actions/addCommentToAttribute"
5
+ import { getAttribute } from "../../shared/actions/getAttribute"
6
+ import { removeAttribute } from "../../shared/actions/removeAttribute"
7
+ import { andConditions } from "../../shared/conditions/andConditions"
8
+ import { hasAttribute } from "../../shared/conditions/hasAttribute"
9
+ import { notCondition } from "../../shared/conditions/notCondition"
10
+ import { attributeTransformFactory } from "../../shared/transformFactories/attributeTransformFactory"
11
+ import { addImport } from "../../shared/transformFactories/helpers/manageImports"
12
+
13
+ function parseIconName(name: string): { collection: string; symbolId: string } {
14
+ const dotIndex = name.indexOf(".")
15
+ if (dotIndex === -1) {
16
+ return {
17
+ collection: "general",
18
+ symbolId: name.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase(),
19
+ }
20
+ }
21
+ return {
22
+ collection: name.slice(0, dotIndex),
23
+ symbolId: name
24
+ .slice(dotIndex + 1)
25
+ .replace(/([a-z0-9])([A-Z])/g, "$1-$2")
26
+ .toLowerCase(),
27
+ }
28
+ }
29
+
30
+ function resolveStaticIconName(iconAttr: JSXAttribute): string | null {
31
+ const attrValue = iconAttr.value
32
+ if (!attrValue) return null
33
+
34
+ if (attrValue.type === "StringLiteral") {
35
+ return attrValue.value
36
+ }
37
+
38
+ if (
39
+ attrValue.type === "JSXExpressionContainer" &&
40
+ attrValue.expression.type === "ObjectExpression"
41
+ ) {
42
+ const nameProp = attrValue.expression.properties.find(
43
+ (prop) =>
44
+ prop.type === "ObjectProperty" &&
45
+ ((prop.key.type === "Identifier" && prop.key.name === "name") ||
46
+ (prop.key.type === "StringLiteral" && prop.key.value === "name")) &&
47
+ prop.value.type === "StringLiteral"
48
+ ) as { value: { value: string } } | undefined
49
+
50
+ if (nameProp) return nameProp.value.value
51
+ }
52
+
53
+ return null
54
+ }
55
+
56
+ function addSpriteImport(
57
+ source: Collection,
58
+ j: JSCodeshift,
59
+ collection: string
60
+ ): void {
61
+ const spritePath = `@planningcenter/icons/sprites/${collection}.svg`
62
+ const alreadyImported =
63
+ source
64
+ .find(j.ImportDeclaration, {
65
+ source: { value: spritePath },
66
+ })
67
+ .filter((path) =>
68
+ (path.value.specifiers ?? []).some(
69
+ (spec) => spec.type === "ImportDefaultSpecifier"
70
+ )
71
+ ).length > 0
72
+
73
+ if (alreadyImported) return
74
+
75
+ const importDecl = j.importDeclaration(
76
+ [j.importDefaultSpecifier(j.identifier(collection))],
77
+ j.stringLiteral(spritePath)
78
+ )
79
+
80
+ const lastImport = source.find(j.ImportDeclaration).at(-1)
81
+ if (lastImport.length > 0) {
82
+ lastImport.insertAfter(importDecl)
83
+ } else {
84
+ source.find(j.Program).get("body", 0).insertBefore(importDecl)
85
+ }
86
+ }
87
+
88
+ const transform: Transform = attributeTransformFactory({
89
+ condition: andConditions(
90
+ hasAttribute("icon"),
91
+ notCondition(hasAttribute("triggerElement"))
92
+ ),
93
+ targetComponent: "Dropdown",
94
+ targetPackage: "@planningcenter/tapestry-react",
95
+ transform: (element, { j, source }) => {
96
+ const iconAttr = getAttribute({ element, name: "icon" })
97
+ if (!iconAttr) return false
98
+
99
+ const titleAttr = getAttribute({ element, name: "title" })
100
+
101
+ const resolvedIconName = resolveStaticIconName(iconAttr)
102
+ const parsedIcon =
103
+ resolvedIconName !== null ? parseIconName(resolvedIconName) : null
104
+ let iconJSXAttr: JSXAttribute
105
+
106
+ if (parsedIcon !== null) {
107
+ const { collection, symbolId } = parsedIcon
108
+
109
+ const symbolName = addImport({
110
+ component: "Symbol",
111
+ fromPackage: "@planningcenter/tapestry-react",
112
+ j,
113
+ pkg: "@planningcenter/icons/symbol",
114
+ source,
115
+ })
116
+ addSpriteImport(source, j, collection)
117
+
118
+ const symbolTemplateLiteral = j.templateLiteral(
119
+ [
120
+ j.templateElement({ cooked: "", raw: "" }, false),
121
+ j.templateElement(
122
+ { cooked: `#${symbolId}`, raw: `#${symbolId}` },
123
+ true
124
+ ),
125
+ ],
126
+ [j.identifier(collection)]
127
+ )
128
+
129
+ const symbolElement = j.jsxElement(
130
+ j.jsxOpeningElement(
131
+ j.jsxIdentifier(symbolName),
132
+ [
133
+ j.jsxAttribute(
134
+ j.jsxIdentifier("symbol"),
135
+ j.jsxExpressionContainer(symbolTemplateLiteral)
136
+ ),
137
+ j.jsxAttribute(j.jsxIdentifier("aria-hidden")),
138
+ ],
139
+ true
140
+ ),
141
+ null,
142
+ []
143
+ )
144
+
145
+ iconJSXAttr = j.jsxAttribute(
146
+ j.jsxIdentifier("icon"),
147
+ j.jsxExpressionContainer(symbolElement)
148
+ )
149
+
150
+ addCommentToAttribute({
151
+ attribute: iconJSXAttr,
152
+ j,
153
+ text: `install @planningcenter/icons if not already a dependency; verify the symbol id against the ${collection} sprite`,
154
+ })
155
+ } else {
156
+ const originalExpr =
157
+ iconAttr.value?.type === "JSXExpressionContainer" &&
158
+ iconAttr.value.expression.type !== "JSXEmptyExpression"
159
+ ? iconAttr.value.expression
160
+ : j.identifier("undefined")
161
+
162
+ iconJSXAttr = j.jsxAttribute(
163
+ j.jsxIdentifier("icon"),
164
+ j.jsxExpressionContainer(originalExpr)
165
+ )
166
+ addCommentToAttribute({
167
+ attribute: iconJSXAttr,
168
+ j,
169
+ text: "verify this icon value and replace with <Symbol symbol={`${general}#<name>`} aria-hidden /> from @planningcenter/icons/symbol if needed",
170
+ })
171
+ }
172
+
173
+ let ariaLabelAttr: JSXAttribute | null = null
174
+ if (titleAttr?.value) {
175
+ ariaLabelAttr = j.jsxAttribute(
176
+ j.jsxIdentifier("aria-label"),
177
+ titleAttr.value
178
+ )
179
+ } else {
180
+ addComment({
181
+ element,
182
+ j,
183
+ scope: "icon",
184
+ source,
185
+ text: "aria-label is required on DropdownIconButton — add a descriptive label",
186
+ })
187
+ }
188
+
189
+ const iconButtonAttrs: JSXAttribute[] = []
190
+ if (ariaLabelAttr) iconButtonAttrs.push(ariaLabelAttr)
191
+ iconButtonAttrs.push(iconJSXAttr)
192
+
193
+ const iconButtonElement = j.jsxElement(
194
+ j.jsxOpeningElement(
195
+ j.jsxIdentifier("DropdownIconButton"),
196
+ iconButtonAttrs,
197
+ true
198
+ ),
199
+ null,
200
+ []
201
+ )
202
+
203
+ const triggerWrapper = j.jsxElement(
204
+ j.jsxOpeningElement(j.jsxIdentifier("DropdownTrigger"), [], false),
205
+ j.jsxClosingElement(j.jsxIdentifier("DropdownTrigger")),
206
+ [iconButtonElement]
207
+ )
208
+
209
+ removeAttribute("icon", { element, j, source })
210
+ removeAttribute("title", { element, j, source })
211
+
212
+ element.children = [triggerWrapper, ...(element.children ?? [])]
213
+
214
+ addImport({
215
+ component: "DropdownIconButton",
216
+ fromPackage: "@planningcenter/tapestry-react",
217
+ j,
218
+ pkg: "@planningcenter/tapestry",
219
+ source,
220
+ })
221
+ addImport({
222
+ component: "DropdownTrigger",
223
+ fromPackage: "@planningcenter/tapestry-react",
224
+ j,
225
+ pkg: "@planningcenter/tapestry",
226
+ source,
227
+ })
228
+
229
+ return true
230
+ },
231
+ })
232
+
233
+ export default transform