@adyen/eslint-plugin-bento 2.9.0 → 2.11.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 (47) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/configs/vue-recommended.js +7 -0
  3. package/configs/vue-strict.js +7 -0
  4. package/index.js +7 -0
  5. package/package.json +2 -2
  6. package/rules/deprecation-bento-data-grid-cell-action-menu-item-handler/README.md +48 -0
  7. package/rules/deprecation-bento-data-grid-cell-action-menu-item-handler/__tests__/deprecation-bento-data-grid-cell-action-menu-item-handler.vue +63 -0
  8. package/rules/deprecation-bento-data-grid-cell-action-menu-item-handler/__tests__/eslint.config.js +26 -0
  9. package/rules/deprecation-bento-data-grid-cell-action-menu-item-handler/deprecation-bento-data-grid-cell-action-menu-item-handler.integration.test.ts +24 -0
  10. package/rules/deprecation-bento-data-grid-cell-action-menu-item-handler/deprecation-bento-data-grid-cell-action-menu-item-handler.js +232 -0
  11. package/rules/deprecation-bento-data-grid-cell-action-menu-item-handler/deprecation-bento-data-grid-cell-action-menu-item-handler.test.ts +116 -0
  12. package/rules/deprecation-bento-popover-modifiers-prop/README.md +23 -0
  13. package/rules/deprecation-bento-popover-modifiers-prop/__tests__/deprecation-bento-popover-modifiers-prop.vue +9 -0
  14. package/rules/deprecation-bento-popover-modifiers-prop/__tests__/eslint.config.js +26 -0
  15. package/rules/deprecation-bento-popover-modifiers-prop/deprecation-bento-popover-modifiers-prop.integration.test.ts +24 -0
  16. package/rules/deprecation-bento-popover-modifiers-prop/deprecation-bento-popover-modifiers-prop.js +78 -0
  17. package/rules/deprecation-bento-popover-modifiers-prop/deprecation-bento-popover-modifiers-prop.test.ts +109 -0
  18. package/rules/deprecation-bento-timeline-item-variant-prop/README.md +25 -0
  19. package/rules/deprecation-bento-timeline-item-variant-prop/__tests__/deprecation-bento-timeline-item-variant-prop.vue +9 -0
  20. package/rules/deprecation-bento-timeline-item-variant-prop/__tests__/eslint.config.js +26 -0
  21. package/rules/deprecation-bento-timeline-item-variant-prop/deprecation-bento-timeline-item-variant-prop.integration.test.ts +31 -0
  22. package/rules/deprecation-bento-timeline-item-variant-prop/deprecation-bento-timeline-item-variant-prop.js +78 -0
  23. package/rules/deprecation-bento-timeline-item-variant-prop/deprecation-bento-timeline-item-variant-prop.test.ts +66 -0
  24. package/rules/deprecation-components-aria-label-prop/README.md +62 -0
  25. package/rules/deprecation-components-aria-label-prop/__tests__/deprecation-components-aria-label-prop.vue +25 -0
  26. package/rules/deprecation-components-aria-label-prop/__tests__/eslint.config.js +26 -0
  27. package/rules/deprecation-components-aria-label-prop/deprecation-components-aria-label-prop.integration.test.ts +36 -0
  28. package/rules/deprecation-components-aria-label-prop/deprecation-components-aria-label-prop.js +79 -0
  29. package/rules/deprecation-components-aria-label-prop/deprecation-components-aria-label-prop.test.ts +194 -0
  30. package/rules/deprecation-components-has-error-prop/README.md +34 -0
  31. package/rules/deprecation-components-has-error-prop/__tests__/deprecation-components-has-error-prop.vue +13 -0
  32. package/rules/deprecation-components-has-error-prop/__tests__/eslint.config.js +26 -0
  33. package/rules/deprecation-components-has-error-prop/deprecation-components-has-error-prop.integration.test.ts +29 -0
  34. package/rules/deprecation-components-has-error-prop/deprecation-components-has-error-prop.js +77 -0
  35. package/rules/deprecation-components-has-error-prop/deprecation-components-has-error-prop.test.ts +133 -0
  36. package/rules/deprecation-country-custom-usage/README.md +151 -0
  37. package/rules/deprecation-country-custom-usage/__tests__/deprecation-country-custom-usage.vue +93 -0
  38. package/rules/deprecation-country-custom-usage/__tests__/eslint.config.js +25 -0
  39. package/rules/deprecation-country-custom-usage/deprecation-country-custom-usage.integration.test.ts +22 -0
  40. package/rules/deprecation-country-custom-usage/deprecation-country-custom-usage.js +569 -0
  41. package/rules/deprecation-country-custom-usage/deprecation-country-custom-usage.test.ts +380 -0
  42. package/rules/deprecation-input-filter-dropdown-options/README.md +44 -0
  43. package/rules/deprecation-input-filter-dropdown-options/__tests__/deprecation-input-filter-dropdown-options.vue +46 -0
  44. package/rules/deprecation-input-filter-dropdown-options/__tests__/eslint.config.js +26 -0
  45. package/rules/deprecation-input-filter-dropdown-options/deprecation-input-filter-dropdown-options.integration.test.ts +27 -0
  46. package/rules/deprecation-input-filter-dropdown-options/deprecation-input-filter-dropdown-options.js +135 -0
  47. package/rules/deprecation-input-filter-dropdown-options/deprecation-input-filter-dropdown-options.test.ts +392 -0
