Repository: azat-io/eslint-plugin-perfectionist
Pull request #495 (2025-03-20 13:46:44)

feat(sort-jsx-props): migrate `groups`-related option to new API
- Resolves partially https://github.com/azat-io/eslint-plugin-perfectionist/issues/493

## Description

This PR allows `sort-jsx-props` to handle the new `customGroups` API.

### Selector

- `prop`
- [DEPRECATED] `multiline`: allows keeping the existing `multiline` group.
- [DEPRECATED] `shorthand`: allows keeping the existing `shorthand` group.

### Modifiers

- `multiline`
- `shorthand`

### New `customGroups` API

The old `customGroups` object-based API is still handled.

Handles:
- `elementNamePattern`.
- `elementValuePattern`: can match arrow functions with `"\(\s*.*\s*\)\s*=>"`
  - Detecting function expressions in JSX props is not straightfoward: it requires us to use the RegExp above. I haven't put a `method` selector in this PR yet.

## What is the purpose of this pull request?

- [x] New Feature


codecov[bot]: ## [Codecov](https://app.codecov.io/gh/azat-io/eslint-plugin-perfectionist/pull/495?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Azat+S.) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 100.00%. Comparing base [(`305a418`)](https://app.codecov.io/gh/azat-io/eslint-plugin-perfectionist/commit/305a418e6b221edfad539ee67da0f60f840a25b0?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Azat+S.) to head [(`4c1f0fa`)](https://app.codecov.io/gh/azat-io/eslint-plugin-perfectionist/commit/4c1f0fadb1e1199ba4af28fc0d674a9afc247257?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Azat+S.).

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff            @@
##              main      #495   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           94        95    +1     
  Lines         7628      7646   +18     
  Branches      1639      1642    +3     
=========================================
+ Hits          7628      7646   +18     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/azat-io/eslint-plugin-perfectionist/pull/495?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Azat+S.).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Azat+S.).

<details><summary>🚀 New features to boost your workflow: </summary>