@@ -0,0 +1,569 @@
1
+ /**
2
+ * @typedef {import('eslint').Rule.RuleContext} RuleContext
3
+ */
4
+ /**
5
+ * @typedef {import('eslint-plugin-vue/lib/utils').RuleModule} RuleModule
6
+ */
7
+
8
+ const VueUtils = require('eslint-plugin-vue/lib/utils');
9
+
10
+ /** @type {RuleModule} */
11
+ module.exports = {
12
+ meta: {
13
+ type: 'suggestion',
14
+ docs: {
15
+ description:
16
+ 'Deprecates "custom" variant / custom label usage across bento-country, bento-dropdown-country, and SELECT_COUNTRY filter config.',
17
+ category: 'Deprecations',
18
+ url: 'https://github.com/Adyen/bento/blob/main/packages/eslint/rules/deprecation-country-custom-usage/README.md',
19
+ recommended: true,
20
+ },
21
+ messages: {
22
+ countryCustomVariant:
23
+ 'The "bento-country" component with variant "custom" is deprecated. Use <bento-typography el="span"> instead.',
24
+ countryUnusedLabel:
25
+ 'The "label" prop on "bento-country" is only used with variant "custom" which is deprecated. Remove the "label" prop.',
26
+ dropdownCountryCustomLabel:
27
+ 'The "bento-dropdown-country" component with custom "label" in items is deprecated. Use "bento-dropdown" instead.',
28
+ dropdownCountryCustomVariant:
29
+ 'The "variant" prop with value "custom" on "bento-dropdown-country" is deprecated. Remove the "variant" prop.',
30
+ selectCountryCustom:
31
+ 'The "{{ key }}" property in SELECT_COUNTRY filter options indicates custom usage. Use "BentoFilterItemType.SELECT" instead of "SELECT_COUNTRY".',
32
+ },
33
+ fixable: 'code',
34
+ schema: [
35
+ {
36
+ type: 'object',
37
+ properties: {
38
+ autofix: {
39
+ type: 'boolean',
40
+ default: false,
41
+ },
42
+ },
43
+ additionalProperties: false,
44
+ },
45
+ ],
46
+ },
47
+
48
+ /** @param {RuleContext} context */
49
+ create(context) {
50
+ const options = context.options[0] || {};
51
+ const autofix = options.autofix === true;
52
+
53
+ function getStaticPropertyName(prop) {
54
+ if (prop.type !== 'Property' || prop.computed) {
55
+ return null;
56
+ }
57
+ return prop.key.type === 'Identifier'
58
+ ? prop.key.name
59
+ : prop.key.type === 'Literal'
60
+ ? String(prop.key.value)
61
+ : null;
62
+ }
63
+
64
+ function removeAttribute(fixer, attr) {
65
+ const sourceCode = context.getSourceCode();
66
+ const src = sourceCode.getText();
67
+ // Walk backwards from the attribute to consume preceding whitespace
68
+ let start = attr.range[0];
69
+ while (start > 0 && (src[start - 1] === ' ' || src[start - 1] === '\t')) {
70
+ start--;
71
+ }
72
+ return fixer.removeRange([start, attr.range[1]]);
73
+ }
74
+
75
+ function removeProperty(fixer, node, prop) {
76
+ const properties = node.properties;
77
+ const index = properties.indexOf(prop);
78
+ const isLast = index === properties.length - 1;
79
+
80
+ if (properties.length === 1) {
81
+ return fixer.replaceText(node, '{}');
82
+ }
83
+ if (isLast) {
84
+ const prev = properties[index - 1];
85
+ return fixer.removeRange([prev.range[1], prop.range[1]]);
86
+ }
87
+ const next = properties[index + 1];
88
+ return fixer.removeRange([prop.range[0], next.range[0]]);
89
+ }
90
+
91
+ function removeLabelsFromArray(fixer, arrayNode) {
92
+ const fixes = [];
93
+ for (const element of arrayNode.elements) {
94
+ if (!element || element.type !== 'ObjectExpression') {
95
+ continue;
96
+ }
97
+ for (const prop of element.properties) {
98
+ if (getStaticPropertyName(prop) === 'label') {
99
+ fixes.push(removeProperty(fixer, element, prop));
100
+ }
101
+ }
102
+ }
103
+ return fixes;
104
+ }
105
+
106
+ /* ------------------------------------------------ */
107
+ /* CASE 1: <bento-country variant="custom"> */
108
+ /* ------------------------------------------------ */
109
+
110
+ function isCustomVariantValue(expression) {
111
+ if (!expression) {
112
+ return false;
113
+ }
114
+ if (expression.type === 'Literal' && expression.value === 'custom') {
115
+ return true;
116
+ }
117
+ if (
118
+ expression.type === 'MemberExpression' &&
119
+ expression.object?.type === 'Identifier' &&
120
+ expression.object.name === 'BentoCountryVariant' &&
121
+ expression.property?.type === 'Identifier' &&
122
+ expression.property.name === 'CUSTOM'
123
+ ) {
124
+ return true;
125
+ }
126
+ return false;
127
+ }
128
+
129
+ function replaceWithTypography(fixer, element) {
130
+ const sourceCode = context.getSourceCode();
131
+ const startTag = element.startTag;
132
+ const endTag = element.endTag;
133
+
134
+ const labelAttr = findLabelAttribute(element);
135
+
136
+ let content = '';
137
+ if (labelAttr) {
138
+ if (labelAttr.directive) {
139
+ content = `{{ ${sourceCode.getText(labelAttr.value.expression)} }}`;
140
+ } else {
141
+ content = labelAttr.value.value;
142
+ }
143
+ }
144
+
145
+ if (!endTag) {
146
+ return fixer.replaceTextRange(
147
+ element.range,
148
+ `<bento-typography el="span">${content}</bento-typography>`
149
+ );
150
+ }
151
+
152
+ const childrenSource = sourceCode.text.slice(startTag.range[1], endTag.range[0]);
153
+ const body = content || childrenSource;
154
+ return fixer.replaceTextRange(element.range, `<bento-typography el="span">${body}</bento-typography>`);
155
+ }
156
+
157
+ /* ------------------------------------------------ */
158
+ /* CASE 2: <bento-dropdown-country :items="[...]"> */
159
+ /* ------------------------------------------------ */
160
+
161
+ function checkItemsForCustomLabel(node) {
162
+ if (node.type !== 'ArrayExpression') {
163
+ return false;
164
+ }
165
+ return node.elements.some(element => {
166
+ if (!element || element.type !== 'ObjectExpression') {
167
+ return false;
168
+ }
169
+ return element.properties.some(prop => {
170
+ const keyName = getStaticPropertyName(prop);
171
+ return keyName === 'label';
172
+ });
173
+ });
174
+ }
175
+
176
+ function getItemsExpression(element) {
177
+ const itemsAttr = element.startTag.attributes.find(
178
+ attr => attr.directive && attr.key?.name?.name === 'bind' && attr.key?.argument?.name === 'items'
179
+ );
180
+ return itemsAttr?.value?.expression;
181
+ }
182
+
183
+ function replaceDropdownCountryTag(fixer, element) {
184
+ const startTag = element.startTag;
185
+ const openTagName = element.rawName;
186
+ const endTag = element.endTag;
187
+ const sourceCode = context.getSourceCode();
188
+
189
+ const fixes = [
190
+ fixer.replaceTextRange(
191
+ [startTag.range[0] + 1, startTag.range[0] + 1 + openTagName.length],
192
+ 'bento-dropdown'
193
+ ),
194
+ ];
195
+
196
+ // Remove the variant="custom" attribute since it's meaningless on bento-dropdown
197
+ const variantAttr = findVariantAttribute(element);
198
+ if (variantAttr) {
199
+ fixes.push(removeAttribute(fixer, variantAttr));
200
+ }
201
+
202
+ if (endTag) {
203
+ const endTagSource = sourceCode.getText(endTag);
204
+ const closingTagName = endTagSource.match(/<\/\s*([^\s>]+)/)?.[1];
205
+ if (closingTagName) {
206
+ fixes.push(
207
+ fixer.replaceTextRange(
208
+ [endTag.range[0] + 2, endTag.range[0] + 2 + closingTagName.length],
209
+ 'bento-dropdown'
210
+ )
211
+ );
212
+ }
213
+ }
214
+
215
+ return fixes;
216
+ }
217
+
218
+ /* ------------------------------------------------ */
219
+ /* CASE 3: Filter config SELECT_COUNTRY with custom */
220
+ /* ------------------------------------------------ */
221
+
222
+ function isSelectCountryFilterType(valueNode) {
223
+ if (
224
+ valueNode.type === 'MemberExpression' &&
225
+ valueNode.object?.type === 'Identifier' &&
226
+ valueNode.object.name === 'BentoFilterItemType' &&
227
+ valueNode.property?.type === 'Identifier' &&
228
+ valueNode.property.name === 'SELECT_COUNTRY'
229
+ ) {
230
+ return true;
231
+ }
232
+ if (valueNode.type === 'Literal' && valueNode.value === 'BentoSelectCountryFilter') {
233
+ return true;
234
+ }
235
+ return false;
236
+ }
237
+
238
+ function hasCustomUsageInOptions(optionsNode) {
239
+ if (!optionsNode || optionsNode.type !== 'ObjectExpression') {
240
+ return { hasCustomLabel: false, hasCustomVariant: false, variantProp: null, listboxItemsNode: null };
241
+ }
242
+
243
+ let hasCustomLabel = false;
244
+ let hasCustomVariant = false;
245
+ let variantProp = null;
246
+ let listboxItemsNode = null;
247
+
248
+ for (const prop of optionsNode.properties) {
249
+ const keyName = getStaticPropertyName(prop);
250
+
251
+ if (keyName === 'variant') {
252
+ if (prop.value.type === 'Literal' && prop.value.value === 'custom') {
253
+ hasCustomVariant = true;
254
+ variantProp = prop;
255
+ } else if (
256
+ prop.value.type === 'MemberExpression' &&
257
+ prop.value.property?.type === 'Identifier' &&
258
+ prop.value.property.name === 'CUSTOM'
259
+ ) {
260
+ hasCustomVariant = true;
261
+ variantProp = prop;
262
+ }
263
+ }
264
+
265
+ if (keyName === 'listboxItems' && prop.value.type === 'ArrayExpression') {
266
+ listboxItemsNode = prop.value;
267
+ hasCustomLabel = prop.value.elements.some(el => {
268
+ if (!el || el.type !== 'ObjectExpression') {
269
+ return false;
270
+ }
271
+ return el.properties.some(p => getStaticPropertyName(p) === 'label');
272
+ });
273
+ }
274
+ }
275
+
276
+ return { hasCustomLabel, hasCustomVariant, variantProp, listboxItemsNode };
277
+ }
278
+
279
+ function checkFilterConfigObject(node) {
280
+ if (node.type !== 'ObjectExpression') {
281
+ return;
282
+ }
283
+
284
+ let typeValueNode = null;
285
+ let optionsNode = null;
286
+
287
+ for (const prop of node.properties) {
288
+ const keyName = getStaticPropertyName(prop);
289
+
290
+ if (keyName === 'type' && isSelectCountryFilterType(prop.value)) {
291
+ typeValueNode = prop.value;
292
+ }
293
+
294
+ if (keyName === 'options' && prop.value.type === 'ObjectExpression') {
295
+ optionsNode = prop.value;
296
+ }
297
+ }
298
+
299
+ if (!typeValueNode || !optionsNode) {
300
+ return;
301
+ }
302
+
303
+ const { hasCustomLabel, hasCustomVariant, variantProp, listboxItemsNode } =
304
+ hasCustomUsageInOptions(optionsNode);
305
+
306
+ if (hasCustomVariant) {
307
+ context.report({
308
+ node: optionsNode,
309
+ messageId: 'selectCountryCustom',
310
+ data: { key: 'variant' },
311
+ ...(autofix && {
312
+ fix(fixer) {
313
+ if (hasCustomLabel) {
314
+ // Both present: change type to SELECT and remove variant
315
+ const fixes = [removeProperty(fixer, optionsNode, variantProp)];
316
+ if (typeValueNode.type === 'MemberExpression') {
317
+ fixes.push(fixer.replaceText(typeValueNode.property, 'SELECT'));
318
+ } else {
319
+ fixes.push(fixer.replaceText(typeValueNode, "'BentoSelectFilter'"));
320
+ }
321
+ return fixes;
322
+ }
323
+ // Variant only: remove the variant property
324
+ return removeProperty(fixer, optionsNode, variantProp);
325
+ },
326
+ }),
327
+ });
328
+ }
329
+
330
+ if (hasCustomLabel) {
331
+ context.report({
332
+ node: optionsNode,
333
+ messageId: 'selectCountryCustom',
334
+ data: { key: 'listboxItems with label' },
335
+ ...(autofix && {
336
+ fix(fixer) {
337
+ if (hasCustomVariant) {
338
+ // Both present: change type to SELECT and remove variant
339
+ const fixes = [removeProperty(fixer, optionsNode, variantProp)];
340
+ if (typeValueNode.type === 'MemberExpression') {
341
+ fixes.push(fixer.replaceText(typeValueNode.property, 'SELECT'));
342
+ } else {
343
+ fixes.push(fixer.replaceText(typeValueNode, "'BentoSelectFilter'"));
344
+ }
345
+ return fixes;
346
+ }
347
+ // Label only: remove label properties from listboxItems
348
+ return removeLabelsFromArray(fixer, listboxItemsNode);
349
+ },
350
+ }),
351
+ });
352
+ }
353
+ }
354
+
355
+ /* ------------------------------------------------ */
356
+ /* Visitors */
357
+ /* ------------------------------------------------ */
358
+
359
+ function hasCustomVariantAttribute(element) {
360
+ return element.startTag.attributes.some(attr => {
361
+ if (!attr.directive && attr.key.name === 'variant' && attr.value?.value === 'custom') {
362
+ return true;
363
+ }
364
+ if (attr.directive && attr.key?.name?.name === 'bind' && attr.key?.argument?.name === 'variant') {
365
+ return isCustomVariantValue(attr.value?.expression);
366
+ }
367
+ return false;
368
+ });
369
+ }
370
+
371
+ function findVariantAttribute(element) {
372
+ return element.startTag.attributes.find(attr => {
373
+ if (!attr.directive && attr.key.name === 'variant') {
374
+ return true;
375
+ }
376
+ if (attr.directive && attr.key?.name?.name === 'bind' && attr.key?.argument?.name === 'variant') {
377
+ return true;
378
+ }
379
+ return false;
380
+ });
381
+ }
382
+
383
+ function findLabelAttribute(element) {
384
+ return element.startTag.attributes.find(attr => {
385
+ if (!attr.directive && attr.key.name === 'label') {
386
+ return true;
387
+ }
388
+ if (attr.directive && attr.key?.name?.name === 'bind' && attr.key?.argument?.name === 'label') {
389
+ return true;
390
+ }
391
+ return false;
392
+ });
393
+ }
394
+
395
+ function hasLabelAttribute(element) {
396
+ return !!findLabelAttribute(element);
397
+ }
398
+
399
+ const countryComponentSelector = `VElement:matches([name="bento-country"], [name="BentoCountry"])`;
400
+ const dropdownCountrySelector = `VElement:matches([name="bento-dropdown-country"], [name="BentoDropdownCountry"], [name="b-dropdown-country"])`;
401
+
402
+ const filterConfigVisitor = {
403
+ ObjectExpression(node) {
404
+ checkFilterConfigObject(node);
405
+ },
406
+ };
407
+
408
+ return VueUtils.defineTemplateBodyVisitor(
409
+ context,
410
+ {
411
+ // CASE 1a: static variant="custom" on bento-country
412
+ [`${countryComponentSelector} > VStartTag > VAttribute[directive=false][key.name="variant"][value.value="custom"]`](
413
+ node
414
+ ) {
415
+ const element = node.parent.parent;
416
+ const hasLabel = hasLabelAttribute(element);
417
+ context.report({
418
+ node: element,
419
+ messageId: 'countryCustomVariant',
420
+ ...(autofix && {
421
+ fix(fixer) {
422
+ if (hasLabel) {
423
+ return replaceWithTypography(fixer, element);
424
+ }
425
+ // variant="custom" only: remove the variant attribute
426
+ return removeAttribute(fixer, node);
427
+ },
428
+ }),
429
+ });
430
+ },
431
+
432
+ // CASE 1b: dynamic :variant="'custom'" or :variant="BentoCountryVariant.CUSTOM" on bento-country
433
+ [`${countryComponentSelector} > VStartTag > VAttribute[directive=true][key.name.name="bind"][key.argument.name="variant"]`](
434
+ node
435
+ ) {
436
+ const expression = node.value?.expression;
437
+ if (isCustomVariantValue(expression)) {
438
+ const element = node.parent.parent;
439
+ const hasLabel = hasLabelAttribute(element);
440
+ context.report({
441
+ node: element,
442
+ messageId: 'countryCustomVariant',
443
+ ...(autofix && {
444
+ fix(fixer) {
445
+ if (hasLabel) {
446
+ return replaceWithTypography(fixer, element);
447
+ }
448
+ // variant="custom" only: remove the variant attribute
449
+ return removeAttribute(fixer, node);
450
+ },
451
+ }),
452
+ });
453
+ }
454
+ },
455
+
456
+ // CASE 1c: label prop on bento-country without variant="custom"
457
+ [`${countryComponentSelector} > VStartTag > VAttribute[directive=false][key.name="label"]`](node) {
458
+ const element = node.parent.parent;
459
+ if (!hasCustomVariantAttribute(element)) {
460
+ context.report({
461
+ node: element,
462
+ messageId: 'countryUnusedLabel',
463
+ ...(autofix && {
464
+ fix(fixer) {
465
+ return removeAttribute(fixer, node);
466
+ },
467
+ }),
468
+ });
469
+ }
470
+ },
471
+
472
+ // CASE 1d: dynamic :label on bento-country without variant="custom"
473
+ [`${countryComponentSelector} > VStartTag > VAttribute[directive=true][key.name.name="bind"][key.argument.name="label"]`](
474
+ node
475
+ ) {
476
+ const element = node.parent.parent;
477
+ if (!hasCustomVariantAttribute(element)) {
478
+ context.report({
479
+ node: element,
480
+ messageId: 'countryUnusedLabel',
481
+ ...(autofix && {
482
+ fix(fixer) {
483
+ return removeAttribute(fixer, node);
484
+ },
485
+ }),
486
+ });
487
+ }
488
+ },
489
+
490
+ // CASE 2a: :items with label on bento-dropdown-country
491
+ [`${dropdownCountrySelector} > VStartTag > VAttribute[directive=true][key.name.name="bind"][key.argument.name="items"]`](
492
+ node
493
+ ) {
494
+ const valueNode = node.value?.expression;
495
+ if (!valueNode) {
496
+ return;
497
+ }
498
+
499
+ if (checkItemsForCustomLabel(valueNode)) {
500
+ const element = node.parent.parent;
501
+ const hasCustomVariant = hasCustomVariantAttribute(element);
502
+ context.report({
503
+ node: element,
504
+ messageId: 'dropdownCountryCustomLabel',
505
+ ...(autofix && {
506
+ fix(fixer) {
507
+ if (hasCustomVariant) {
508
+ return replaceDropdownCountryTag(fixer, element);
509
+ }
510
+ // Label only: remove labels from items
511
+ return removeLabelsFromArray(fixer, valueNode);
512
+ },
513
+ }),
514
+ });
515
+ }
516
+ },
517
+
518
+ // CASE 2b: variant="custom" on bento-dropdown-country
519
+ [`${dropdownCountrySelector} > VStartTag > VAttribute[directive=false][key.name="variant"][value.value="custom"]`](
520
+ node
521
+ ) {
522
+ const element = node.parent.parent;
523
+ const itemsExpr = getItemsExpression(element);
524
+ const hasLabel = itemsExpr ? checkItemsForCustomLabel(itemsExpr) : false;
525
+ if (!hasLabel) {
526
+ context.report({
527
+ node: element,
528
+ messageId: 'dropdownCountryCustomVariant',
529
+ ...(autofix && {
530
+ fix(fixer) {
531
+ return removeAttribute(fixer, node);
532
+ },
533
+ }),
534
+ });
535
+ }
536
+ },
537
+
538
+ // CASE 2c: dynamic :variant="'custom'" on bento-dropdown-country
539
+ [`${dropdownCountrySelector} > VStartTag > VAttribute[directive=true][key.name.name="bind"][key.argument.name="variant"]`](
540
+ node
541
+ ) {
542
+ const expression = node.value?.expression;
543
+ if (!isCustomVariantValue(expression)) {
544
+ return;
545
+ }
546
+ const element = node.parent.parent;
547
+ const itemsExpr = getItemsExpression(element);
548
+ const hasLabel = itemsExpr ? checkItemsForCustomLabel(itemsExpr) : false;
549
+ if (!hasLabel) {
550
+ context.report({
551
+ node: element,
552
+ messageId: 'dropdownCountryCustomVariant',
553
+ ...(autofix && {
554
+ fix(fixer) {
555
+ return removeAttribute(fixer, node);
556
+ },
557
+ }),
558
+ });
559
+ }
560
+ },
561
+
562
+ // CASE 3: inline filter config in template
563
+ ...filterConfigVisitor,
564
+ },
565
+ // CASE 3: script-defined filter config
566
+ filterConfigVisitor
567
+ );
568
+ },
569
+ };