- ❄ [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
- 📦 [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>

Patch:
diff --git a/astro.config.ts b/astro.config.ts
index 986cc66..2b9e098 100644
--- a/astro.config.ts
+++ b/astro.config.ts
@@ -43,9 +43,9 @@ export default defineConfig({
     css: {
       lightningcss: {
         targets: browserslistToTargets(
-          browserslist(null, {
-            config: path.join(dirname, './.browserslistrc'),
-          }),
+          browserslist(
+            browserslist.loadConfig({ path: '.' }) ?? browserslist.defaults,
+          ),
         ),
       },
       transformer: 'lightningcss',
diff --git a/docs/content/rules/sort-array-includes.mdx b/docs/content/rules/sort-array-includes.mdx
index ec337d8..ecfa1da 100644
--- a/docs/content/rules/sort-array-includes.mdx
+++ b/docs/content/rules/sort-array-includes.mdx
@@ -378,6 +378,7 @@ interface CustomGroupDefinition {
   type?: 'alphabetical' | 'natural' | 'line-length' | 'unsorted'
   order?: 'asc' | 'desc'
   fallbackSort?: { type: string; order?: 'asc' | 'desc' }
+  newlinesInside?: 'always' | 'never'
   selector?: string
   elementNamePattern?: string | string[] | { pattern: string; flags?: string } | { pattern: string; flags?: string }[]
 }
@@ -393,6 +394,7 @@ interface CustomGroupAnyOfDefinition {
   type?: 'alphabetical' | 'natural' | 'line-length' | 'unsorted'
   order?: 'asc' | 'desc'
   fallbackSort?: { type: string; order?: 'asc' | 'desc' }
+  newlinesInside?: 'always' | 'never'
   anyOf: Array<{
       selector?: string
       elementNamePattern?: string | string[] | { pattern: string; flags?: string } | { pattern: string; flags?: string }[]
@@ -410,6 +412,7 @@ An array element will match a `CustomGroupAnyOfDefinition` group if it matches a
 - `type`: Overrides the sort type for that custom group. `unsorted` will not sort the group.
 - `order`: Overrides the sort order for that custom group
 - `fallbackSort`: Overrides the fallbackSort option for that custom group
+- `newlinesInside`: Enforces a specific newline behavior between elements of the group.
 
 #### Match importance
 
diff --git a/docs/content/rules/sort-enums.mdx b/docs/content/rules/sort-enums.mdx
index 60439da..7b66bd4 100644
--- a/docs/content/rules/sort-enums.mdx
+++ b/docs/content/rules/sort-enums.mdx
@@ -292,6 +292,7 @@ interface CustomGroupDefinition {
   type?: 'alphabetical' | 'natural' | 'line-length' | 'unsorted'
   order?: 'asc' | 'desc'
   fallbackSort?: { type: string; order?: 'asc' | 'desc' }
+  newlinesInside?: 'always' | 'never'
   elementNamePattern?: string | string[] | { pattern: string; flags?: string } | { pattern: string; flags?: string }[]
 }
 
@@ -306,6 +307,7 @@ interface CustomGroupAnyOfDefinition {
   type?: 'alphabetical' | 'natural' | 'line-length' | 'unsorted'
   order?: 'asc' | 'desc'
   fallbackSort?: { type: string; order?: 'asc' | 'desc' }
+  newlinesInside?: 'always' | 'never'
   anyOf: Array<{
       elementNamePattern?: string | string[] | { pattern: string; flags?: string } | { pattern: string; flags?: string }[]
   }>
@@ -322,6 +324,7 @@ An enum member will match a `CustomGroupAnyOfDefinition` group if it matches all
 - `type`: Overrides the sort type for that custom group. `unsorted` will not sort the group.
 - `order`: Overrides the sort order for that custom group
 - `fallbackSort`: Overrides the fallbackSort option for that custom group
+- `newlinesInside`: Enforces a specific newline behavior between elements of the group.
 
 #### Match importance
 
diff --git a/docs/content/rules/sort-exports.mdx b/docs/content/rules/sort-exports.mdx
index 1612b52..c8dc749 100644
--- a/docs/content/rules/sort-exports.mdx
+++ b/docs/content/rules/sort-exports.mdx
@@ -294,6 +294,7 @@ interface CustomGroupDefinition {
   type?: 'alphabetical' | 'natural' | 'line-length' | 'unsorted'
   order?: 'asc' | 'desc'
   fallbackSort?: { type: string; order?: 'asc' | 'desc' }
+  newlinesInside?: 'always' | 'never'
   selector?: string
   elementNamePattern?: string | string[] | { pattern: string; flags?: string } | { pattern: string; flags?: string }[]
 }
@@ -309,6 +310,7 @@ interface CustomGroupAnyOfDefinition {
   type?: 'alphabetical' | 'natural' | 'line-length' | 'unsorted'
   order?: 'asc' | 'desc'
   fallbackSort?: { type: string; order?: 'asc' | 'desc' }
+  newlinesInside?: 'always' | 'never'
   anyOf: Array<{
       selector?: string
       elementNamePattern?: string | string[] | { pattern: string; flags?: string } | { pattern: string; flags?: string }[]
@@ -326,6 +328,7 @@ An export will match a `CustomGroupAnyOfDefinition` group if it matches all the
 - `type`: Overrides the sort type for that custom group. `unsorted` will not sort the group.
 - `order`: Overrides the sort order for that custom group
 - `fallbackSort`: Overrides the fallbackSort option for that custom group
+- `newlinesInside`: Enforces a specific newline behavior between elements of the group.
 
 #### Match importance
 
diff --git a/docs/content/rules/sort-jsx-props.mdx b/docs/content/rules/sort-jsx-props.mdx
index d88b13e..037ad4e 100644
--- a/docs/content/rules/sort-jsx-props.mdx
+++ b/docs/content/rules/sort-jsx-props.mdx
@@ -234,7 +234,7 @@ Use the [useConfigurationIf.tagMatchesPattern](#useconfigurationif) option along
 
 Allows you to specify names or patterns for JSX elements that should be ignored by this rule. This can be useful if you have specific components that you do not want to sort.
 
-You can specify their names or a regexp pattern to ignore, for example: `'^Table.+'` to ignore all object types whose names begin with the word Table.
+You can specify their names or a regexp pattern to ignore, for example: `'^Table.+'` to ignore all JSX elements whose names begin with the word Table.
 
 ### partitionByNewLine
 
@@ -242,6 +242,22 @@ You can specify their names or a regexp pattern to ignore, for example: `'^Table
 
 When `true`, the rule will not sort members if there is an empty line between them. This can be useful for keeping logically separated groups of members in their defined order.
 
+### newlinesBetween
+
+<sub>default: `'ignore'`</sub>
+
+Specifies how new lines should be handled between groups.
+
+- `ignore` — Do not report errors related to new lines.
+- `always` — Enforce one new line between each group, and forbid new lines inside a group.
+- `never` — No new lines are allowed.
+
+You can also enforce the newline behavior between two specific groups through the `groups` options.
+
+See the [`groups`](#newlines-between-groups) option.
+
+This option is only applicable when `partitionByNewLine` is `false`.
+
 ### useConfigurationIf
 
 <sub>
@@ -255,7 +271,7 @@ When `true`, the rule will not sort members if there is an empty line between th
 </sub>
 <sub>default: `{}`</sub>
 
-Allows you to specify filters to match a particular options configuration for a given object type.
+Allows you to specify filters to match a particular options configuration for a given JSX element.
 
 The first matching options configuration will be used. If no configuration matches, the default options configuration will be used.
 
@@ -313,15 +329,7 @@ Example configuration:
 
 Allows you to specify a list of JSX props groups for sorting. Groups help organize props into categories, making your components more readable and maintainable.
 
-Predefined groups:
-
-- `'multiline'` — Props with multiline values.
-- `'shorthand'` — Shorthand props, which are used without a value, typically for boolean props.
-- `'unknown'` — Props that don’t fit into any group specified in the `groups` option.
-
-If the `unknown` group is not specified in the `groups` option, it will automatically be added to the end of the list.
-
-Each JSX prop will be assigned a single group specified in the `groups` option (or the `unknown` group if no match is found).
+Each element will be assigned a single group specified in the `groups` option (or the `unknown` group if no match is found).
 The order of items in the `groups` option determines how groups are ordered.
 
 Within a given group, members will be sorted according to the `type`, `order`, `ignoreCase`, etc. options.
@@ -329,6 +337,19 @@ Within a given group, members will be sorted according to the `type`, `order`, `
 Individual groups can be combined together by placing them in an array. The order of groups in that array does not matter.
 All members of the groups in the array will be sorted together as if they were part of a single group.
 
+Predefined groups are characterized by a single selector and potentially multiple modifiers. You may enter modifiers in any order, but the selector must always come at the end.
+
+##### List of selectors
+
+The only selector possible for this rule is `prop`.
+
+#### Modifiers
+
+- `multiline`: Matches multiline props.
+- `shorthand`: Matches shorthand props, which are used without a value, typically for boolean props.
+
+Example: `shorthand-prop`.
+
 #### Newlines between groups
 
 You may place `newlinesBetween` objects between your groups to enforce the newline behavior between two specific groups.
@@ -350,33 +371,114 @@ This feature is only applicable when `partitionByNewLine` is false.
 
 ### customGroups
 
+<Important title="Migrating from the old API">
+Support for the object-based `customGroups` option is deprecated.
+
+Migrating from the old to the current API is easy:
+
+Old API:
+```ts
+{
+  "key1": "value1",
+  "key2": "value2"
+}
+```
+
+Current API:
+```ts
+[
+  {
+    "groupName": "key1",
+    "elementNamePattern": "value1"
+  },
+  {
+    "groupName": "key2",
+    "elementNamePattern": "value2"
+  }
+]
+```
+</Important>
+
 <sub>
-  type: `{ [groupName: string]: string | string[] }`
+  type: `Array<CustomGroupDefinition | CustomGroupAnyOfDefinition>`
 </sub>
-<sub>default: `{}`</sub>
+<sub>default: `[]`</sub>
+
+You can define your own groups and use regexp patterns to match specific JSX prop.
+
+A custom group definition may follow one of the two following interfaces:
+
+```ts
+interface CustomGroupDefinition {
+  groupName: string
+  type?: 'alphabetical' | 'natural' | 'line-length' | 'unsorted'
+  order?: 'asc' | 'desc'
+  fallbackSort?: { type: string; order?: 'asc' | 'desc'; sortBy?: 'name' | 'value' }
+  newlinesInside?: 'always' | 'never'
+  selector?: string
+  modifiers?: string[]
+  elementNamePattern?: string | string[] | { pattern: string; flags?: string } | { pattern: string; flags?: string }[]
+  elementValuePattern?: string | string[] | { pattern: string; flags?: string } | { pattern: string; flags?: string }[]
+}
+
+```
+A JSX prop will match a `CustomGroupDefinition` group if it matches all the filters of the custom group's definition.
+
+or:
+
+```ts
+interface CustomGroupAnyOfDefinition {
+  groupName: string
+  type?: 'alphabetical' | 'natural' | 'line-length' | 'unsorted'
+  order?: 'asc' | 'desc'
+  fallbackSort?: { type: string; order?: 'asc' | 'desc'; sortBy?: 'name' | 'value' }
+  newlinesInside?: 'always' | 'never'
+  anyOf: Array<{
+      selector?: string
+      modifiers?: string[]
+      elementNamePattern?: string | string[] | { pattern: string; flags?: string } | { pattern: string; flags?: string }[]
+      elementValuePattern?: string | string[] | { pattern: string; flags?: string } | { pattern: string; flags?: string }[]
+  }>
+}
+```
+
+A JSX prop will match a `CustomGroupAnyOfDefinition` group if it matches all the filters of at least one of the `anyOf` items.
+
+#### Attributes
+
+- `groupName`: The group's name, which needs to be put in the `groups` option.
+- `selector`: Filter on the `selector` of the element.
+- `modifiers`: Filter on the `modifiers` of the element. (All the modifiers of the element must be present in that list)
+- `elementNamePattern`: If entered, will check that the name of the element matches the pattern entered.
+- `elementValuePattern`: If entered, will check that the value of the element matches the pattern entered.
+- `type`: Overrides the sort type for that custom group. `unsorted` will not sort the group.
+- `order`: Overrides the sort order for that custom group
+- `fallbackSort`: Overrides the fallbackSort option for that custom group
+- `newlinesInside`: Enforces a specific newline behavior between elements of the group.
 
-You can define your own groups and use regexp patterns to match specific JSX attributes.
+#### Match importance
 
-Each key of `customGroups` represents a group name which you can then use in the `groups` option. The value for each key can either be of type:
-- `string` — A JSX prop's name matching the value will be marked as part of the group referenced by the key.
-- `string[]` — A JSX prop's name matching any of the values of the array will be marked as part of the group referenced by the key.
-The order of values in the array does not matter.
+The `customGroups` list is ordered:
+The first custom group definition that matches an element will be used.
 
-Custom group matching takes precedence over predefined group matching.
+Custom groups have a higher priority than any predefined group.
 
 #### Example
 
 ```js
  {
    groups: [
-     'multiline',
+     'multiline-prop',
      'unknown',
-     'shorthand',
+     'shorthand-prop',
 +    'callback',       // [!code ++]
    ],
-+  customGroups: {     // [!code ++]
-+    callback: '^on.+' // [!code ++]
-+  }                   // [!code ++]
++  customGroups: [     // [!code ++]
++    {                 // [!code ++]
++      groupName: 'callback',      // [!code ++]
++      elementNamePattern: '^on.+' // [!code ++]
++    }                 // [!code ++]
++  ]                   // [!code ++]
  }
 ```
 
diff --git a/docs/content/rules/sort-maps.mdx b/docs/content/rules/sort-maps.mdx
index fe5195f..567d1f1 100644
--- a/docs/content/rules/sort-maps.mdx
+++ b/docs/content/rules/sort-maps.mdx
@@ -322,6 +322,7 @@ interface CustomGroupDefinition {
   type?: 'alphabetical' | 'natural' | 'line-length' | 'unsorted'
   order?: 'asc' | 'desc'
   fallbackSort?: { type: string; order?: 'asc' | 'desc' }
+  newlinesInside?: 'always' | 'never'
   elementNamePattern?: string | string[] | { pattern: string; flags?: string } | { pattern: string; flags?: string }[]
 }
 
@@ -336,6 +337,7 @@ interface CustomGroupAnyOfDefinition {
   type?: 'alphabetical' | 'natural' | 'line-length' | 'unsorted'
   order?: 'asc' | 'desc'
   fallbackSort?: { type: string; order?: 'asc' | 'desc' }
+  newlinesInside?: 'always' | 'never'
   anyOf: Array<{
       elementNamePattern?: string | string[] | { pattern: string; flags?: string } | { pattern: string; flags?: string }[]
   }>
@@ -351,6 +353,7 @@ A map element will match a `CustomGroupAnyOfDefinition` group if it matches all
 - `type`: Overrides the sort type for that custom group. `unsorted` will not sort the group.
 - `order`: Overrides the sort order for that custom group
 - `fallbackSort`: Overrides the fallbackSort option for that custom group
+- `newlinesInside`: Enforces a specific newline behavior between elements of the group.
 
 #### Match importance
 
diff --git a/docs/content/rules/sort-sets.mdx b/docs/content/rules/sort-sets.mdx
index 5572a08..283c25a 100644
--- a/docs/content/rules/sort-sets.mdx
+++ b/docs/content/rules/sort-sets.mdx
@@ -337,6 +337,7 @@ interface CustomGroupDefinition {
   type?: 'alphabetical' | 'natural' | 'line-length' | 'unsorted'
   order?: 'asc' | 'desc'
   fallbackSort?: { type: string; order?: 'asc' | 'desc' }
+  newlinesInside?: 'always' | 'never'
   selector?: string
   elementNamePattern?: string | string[] | { pattern: string; flags?: string } | { pattern: string; flags?: string }[]
 }
@@ -352,6 +353,7 @@ interface CustomGroupAnyOfDefinition {
   type?: 'alphabetical' | 'natural' | 'line-length' | 'unsorted'
   order?: 'asc' | 'desc'
   fallbackSort?: { type: string; order?: 'asc' | 'desc' }
+  newlinesInside?: 'always' | 'never'
   anyOf: Array<{
       selector?: string
       elementNamePattern?: string | string[] | { pattern: string; flags?: string } | { pattern: string; flags?: string }[]
@@ -369,6 +371,7 @@ A set element will match a `CustomGroupAnyOfDefinition` group if it matches all
 - `type`: Overrides the sort type for that custom group. `unsorted` will not sort the group.
 - `order`: Overrides the sort order for that custom group
 - `fallbackSort`: Overrides the fallbackSort option for that custom group
+- `newlinesInside`: Enforces a specific newline behavior between elements of the group.
 
 #### Match importance
 
diff --git a/rules/sort-array-includes.ts b/rules/sort-array-includes.ts
index 4fea1e1..e63fcc2 100644
--- a/rules/sort-array-includes.ts
+++ b/rules/sort-array-includes.ts
@@ -29,11 +29,11 @@ import {
   singleCustomGroupJsonSchema,
   allSelectors,
 } from './sort-array-includes/types'
-import { doesCustomGroupMatch } from './sort-array-includes/does-custom-group-match'
 import { getMatchingContextOptions } from '../utils/get-matching-context-options'
 import { generatePredefinedGroups } from '../utils/generate-predefined-groups'
 import { getEslintDisabledLines } from '../utils/get-eslint-disabled-lines'
 import { isNodeEslintDisabled } from '../utils/is-node-eslint-disabled'
+import { doesCustomGroupMatch } from '../utils/does-custom-group-match'
 import { sortNodesByGroups } from '../utils/sort-nodes-by-groups'
 import { createEslintRule } from '../utils/create-eslint-rule'
 import { reportAllErrors } from '../utils/report-all-errors'
@@ -228,6 +228,7 @@ export let sortArray = <MessageIds extends string>({
           doesCustomGroupMatch({
             selectors: [selector],
             elementName: name,
+            modifiers: [],
             customGroup,
           })
         ) {
diff --git a/rules/sort-array-includes/does-custom-group-match.ts b/rules/sort-array-includes/does-custom-group-match.ts
deleted file mode 100644
index d8deabe..0000000
--- a/rules/sort-array-includes/does-custom-group-match.ts
+++ /dev/null
@@ -1,49 +0,0 @@
-import type { AnyOfCustomGroup } from '../../types/common-options'
-import type { SingleCustomGroup, Selector } from './types'
-
-import { matches } from '../../utils/matches'
-
-interface DoesCustomGroupMatchParameters {
-  customGroup: AnyOfCustomGroup<SingleCustomGroup> | SingleCustomGroup
-  selectors: Selector[]
-  elementName: string
-}
-
-/**
- * Determines whether a custom group matches the given properties.
- * @param {DoesCustomGroupMatchParameters} props - The properties to compare
- * with the custom group, including selectors, modifiers, and element name.
- * @returns {boolean} `true` if the custom group matches the properties;
- * otherwise, `false`.
- */
-export let doesCustomGroupMatch = (
-  props: DoesCustomGroupMatchParameters,
-): boolean => {
-  if ('anyOf' in props.customGroup) {
-    // At least one subgroup must match
-    return props.customGroup.anyOf.some(subgroup =>
-      doesCustomGroupMatch({ ...props, customGroup: subgroup }),
-    )
-  }
-  if (
-    props.customGroup.selector &&
-    !props.selectors.includes(props.customGroup.selector)
-  ) {
-    return false
-  }
-
-  if (
-    'elementNamePattern' in props.customGroup &&
-    props.customGroup.elementNamePattern
-  ) {
-    let matchesElementNamePattern: boolean = matches(
-      props.elementName,
-      props.customGroup.elementNamePattern,
-    )
-    if (!matchesElementNamePattern) {
-      return false
-    }
-  }
-
-  return true
-}
diff --git a/rules/sort-classes.ts b/rules/sort-classes.ts
index 8351a21..5ebd586 100644
--- a/rules/sort-classes.ts
+++ b/rules/sort-classes.ts
@@ -36,9 +36,9 @@ import { validateCustomSortConfiguration } from '../utils/validate-custom-sort-c
 import { getOverloadSignatureGroups } from './sort-classes/get-overload-signature-groups'
 import { generatePredefinedGroups } from '../utils/generate-predefined-groups'
 import { sortNodesByDependencies } from '../utils/sort-nodes-by-dependencies'
-import { doesCustomGroupMatch } from './sort-classes/does-custom-group-match'
 import { getEslintDisabledLines } from '../utils/get-eslint-disabled-lines'
 import { isNodeEslintDisabled } from '../utils/is-node-eslint-disabled'
+import { doesCustomGroupMatch } from '../utils/does-custom-group-match'
 import { sortNodesByGroups } from '../utils/sort-nodes-by-groups'
 import { getNodeDecorators } from '../utils/get-node-decorators'
 import { createEslintRule } from '../utils/create-eslint-rule'
diff --git a/rules/sort-classes/does-custom-group-match.ts b/rules/sort-classes/does-custom-group-match.ts
deleted file mode 100644
index 72c1295..0000000
--- a/rules/sort-classes/does-custom-group-match.ts
+++ /dev/null
@@ -1,87 +0,0 @@
-import type { SingleCustomGroup, Modifier, Selector } from './types'
-import type { AnyOfCustomGroup } from '../../types/common-options'
-
-import { matches } from '../../utils/matches'
-
-interface DoesCustomGroupMatchParameters {
-  customGroup: AnyOfCustomGroup<SingleCustomGroup> | SingleCustomGroup
-  elementValue: undefined | string
-  selectors: Selector[]
-  modifiers: Modifier[]
-  decorators: string[]
-  elementName: string
-}
-
-/**
- * Determines whether a custom group matches the given properties.
- * @param {DoesCustomGroupMatchParameters} props - The properties to match
- * against the custom group, including selectors, modifiers, decorators, and
- * element names.
- * @returns {boolean} `true` if the custom group matches the properties;
- * otherwise, `false`.
- */
-export let doesCustomGroupMatch = (
-  props: DoesCustomGroupMatchParameters,
-): boolean => {
-  if ('anyOf' in props.customGroup) {
-    // At least one subgroup must match.
-    return props.customGroup.anyOf.some(subgroup =>
-      doesCustomGroupMatch({ ...props, customGroup: subgroup }),
-    )
-  }
-  if (
-    props.customGroup.selector &&
-    !props.selectors.includes(props.customGroup.selector)
-  ) {
-    return false
-  }
-
-  if (props.customGroup.modifiers) {
-    for (let modifier of props.customGroup.modifiers) {
-      if (!props.modifiers.includes(modifier)) {
-        return false
-      }
-    }
-  }
-
-  if (
-    'elementNamePattern' in props.customGroup &&
-    props.customGroup.elementNamePattern
-  ) {
-    let matchesElementNamePattern: boolean = matches(
-      props.elementName,
-      props.customGroup.elementNamePattern,
-    )
-    if (!matchesElementNamePattern) {
-      return false
-    }
-  }
-
-  if (
-    'elementValuePattern' in props.customGroup &&
-    props.customGroup.elementValuePattern
-  ) {
-    let matchesElementValuePattern: boolean = matches(
-      props.elementValue ?? '',
-      props.customGroup.elementValuePattern,
-    )
-    if (!matchesElementValuePattern) {
-      return false
-    }
-  }
-
-  if (
-    'decoratorNamePattern' in props.customGroup &&
-    props.customGroup.decoratorNamePattern
-  ) {
-    let decoratorPattern = props.customGroup.decoratorNamePattern
-    let matchesDecoratorNamePattern: boolean = props.decorators.some(
-      decorator => matches(decorator, decoratorPattern),
-    )
-    if (!matchesDecoratorNamePattern) {
-      return false
-    }
-  }
-
-  return true
-}
diff --git a/rules/sort-enums.ts b/rules/sort-enums.ts
index 5a696c0..3d8effb 100644
--- a/rules/sort-enums.ts
+++ b/rules/sort-enums.ts
@@ -27,8 +27,8 @@ import { validateCustomSortConfiguration } from '../utils/validate-custom-sort-c
 import { getCustomGroupOverriddenOptions } from '../utils/get-custom-groups-compare-options'
 import { sortNodesByDependencies } from '../utils/sort-nodes-by-dependencies'
 import { getEslintDisabledLines } from '../utils/get-eslint-disabled-lines'
-import { doesCustomGroupMatch } from './sort-enums/does-custom-group-match'
 import { isNodeEslintDisabled } from '../utils/is-node-eslint-disabled'
+import { doesCustomGroupMatch } from '../utils/does-custom-group-match'
 import { sortNodesByGroups } from '../utils/sort-nodes-by-groups'
 import { singleCustomGroupJsonSchema } from './sort-enums/types'
 import { createEslintRule } from '../utils/create-eslint-rule'
@@ -153,6 +153,8 @@ export default createEslintRule<Options, MESSAGE_ID>({
               doesCustomGroupMatch({
                 elementValue: sourceCode.getText(member.initializer),
                 elementName: name,
+                selectors: [],
+                modifiers: [],
                 customGroup,
               })
             ) {
diff --git a/rules/sort-enums/does-custom-group-match.ts b/rules/sort-enums/does-custom-group-match.ts
deleted file mode 100644
index 5fb5e3d..0000000
--- a/rules/sort-enums/does-custom-group-match.ts
+++ /dev/null
@@ -1,49 +0,0 @@
-import type { AnyOfCustomGroup } from '../../types/common-options'
-import type { SingleCustomGroup } from './types'
-
-import { matches } from '../../utils/matches'
-
-interface DoesCustomGroupMatchProps {
-  customGroup: AnyOfCustomGroup<SingleCustomGroup> | SingleCustomGroup
-  elementValue: string
-  elementName: string
-}
-
-export let doesCustomGroupMatch = (
-  props: DoesCustomGroupMatchProps,
-): boolean => {
-  if ('anyOf' in props.customGroup) {
-    // At least one subgroup must match
-    return props.customGroup.anyOf.some(subgroup =>
-      doesCustomGroupMatch({ ...props, customGroup: subgroup }),
-    )
-  }
-
-  if (
-    'elementNamePattern' in props.customGroup &&
-    props.customGroup.elementNamePattern
-  ) {
-    let matchesElementNamePattern: boolean = matches(
-      props.elementName,
-      props.customGroup.elementNamePattern,
-    )
-    if (!matchesElementNamePattern) {
-      return false
-    }
-  }
-
-  if (
-    'elementValuePattern' in props.customGroup &&
-    props.customGroup.elementValuePattern
-  ) {
-    let matchesElementValuePattern: boolean = matches(
-      props.elementValue,
-      props.customGroup.elementValuePattern,
-    )
-    if (!matchesElementValuePattern) {
-      return false
-    }
-  }
-
-  return true
-}
diff --git a/rules/sort-exports.ts b/rules/sort-exports.ts
index 29e740c..f2e2f18 100644
--- a/rules/sort-exports.ts
+++ b/rules/sort-exports.ts
@@ -22,9 +22,9 @@ import { buildGetCustomGroupOverriddenOptionsFunction } from '../utils/get-custo
 import { validateGeneratedGroupsConfiguration } from '../utils/validate-generated-groups-configuration'
 import { validateCustomSortConfiguration } from '../utils/validate-custom-sort-configuration'
 import { generatePredefinedGroups } from '../utils/generate-predefined-groups'
-import { doesCustomGroupMatch } from './sort-exports/does-custom-group-match'
 import { getEslintDisabledLines } from '../utils/get-eslint-disabled-lines'
 import { isNodeEslintDisabled } from '../utils/is-node-eslint-disabled'
+import { doesCustomGroupMatch } from '../utils/does-custom-group-match'
 import { singleCustomGroupJsonSchema } from './sort-exports/types'
 import { allModifiers, allSelectors } from './sort-exports/types'
 import { sortNodesByGroups } from '../utils/sort-nodes-by-groups'
@@ -119,6 +119,7 @@ export default createEslintRule<Options, MESSAGE_ID>({
       for (let customGroup of options.customGroups) {
         if (
           doesCustomGroupMatch({
+            selectors: [selector],
             elementName: name,
             customGroup,
             modifiers,
diff --git a/rules/sort-exports/does-custom-group-match.ts b/rules/sort-exports/does-custom-group-match.ts
deleted file mode 100644
index 7e55cf1..0000000
--- a/rules/sort-exports/does-custom-group-match.ts
+++ /dev/null
@@ -1,43 +0,0 @@
-import type { AnyOfCustomGroup } from '../../types/common-options'
-import type { SingleCustomGroup, Modifier } from './types'
-
-import { matches } from '../../utils/matches'
-
-interface DoesCustomGroupMatchParameters {
-  customGroup: AnyOfCustomGroup<SingleCustomGroup> | SingleCustomGroup
-  modifiers: Modifier[]
-  elementName: string
-}
-
-export let doesCustomGroupMatch = (
-  props: DoesCustomGroupMatchParameters,
-): boolean => {
-  if ('anyOf' in props.customGroup) {
-    return props.customGroup.anyOf.some(subgroup =>
-      doesCustomGroupMatch({ ...props, customGroup: subgroup }),
-    )
-  }
-
-  if (props.customGroup.modifiers) {
-    for (let modifier of props.customGroup.modifiers) {
-      if (!props.modifiers.includes(modifier)) {
-        return false
-      }
-    }
-  }
-
-  if (
-    'elementNamePattern' in props.customGroup &&
-    props.customGroup.elementNamePattern
-  ) {
-    let matchesElementNamePattern: boolean = matches(
-      props.elementName,
-      props.customGroup.elementNamePattern,
-    )
-    if (!matchesElementNamePattern) {
-      return false
-    }
-  }
-
-  return true
-}
diff --git a/rules/sort-jsx-props.ts b/rules/sort-jsx-props.ts
index ef0a57b..7cbcbe2 100644
--- a/rules/sort-jsx-props.ts
+++ b/rules/sort-jsx-props.ts
@@ -1,16 +1,12 @@
 import { TSESTree } from '@typescript-eslint/types'
 
-import type {
-  DeprecatedCustomGroupsOption,
-  NewlinesBetweenOption,
-  CommonOptions,
-  GroupsOptions,
-  RegexOption,
-} from '../types/common-options'
+import type { Modifier, Selector } from './sort-jsx-props/types'
 import type { SortingNode } from '../types/sorting-node'
+import type { Options } from './sort-jsx-props/types'
 
 import {
   buildUseConfigurationIfJsonSchema,
+  buildCustomGroupsArrayJsonSchema,
   partitionByNewLineJsonSchema,
   newlinesBetweenJsonSchema,
   customGroupsJsonSchema,
@@ -25,11 +21,16 @@ import {
   ORDER_ERROR,
 } from '../utils/report-errors'
 import { validateNewlinesAndPartitionConfiguration } from '../utils/validate-newlines-and-partition-configuration'
+import { buildGetCustomGroupOverriddenOptionsFunction } from '../utils/get-custom-groups-compare-options'
+import { validateGeneratedGroupsConfiguration } from '../utils/validate-generated-groups-configuration'
 import { validateCustomSortConfiguration } from '../utils/validate-custom-sort-configuration'
-import { validateGroupsConfiguration } from '../utils/validate-groups-configuration'
 import { getMatchingContextOptions } from '../utils/get-matching-context-options'
+import { doesCustomGroupMatch } from './sort-jsx-props/does-custom-group-match'
+import { generatePredefinedGroups } from '../utils/generate-predefined-groups'
 import { getEslintDisabledLines } from '../utils/get-eslint-disabled-lines'
 import { isNodeEslintDisabled } from '../utils/is-node-eslint-disabled'
+import { singleCustomGroupJsonSchema } from './sort-jsx-props/types'
+import { allModifiers, allSelectors } from './sort-jsx-props/types'
 import { sortNodesByGroups } from '../utils/sort-nodes-by-groups'
 import { createEslintRule } from '../utils/create-eslint-rule'
 import { reportAllErrors } from '../utils/report-all-errors'
@@ -41,22 +42,10 @@ import { useGroups } from '../utils/use-groups'
 import { complete } from '../utils/complete'
 import { matches } from '../utils/matches'
 
-type Options = Partial<
-  {
-    useConfigurationIf: {
-      allNamesMatchPattern?: RegexOption
-      tagMatchesPattern?: RegexOption
-    }
-    customGroups: DeprecatedCustomGroupsOption
-    newlinesBetween: NewlinesBetweenOption
-    groups: GroupsOptions<Group>
-    partitionByNewLine: boolean
-    /**
-     * @deprecated for {@link `useConfigurationIf.tagMatchesPattern`}
-     */
-    ignorePattern: RegexOption
-  } & CommonOptions
->[]
+/**
+ * Cache computed groups by modifiers and selectors for performance
+ */
+let cachedGroupsByModifiersAndSelectors = new Map<string, string[]>()
 
 type MESSAGE_ID =
   | 'missedSpacingBetweenJSXPropsMembers'
@@ -64,8 +53,6 @@ type MESSAGE_ID =
   | 'unexpectedJSXPropsGroupOrder'
   | 'unexpectedJSXPropsOrder'
 
-type Group = 'multiline' | 'shorthand' | 'unknown' | string
-
 let defaultOptions: Required<Options[0]> = {
   fallbackSort: { type: 'unsorted' },
   specialCharacters: 'keep',
@@ -110,9 +97,9 @@ export default createEslintRule<Options, MESSAGE_ID>({
       })
       let options = complete(matchedContextOptions, settings, defaultOptions)
       validateCustomSortConfiguration(options)
-      validateGroupsConfiguration({
-        allowedPredefinedGroups: ['multiline', 'shorthand', 'unknown'],
-        allowedCustomGroups: Object.keys(options.customGroups),
+      validateGeneratedGroupsConfiguration({
+        selectors: allSelectors,
+        modifiers: allModifiers,
         options,
       })
       validateNewlinesAndPartitionConfiguration(options)
@@ -145,12 +132,55 @@ export default createEslintRule<Options, MESSAGE_ID>({
 
             let { setCustomGroups, defineGroup, getGroup } = useGroups(options)
 
-            setCustomGroups(options.customGroups, name)
+            let selectors: Selector[] = []
+            let modifiers: Modifier[] = []
 
             if (attribute.value === null) {
-              defineGroup('shorthand')
-            } else if (attribute.loc.start.line !== attribute.loc.end.line) {
-              defineGroup('multiline')
+              selectors.push('shorthand')
+              modifiers.push('shorthand')
+            }
+            if (attribute.loc.start.line !== attribute.loc.end.line) {
+              selectors.push('multiline')
+              modifiers.push('multiline')
+            }
+            selectors.push('prop')
+
+            let predefinedGroups = generatePredefinedGroups({
+              cache: cachedGroupsByModifiersAndSelectors,
+              selectors,
+              modifiers,
+            })
+            for (let predefinedGroup of predefinedGroups) {
+              defineGroup(predefinedGroup)
+            }
+
+            if (Array.isArray(options.customGroups)) {
+              for (let customGroup of options.customGroups) {
+                if (
+                  doesCustomGroupMatch({
+                    elementValue: attribute.value
+                      ? sourceCode.getText(attribute.value)
+                      : null,
+                    elementName: name,
+                    customGroup,
+                    selectors,
+                    modifiers,
+                  })
+                ) {
+                  defineGroup(customGroup.groupName, true)
+                  /**
+                   * If the custom group is not referenced in the `groups` option, it
+                   * will be ignored
+                   */
+                  if (getGroup() === customGroup.groupName) {
+                    break
+                  }
+                }
+              }
+            } else {
+              setCustomGroups(options.customGroups, name, {
+                override: true,
+              })
             }
 
             let sortingNode: SortingNode = {
@@ -188,7 +218,8 @@ export default createEslintRule<Options, MESSAGE_ID>({
           ignoreEslintDisabledNodes: boolean,
         ): SortingNode[] =>
           sortNodesByGroups({
-            getOptionsByGroupNumber: () => ({ options }),
+            getOptionsByGroupNumber:
+              buildGetCustomGroupOverriddenOptionsFunction(options),
             ignoreEslintDisabledNodes,
             groups: options.groups,
             nodes,
@@ -215,6 +246,14 @@ export default createEslintRule<Options, MESSAGE_ID>({
       items: {
         properties: {
           ...commonJsonSchemas,
+          customGroups: {
+            oneOf: [
+              customGroupsJsonSchema,
+              buildCustomGroupsArrayJsonSchema({
+                singleCustomGroupJsonSchema,
+              }),
+            ],
+          },
           useConfigurationIf: buildUseConfigurationIfJsonSchema({
             additionalProperties: {
               tagMatchesPattern: regexJsonSchema,
@@ -222,7 +261,6 @@ export default createEslintRule<Options, MESSAGE_ID>({
           }),
           partitionByNewLine: partitionByNewLineJsonSchema,
           newlinesBetween: newlinesBetweenJsonSchema,
-          customGroups: customGroupsJsonSchema,
           ignorePattern: regexJsonSchema,
           groups: groupsJsonSchema,
         },
diff --git a/rules/sort-objects/does-custom-group-match.ts b/rules/sort-jsx-props/does-custom-group-match.ts
similarity index 89%
rename from rules/sort-objects/does-custom-group-match.ts
rename to rules/sort-jsx-props/does-custom-group-match.ts
index 5d46bd4..f5d9d54 100644
--- a/rules/sort-objects/does-custom-group-match.ts
+++ b/rules/sort-jsx-props/does-custom-group-match.ts
@@ -1,4 +1,4 @@
-import type { SingleCustomGroup, Modifier, Selector } from './types'
+import type { SingleCustomGroup, Selector, Modifier } from './types'
 import type { AnyOfCustomGroup } from '../../types/common-options'
 
 import { matches } from '../../utils/matches'
@@ -22,7 +22,7 @@ export let doesCustomGroupMatch = (
   props: DoesCustomGroupMatchParameters,
 ): boolean => {
   if ('anyOf' in props.customGroup) {
-    // At least one subgroup must match.
+    // At least one subgroup must match
     return props.customGroup.anyOf.some(subgroup =>
       doesCustomGroupMatch({ ...props, customGroup: subgroup }),
     )
@@ -59,11 +59,11 @@ export let doesCustomGroupMatch = (
     'elementValuePattern' in props.customGroup &&
     props.customGroup.elementValuePattern
   ) {
-    let matchesElementValuePattern: boolean = matches(
+    let matchesElementNamePattern: boolean = matches(
       props.elementValue ?? '',
       props.customGroup.elementValuePattern,
     )
-    if (!matchesElementValuePattern) {
+    if (!matchesElementNamePattern) {
       return false
     }
   }
diff --git a/rules/sort-jsx-props/types.ts b/rules/sort-jsx-props/types.ts
new file mode 100644
index 0000000..fce2554
--- /dev/null
+++ b/rules/sort-jsx-props/types.ts
@@ -0,0 +1,98 @@
+import type { JSONSchema4 } from '@typescript-eslint/utils/json-schema'
+
+import type {
+  DeprecatedCustomGroupsOption,
+  NewlinesBetweenOption,
+  CustomGroupsOption,
+  CommonOptions,
+  GroupsOptions,
+  RegexOption,
+} from '../../types/common-options'
+import type { JoinWithDash } from '../../types/join-with-dash'
+
+import {
+  buildCustomGroupModifiersJsonSchema,
+  buildCustomGroupSelectorJsonSchema,
+  regexJsonSchema,
+} from '../../utils/common-json-schemas'
+
+export type Options = Partial<
+  {
+    useConfigurationIf: {
+      allNamesMatchPattern?: RegexOption
+      tagMatchesPattern?: RegexOption
+    }
+    customGroups:
+      | CustomGroupsOption<SingleCustomGroup>
+      | DeprecatedCustomGroupsOption
+    newlinesBetween: NewlinesBetweenOption
+    groups: GroupsOptions<Group>
+    partitionByNewLine: boolean
+    /**
+     * @deprecated for {@link `useConfigurationIf.tagMatchesPattern`}
+     */
+    ignorePattern: RegexOption
+  } & CommonOptions
+>[]
+
+export interface SingleCustomGroup {
+  elementValuePattern?: RegexOption
+  elementNamePattern?: RegexOption
+  modifiers?: Modifier[]
+  selector?: Selector
+}
+
+export type Selector = MultilineSelector | ShorthandSelector | PropertySelector
+
+export type Modifier = MultilineModifier | ShorthandModifier
+
+type PropertyGroup = JoinWithDash<
+  [ShorthandModifier, MultilineModifier, PropertySelector]
+>
+
+/**
+ * Only used in code, so I don't know if it's worth maintaining this.
+ */
+type Group =
+  | ShorthandGroup
+  | MultilineGroup
+  | PropertyGroup
+  | 'unknown'
+  | string
+
+/**
+ * @deprecated For {@link `MultilineModifier`}
+ */
+type MultilineGroup = JoinWithDash<[MultilineSelector]>
+
+/**
+ * @deprecated For {@link `ShorthandModifier`}
+ */
+type ShorthandGroup = JoinWithDash<[ShorthandSelector]>
+
+/**
+ * @deprecated For {@link `ShorthandModifier`}
+ */
+type ShorthandSelector = 'shorthand'
+
+/**
+ * @deprecated For {@link `MultilineModifier`}
+ */
+type MultilineSelector = 'multiline'
+
+type MultilineModifier = 'multiline'
+
+type ShorthandModifier = 'shorthand'
+
+type PropertySelector = 'prop'
+
+export let allSelectors: Selector[] = ['multiline', 'prop', 'shorthand']
+
+export let allModifiers: Modifier[] = ['shorthand', 'multiline']
+
+export let singleCustomGroupJsonSchema: Record<string, JSONSchema4> = {
+  modifiers: buildCustomGroupModifiersJsonSchema(allModifiers),
+  selector: buildCustomGroupSelectorJsonSchema(allSelectors),
+  elementValuePattern: regexJsonSchema,
+  elementNamePattern: regexJsonSchema,
+}
diff --git a/rules/sort-maps.ts b/rules/sort-maps.ts
index 725e46c..9142d39 100644
--- a/rules/sort-maps.ts
+++ b/rules/sort-maps.ts
@@ -24,8 +24,8 @@ import { validateGeneratedGroupsConfiguration } from '../utils/validate-generate
 import { validateCustomSortConfiguration } from '../utils/validate-custom-sort-configuration'
 import { getMatchingContextOptions } from '../utils/get-matching-context-options'
 import { getEslintDisabledLines } from '../utils/get-eslint-disabled-lines'
-import { doesCustomGroupMatch } from './sort-maps/does-custom-group-match'
 import { isNodeEslintDisabled } from '../utils/is-node-eslint-disabled'
+import { doesCustomGroupMatch } from '../utils/does-custom-group-match'
 import { sortNodesByGroups } from '../utils/sort-nodes-by-groups'
 import { singleCustomGroupJsonSchema } from './sort-maps/types'
 import { createEslintRule } from '../utils/create-eslint-rule'
@@ -129,6 +129,8 @@ export default createEslintRule<Options, MESSAGE_ID>({
             if (
               doesCustomGroupMatch({
                 elementName: name,
+                selectors: [],
+                modifiers: [],
                 customGroup,
               })
             ) {
diff --git a/rules/sort-maps/does-custom-group-match.ts b/rules/sort-maps/does-custom-group-match.ts
deleted file mode 100644
index f98dbec..0000000
--- a/rules/sort-maps/does-custom-group-match.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-import type { AnyOfCustomGroup } from '../../types/common-options'
-import type { SingleCustomGroup } from './types'
-
-import { matches } from '../../utils/matches'
-
-interface DoesCustomGroupMatchParameters {
-  customGroup: AnyOfCustomGroup<SingleCustomGroup> | SingleCustomGroup
-  elementName: string
-}
-
-export let doesCustomGroupMatch = (
-  props: DoesCustomGroupMatchParameters,
-): boolean => {
-  if ('anyOf' in props.customGroup) {
-    // At least one subgroup must match
-    return props.customGroup.anyOf.some(subgroup =>
-      doesCustomGroupMatch({ ...props, customGroup: subgroup }),
-    )
-  }
-
-  if (
-    'elementNamePattern' in props.customGroup &&
-    props.customGroup.elementNamePattern
-  ) {
-    let matchesElementNamePattern: boolean = matches(
-      props.elementName,
-      props.customGroup.elementNamePattern,
-    )
-    if (!matchesElementNamePattern) {
-      return false
-    }
-  }
-
-  return true
-}
diff --git a/rules/sort-modules.ts b/rules/sort-modules.ts
index 180b971..66880ab 100644
--- a/rules/sort-modules.ts
+++ b/rules/sort-modules.ts
@@ -36,9 +36,9 @@ import {
 import { validateCustomSortConfiguration } from '../utils/validate-custom-sort-configuration'
 import { generatePredefinedGroups } from '../utils/generate-predefined-groups'
 import { sortNodesByDependencies } from '../utils/sort-nodes-by-dependencies'
-import { doesCustomGroupMatch } from './sort-modules/does-custom-group-match'
 import { getEslintDisabledLines } from '../utils/get-eslint-disabled-lines'
 import { isNodeEslintDisabled } from '../utils/is-node-eslint-disabled'
+import { doesCustomGroupMatch } from '../utils/does-custom-group-match'
 import { sortNodesByGroups } from '../utils/sort-nodes-by-groups'
 import { getNodeDecorators } from '../utils/get-node-decorators'
 import { createEslintRule } from '../utils/create-eslint-rule'
diff --git a/rules/sort-modules/does-custom-group-match.ts b/rules/sort-modules/does-custom-group-match.ts
deleted file mode 100644
index 6cf9fcc..0000000
--- a/rules/sort-modules/does-custom-group-match.ts
+++ /dev/null
@@ -1,72 +0,0 @@
-import type { SingleCustomGroup, Modifier, Selector } from './types'
-import type { AnyOfCustomGroup } from '../../types/common-options'
-
-import { matches } from '../../utils/matches'
-
-interface DoesCustomGroupMatchParameters {
-  customGroup: AnyOfCustomGroup<SingleCustomGroup> | SingleCustomGroup
-  selectors: Selector[]
-  modifiers: Modifier[]
-  decorators: string[]
-  elementName: string
-}
-
-/**
- * Determines whether a custom group matches the given properties.
- * @param {DoesCustomGroupMatchParameters} props - The properties to compare
- * with the custom group, including selectors, modifiers, decorators, and element name.
- * @returns {boolean} `true` if the custom group matches the properties;
- * otherwise, `false`.
- */
-export let doesCustomGroupMatch = (
-  props: DoesCustomGroupMatchParameters,
-): boolean => {
-  if ('anyOf' in props.customGroup) {
-    // At least one subgroup must match.
-    return props.customGroup.anyOf.some(subgroup =>
-      doesCustomGroupMatch({ ...props, customGroup: subgroup }),
-    )
-  }
-  if (
-    props.customGroup.selector &&
-    !props.selectors.includes(props.customGroup.selector)
-  ) {
-    return false
-  }
-
-  if (props.customGroup.modifiers) {
-    for (let modifier of props.customGroup.modifiers) {
-      if (!props.modifiers.includes(modifier)) {
-        return false
-      }
-    }
-  }
-
-  if (
-    'elementNamePattern' in props.customGroup &&
-    props.customGroup.elementNamePattern
-  ) {
-    let matchesElementNamePattern: boolean = matches(
-      props.elementName,
-      props.customGroup.elementNamePattern,
-    )
-    if (!matchesElementNamePattern) {
-      return false
-    }
-  }
-
-  if (
-    'decoratorNamePattern' in props.customGroup &&
-    props.customGroup.decoratorNamePattern
-  ) {
-    let decoratorPattern = props.customGroup.decoratorNamePattern
-    let matchesDecoratorNamePattern: boolean = props.decorators.some(
-      decorator => matches(decorator, decoratorPattern),
-    )
-    if (!matchesDecoratorNamePattern) {
-      return false
-    }
-  }
-
-  return true
-}
diff --git a/rules/sort-object-types.ts b/rules/sort-object-types.ts
index aac1370..2ec6838 100644
--- a/rules/sort-object-types.ts
+++ b/rules/sort-object-types.ts
@@ -36,12 +36,12 @@ import {
 import { validateGeneratedGroupsConfiguration } from '../utils/validate-generated-groups-configuration'
 import { getCustomGroupsCompareOptions } from './sort-object-types/get-custom-groups-compare-options'
 import { validateCustomSortConfiguration } from '../utils/validate-custom-sort-configuration'
-import { doesCustomGroupMatch } from './sort-object-types/does-custom-group-match'
 import { getMatchingContextOptions } from '../utils/get-matching-context-options'
 import { generatePredefinedGroups } from '../utils/generate-predefined-groups'
 import { getEslintDisabledLines } from '../utils/get-eslint-disabled-lines'
 import { isMemberOptional } from './sort-object-types/is-member-optional'
 import { isNodeEslintDisabled } from '../utils/is-node-eslint-disabled'
+import { doesCustomGroupMatch } from '../utils/does-custom-group-match'
 import { isNodeFunctionType } from '../utils/is-node-function-type'
 import { sortNodesByGroups } from '../utils/sort-nodes-by-groups'
 import { createEslintRule } from '../utils/create-eslint-rule'
diff --git a/rules/sort-object-types/does-custom-group-match.ts b/rules/sort-object-types/does-custom-group-match.ts
deleted file mode 100644
index 5d46bd4..0000000
--- a/rules/sort-object-types/does-custom-group-match.ts
+++ /dev/null
@@ -1,72 +0,0 @@
-import type { SingleCustomGroup, Modifier, Selector } from './types'
-import type { AnyOfCustomGroup } from '../../types/common-options'
-
-import { matches } from '../../utils/matches'
-
-interface DoesCustomGroupMatchParameters {
-  customGroup: AnyOfCustomGroup<SingleCustomGroup> | SingleCustomGroup
-  elementValue: string | null
-  selectors: Selector[]
-  modifiers: Modifier[]
-  elementName: string
-}
-
-/**
- * Determines whether a custom group matches the given properties.
- * @param {DoesCustomGroupMatchParameters} props - The properties to compare
- * with the custom group, including selectors, modifiers, and element name.
- * @returns {boolean} `true` if the custom group matches the properties;
- * otherwise, `false`.
- */
-export let doesCustomGroupMatch = (
-  props: DoesCustomGroupMatchParameters,
-): boolean => {
-  if ('anyOf' in props.customGroup) {
-    // At least one subgroup must match.
-    return props.customGroup.anyOf.some(subgroup =>
-      doesCustomGroupMatch({ ...props, customGroup: subgroup }),
-    )
-  }
-  if (
-    props.customGroup.selector &&
-    !props.selectors.includes(props.customGroup.selector)
-  ) {
-    return false
-  }
-
-  if (props.customGroup.modifiers) {
-    for (let modifier of props.customGroup.modifiers) {
-      if (!props.modifiers.includes(modifier)) {
-        return false
-      }
-    }
-  }
-
-  if (
-    'elementNamePattern' in props.customGroup &&
-    props.customGroup.elementNamePattern
-  ) {
-    let matchesElementNamePattern: boolean = matches(
-      props.elementName,
-      props.customGroup.elementNamePattern,
-    )
-    if (!matchesElementNamePattern) {
-      return false
-    }
-  }
-
-  if (
-    'elementValuePattern' in props.customGroup &&
-    props.customGroup.elementValuePattern
-  ) {
-    let matchesElementValuePattern: boolean = matches(
-      props.elementValue ?? '',
-      props.customGroup.elementValuePattern,
-    )
-    if (!matchesElementValuePattern) {
-      return false
-    }
-  }
-
-  return true
-}
diff --git a/rules/sort-objects.ts b/rules/sort-objects.ts
index 3fbb5be..2f98acf 100644
--- a/rules/sort-objects.ts
+++ b/rules/sort-objects.ts
@@ -37,9 +37,9 @@ import { getFirstNodeParentWithType } from './sort-objects/get-first-node-parent
 import { getMatchingContextOptions } from '../utils/get-matching-context-options'
 import { generatePredefinedGroups } from '../utils/generate-predefined-groups'
 import { sortNodesByDependencies } from '../utils/sort-nodes-by-dependencies'
-import { doesCustomGroupMatch } from './sort-objects/does-custom-group-match'
 import { getEslintDisabledLines } from '../utils/get-eslint-disabled-lines'
 import { isNodeEslintDisabled } from '../utils/is-node-eslint-disabled'
+import { doesCustomGroupMatch } from '../utils/does-custom-group-match'
 import { sortNodesByGroups } from '../utils/sort-nodes-by-groups'
 import { createEslintRule } from '../utils/create-eslint-rule'
 import { reportAllErrors } from '../utils/report-all-errors'
diff --git a/utils/does-custom-group-match.ts b/utils/does-custom-group-match.ts
new file mode 100644
index 0000000..add928b
--- /dev/null
+++ b/utils/does-custom-group-match.ts
@@ -0,0 +1,104 @@
+import type { AnyOfCustomGroup, RegexOption } from '../types/common-options'
+
+import { matches } from './matches'
+
+export interface DoesCustomGroupMatchParameters {
+  elementValue?: string | null
+  decorators?: string[]
+  modifiers: string[]
+  selectors: string[]
+  elementName: string
+}
+
+interface BaseSingleCustomGroup {
+  decoratorNamePattern?: RegexOption
+  elementValuePattern?: RegexOption
+  elementNamePattern?: RegexOption
+  modifiers?: string[]
+  selector?: string
+}
+
+export let doesCustomGroupMatch = <
+  SingleCustomGroup extends BaseSingleCustomGroup,
+>(
+  props: {
+    customGroup: AnyOfCustomGroup<SingleCustomGroup> | SingleCustomGroup
+  } & DoesCustomGroupMatchParameters,
+): boolean => {
+  if ('anyOf' in props.customGroup) {
+    return props.customGroup.anyOf.some(subgroup =>
+      doesSingleCustomGroupMatch({
+        ...props,
+        customGroup: subgroup,
+      }),
+    )
+  }
+
+  return doesSingleCustomGroupMatch({
+    ...props,
+    customGroup: props.customGroup,
+  })
+}
+
+let doesSingleCustomGroupMatch = ({
+  elementValue,
+  customGroup,
+  elementName,
+  decorators,
+  selectors,
+  modifiers,
+}: {
+  customGroup: BaseSingleCustomGroup
+  elementValue?: string | null
+  decorators?: string[]
+  selectors?: string[]
+  modifiers?: string[]
+  elementName: string
+}): boolean => {
+  if (customGroup.selector && !selectors?.includes(customGroup.selector)) {
+    return false
+  }
+
+  if (customGroup.modifiers) {
+    for (let modifier of customGroup.modifiers) {
+      if (!modifiers?.includes(modifier)) {
+        return false
+      }
+    }
+  }
+
+  if ('elementNamePattern' in customGroup && customGroup.elementNamePattern) {
+    let matchesElementNamePattern: boolean = matches(
+      elementName,
+      customGroup.elementNamePattern,
+    )
+    if (!matchesElementNamePattern) {
+      return false
+    }
+  }
+
+  if ('elementValuePattern' in customGroup && customGroup.elementValuePattern) {
+    let matchesElementValuePattern: boolean = matches(
+      elementValue ?? '',
+      customGroup.elementValuePattern,
+    )
+    if (!matchesElementValuePattern) {
+      return false
+    }
+  }
+
+  if (
+    'decoratorNamePattern' in customGroup &&
+    customGroup.decoratorNamePattern
+  ) {
+    let decoratorPattern = customGroup.decoratorNamePattern
+    let matchesDecoratorNamePattern = decorators?.some(decorator =>
+      matches(decorator, decoratorPattern),
+    )
+    if (!matchesDecoratorNamePattern) {
+      return false
+    }
+  }
+
+  return true
+}


Test patch:
diff --git a/test/rules/sort-jsx-props.test.ts b/test/rules/sort-jsx-props.test.ts
index a970659..d2f2944 100644
--- a/test/rules/sort-jsx-props.test.ts
+++ b/test/rules/sort-jsx-props.test.ts
@@ -549,6 +549,473 @@ describe(ruleName, () => {
       },
     )
 
+    describe(`${ruleName}: custom groups`, () => {
+      ruleTester.run(`${ruleName}: filters on selector`, rule, {
+        invalid: [
+          {
+            errors: [
+              {
+                data: {
+                  rightGroup: 'shorthandElements',
+                  leftGroup: 'unknown',
+                  right: 'a',
+                  left: 'b',
+                },
+                messageId: 'unexpectedJSXPropsGroupOrder',
+              },
+            ],
+            options: [
+              {
+                customGroups: [
+                  {
+                    groupName: 'shorthandElements',
+                    selector: 'shorthand',
+                  },
+                ],
+                groups: ['shorthandElements', 'unknown'],
+              },
+            ],
+            output: dedent`
+              <Element
+                a
+                b="b"
+              />
+            `,
+            code: dedent`
+              <Element
+                b="b"
+                a
+              />
+            `,
+          },
+        ],
+        valid: [],
+      })
+
+      ruleTester.run(`${ruleName}: filters on modifier`, rule, {
+        invalid: [
+          {
+            errors: [
+              {
+                data: {
+                  rightGroup: 'shorthandElements',
+                  leftGroup: 'unknown',
+                  right: 'a',
+                  left: 'b',
+                },
+                messageId: 'unexpectedJSXPropsGroupOrder',
+              },
+            ],
+            options: [
+              {
+                customGroups: [
+                  {
+                    groupName: 'shorthandElements',
+                    modifiers: ['shorthand'],
+                  },
+                ],
+                groups: ['shorthandElements', 'unknown'],
+              },
+            ],
+            output: dedent`
+              <Element
+                a
+                b="b"
+              />
+            `,
+            code: dedent`
+              <Element
+                b="b"
+                a
+              />
+            `,
+          },
+        ],
+        valid: [],
+      })
+
+      for (let elementNamePattern of [
+        'hello',
+        ['noMatch', 'hello'],
+        { pattern: 'HELLO', flags: 'i' },
+        ['noMatch', { pattern: 'HELLO', flags: 'i' }],
+      ]) {
+        ruleTester.run(`${ruleName}: filters on elementNamePattern`, rule, {
+          invalid: [
+            {
+              options: [
+                {
+                  customGroups: [
+                    {
+                      groupName: 'shorthandsStartingWithHello',
+                      modifiers: ['shorthand'],
+                      elementNamePattern,
+                    },
+                  ],
+                  groups: ['shorthandsStartingWithHello', 'unknown'],
+                },
+              ],
+              errors: [
+                {
+                  data: {
+                    rightGroup: 'shorthandsStartingWithHello',
+                    right: 'helloShorthand',
+                    leftGroup: 'unknown',
+                    left: 'b',
+                  },
+                  messageId: 'unexpectedJSXPropsGroupOrder',
+                },
+              ],
+              output: dedent`
+                <Element
+                  helloShorthand
+                  a="a"
+                  b="b"
+                />
+              `,
+              code: dedent`
+                <Element
+                  a="a"
+                  b="b"
+                  helloShorthand
+                />
+              `,
+            },
+          ],
+          valid: [],
+        })
+      }
+
+      for (let elementValuePattern of [
+        'HELLO',
+        ['noMatch', 'HELLO'],
+        { pattern: 'hello', flags: 'i' },
+        ['noMatch', { pattern: 'hello', flags: 'i' }],
+      ]) {
+        ruleTester.run(`${ruleName}: filters on elementValuePattern`, rule, {
+          invalid: [
+            {
+              errors: [
+                {
+                  data: {
+                    rightGroup: 'valuesStartingWithHello',
+                    leftGroup: 'unknown',
+                    right: 'z',
+                    left: 'b',
+                  },
+                  messageId: 'unexpectedJSXPropsGroupOrder',
+                },
+              ],
+              options: [
+                {
+                  customGroups: [
+                    {
+                      groupName: 'valuesStartingWithHello',
+                      elementValuePattern,
+                    },
+                  ],
+                  groups: ['valuesStartingWithHello', 'unknown'],
+                },
+              ],
+              output: dedent`
+                <Element
+                  z="HELLO_VALUE"
+                  a="a"
+                  b
+                />
+              `,
+              code: dedent`
+                <Element
+                  a="a"
+                  b
+                  z="HELLO_VALUE"
+                />
+              `,
+            },
+          ],
+          valid: [],
+        })
+      }
+
+      ruleTester.run(
+        `${ruleName}: sort custom groups by overriding 'type' and 'order'`,
+        rule,
+        {
+          invalid: [
+            {
+              errors: [
+                {
+                  data: {
+                    right: 'bb',
+                    left: 'a',
+                  },
+                  messageId: 'unexpectedJSXPropsOrder',
+                },
+                {
+                  data: {
+                    right: 'ccc',
+                    left: 'bb',
+                  },
+                  messageId: 'unexpectedJSXPropsOrder',
+                },
+                {
+                  data: {
+                    right: 'dddd',
+                    left: 'ccc',
+                  },
+                  messageId: 'unexpectedJSXPropsOrder',
+                },
+                {
+                  data: {
+                    rightGroup: 'reversedShorthandsByLineLength',
+                    leftGroup: 'unknown',
+                    right: 'eee',
+                    left: 'm',
+                  },
+                  messageId: 'unexpectedJSXPropsGroupOrder',
+                },
+              ],
+              options: [
+                {
+                  customGroups: [
+                    {
+                      groupName: 'reversedShorthandsByLineLength',
+                      modifiers: ['shorthand'],
+                      type: 'line-length',
+                      order: 'desc',
+                    },
+                  ],
+                  groups: ['reversedShorthandsByLineLength', 'unknown'],
+                  type: 'alphabetical',
+                  order: 'asc',
+                },
+              ],
+              output: dedent`
+                <Element
+                  dddd
+                  ccc
+                  eee
+                  bb
+                  ff
+                  a
+                  g
+                  m="m"
+                  o="o"
+                  p="p"
+                />
+              `,
+              code: dedent`
+                <Element
+                  a
+                  bb
+                  ccc
+                  dddd
+                  m="m"
+                  eee
+                  ff
+                  g
+                  o="o"
+                  p="p"
+                />
+              `,
+            },
+          ],
+          valid: [],
+        },
+      )
+
+      ruleTester.run(
+        `${ruleName}: sort custom groups by overriding 'fallbackSort'`,
+        rule,
+        {
+          invalid: [
+            {
+              options: [
+                {
+                  customGroups: [
+                    {
+                      fallbackSort: {
+                        type: 'alphabetical',
+                        order: 'asc',
+                      },
+                      elementNamePattern: '^foo',
+                      type: 'line-length',
+                      groupName: 'foo',
+                      order: 'desc',
+                    },
+                  ],
+                  type: 'alphabetical',
+                  groups: ['foo'],
+                  order: 'asc',
+                },
+              ],
+              errors: [
+                {
+                  data: {
+                    right: 'fooBar',
+                    left: 'fooZar',
+                  },
+                  messageId: 'unexpectedJSXPropsOrder',
+                },
+              ],
+              output: dedent`
+                <Element
+                  fooBar
+                  fooZar
+                />
+              `,
+              code: dedent`
+                <Element
+                  fooZar
+                  fooBar
+                />
+              `,
+            },
+          ],
+          valid: [],
+        },
+      )
+
+      ruleTester.run(
+        `${ruleName}: does not sort custom groups with 'unsorted' type`,
+        rule,
+        {
+          invalid: [
+            {
+              options: [
+                {
+                  customGroups: [
+                    {
+                      groupName: 'unsortedShorthands',
+                      modifiers: ['shorthand'],
+                      type: 'unsorted',
+                    },
+                  ],
+                  groups: ['unsortedShorthands', 'unknown'],
+                },
+              ],
+              errors: [
+                {
+                  data: {
+                    rightGroup: 'unsortedShorthands',
+                    leftGroup: 'unknown',
+                    right: 'c',
+                    left: 'm',
+                  },
+                  messageId: 'unexpectedJSXPropsGroupOrder',
+                },
+              ],
+              output: dedent`
+                <Element
+                  b
+                  a
+                  d
+                  e
+                  c
+                  m="m"
+                />
+              `,
+              code: dedent`
+                <Element
+                  b
+                  a
+                  d
+                  e
+                  m="m"
+                  c
+                />
+              `,
+            },
+          ],
+          valid: [],
+        },
+      )
+
+      ruleTester.run(`${ruleName}: sort custom group blocks`, rule, {
+        invalid: [
+          {
+            options: [
+              {
+                customGroups: [
+                  {
+                    anyOf: [
+                      {
+                        elementNamePattern: 'foo|Foo',
+                        modifiers: ['shorthand'],
+                      },
+                      {
+                        elementNamePattern: 'foo|Foo',
+                      },
+                    ],
+                    groupName: 'elementsIncludingFoo',
+                  },
+                ],
+                groups: ['elementsIncludingFoo', 'unknown'],
+              },
+            ],
+            errors: [
+              {
+                data: {
+                  rightGroup: 'elementsIncludingFoo',
+                  leftGroup: 'unknown',
+                  right: 'cFoo',
+                  left: 'a',
+                },
+                messageId: 'unexpectedJSXPropsGroupOrder',
+              },
+            ],
+            output: dedent`
+              <Element
+                cFoo
+                foo="foo"
+                a
+              />
+            `,
+            code: dedent`
+              <Element
+                a
+                cFoo
+                foo="foo"
+              />
+            `,
+          },
+        ],
+        valid: [],
+      })
+
+      ruleTester.run(
+        `${ruleName}: allows to use regex for element names in custom groups`,
+        rule,
+        {
+          valid: [
+            {
+              options: [
+                {
+                  customGroups: [
+                    {
+                      elementNamePattern: '^(?!.*Foo).*$',
+                      groupName: 'elementsWithoutFoo',
+                    },
+                  ],
+                  groups: ['unknown', 'elementsWithoutFoo'],
+                  type: 'alphabetical',
+                },
+              ],
+              code: dedent`
+                <Element
+                  iHaveFooInMyName
+                  meTooIHaveFoo
+                  a
+                  b
+                />
+              `,
+            },
+          ],
+          invalid: [],
+        },
+      )
+    })
+
     ruleTester.run(
       `${ruleName}(${type}): allows to remove special characters`,
       rule,
diff --git a/test/utils/does-custom-group-match.test.ts b/test/utils/does-custom-group-match.test.ts
new file mode 100644
index 0000000..ddc807f
--- /dev/null
+++ b/test/utils/does-custom-group-match.test.ts
@@ -0,0 +1,126 @@
+import { describe, expect, it } from 'vitest'
+
+import { doesCustomGroupMatch } from '../../utils/does-custom-group-match'
+
+describe('does-custom-group-match', () => {
+  describe('selectors', () => {
+    it('returns true if custom group matches at least one selector', () => {
+      expect(
+        doesCustomGroupMatch({
+          customGroup: {
+            selector: 'selector1',
+          },
+          selectors: ['selector1'],
+          elementName: '',
+          modifiers: [],
+        }),
+      ).toBeTruthy()
+    })
+
+    it('returns false if custom group does not match at least one selector', () => {
+      expect(
+        doesCustomGroupMatch({
+          customGroup: {
+            selector: 'selector1',
+          },
+          selectors: ['selector2'],
+          elementName: '',
+          modifiers: [],
+        }),
+      ).toBeFalsy()
+    })
+  })
+
+  describe('modifiers', () => {
+    it('returns true if custom group matches all modifiers', () => {
+      expect(
+        doesCustomGroupMatch({
+          customGroup: {
+            modifiers: ['modifier1', 'modifier2'],
+          },
+          modifiers: ['modifier2', 'modifier1'],
+          elementName: '',
+          selectors: [],
+        }),
+      ).toBeTruthy()
+    })
+
+    it('returns false if custom group does not match all modifiers', () => {
+      expect(
+        doesCustomGroupMatch({
+          customGroup: {
+            modifiers: ['modifier1', 'modifier2'],
+          },
+          modifiers: ['modifier1'],
+          elementName: '',
+          selectors: [],
+        }),
+      ).toBeFalsy()
+    })
+  })
+
+  it.each([
+    'hello',
+    ['noMatch', 'hello'],
+    { pattern: 'HELLO', flags: 'i' },
+    ['noMatch', { pattern: 'HELLO', flags: 'i' }],
+  ])(
+    'returns true if custom group matches elementNamePattern',
+    elementNamePattern => {
+      expect(
+        doesCustomGroupMatch({
+          customGroup: {
+            elementNamePattern,
+          },
+          elementName: 'hello',
+          modifiers: [],
+          selectors: [],
+        }),
+      ).toBeTruthy()
+    },
+  )
+
+  it.each([
+    'hello',
+    ['noMatch', 'hello'],
+    { pattern: 'HELLO', flags: 'i' },
+    ['noMatch', { pattern: 'HELLO', flags: 'i' }],
+  ])(
+    'returns true if custom group matches elementValuePattern',
+    elementValuePattern => {
+      expect(
+        doesCustomGroupMatch({
+          customGroup: {
+            elementValuePattern,
+          },
+          elementValue: 'hello',
+          elementName: '',
+          modifiers: [],
+          selectors: [],
+        }),
+      ).toBeTruthy()
+    },
+  )
+
+  it.each([
+    'hello',
+    ['noMatch', 'hello'],
+    { pattern: 'HELLO', flags: 'i' },
+    ['noMatch', { pattern: 'HELLO', flags: 'i' }],
+  ])(
+    'returns true if custom group matches decoratorNamePattern',
+    decoratorNamePattern => {
+      expect(
+        doesCustomGroupMatch({
+          customGroup: {
+            decoratorNamePattern,
+          },
+          decorators: ['foo', 'hello'],
+          elementName: '',
+          modifiers: [],
+          selectors: [],
+        }),
+      ).toBeTruthy()
+    },
+  )
+})