diff --git a/apps/docs/src/app/(main)/(markdown)/migration/v5-to-v6/page.mdx b/apps/docs/src/app/(main)/(markdown)/migration/v5-to-v6/page.mdx
index d4aee4000a..4ba238f818 100644
--- a/apps/docs/src/app/(main)/(markdown)/migration/v5-to-v6/page.mdx
+++ b/apps/docs/src/app/(main)/(markdown)/migration/v5-to-v6/page.mdx
@@ -1342,6 +1342,160 @@ The `Link` component updated the following props:
## List
+There are a few breaking changes for the list components:
+
+- The `SimpleListItem` was removed in favor of using an `
` with the
+ `ListItemChildren` component
+- The `ListItem` and `ListItemLink` removed support for the ripple props
+ and updated the following props:
+ - The `forceAddonWrap` prop was removed in favor of the `leftAddonForceWrap`
+ and `rightAddonForceWrap`
+ - The `textChildren` prop was renamed to `disableTextChildren`
+- The `ListItemLink` renamed `component` to `as`
+
+> !Warn! For Typescript users, the `ListItemLink` no longer allows
+> `{ [key: string]: unknown }` when the `as` component is provided.
+
+### 🔧 update-list-item-props
+
+```sh
+npx @react-md/codemod v5-to-v6/list/update-list-item-props
+```
+
+Update `textChildren` prop:
+
+```diff
+ import { type ReactElement } from "react";
+ import { ListItem } from "react-md";
+
+ export default function Example(): ReactElement {
+ return (
+ <>
+- Hello, world!
+- Hello, world!
+- Hello, world!
+- Hello, world!
++ Hello, world!
++ Hello, world!
++ Hello, world!
++ Hello, world!
+ >
+ );
+ }
+```
+
+Remove ripple props:
+
+```diff
+ import { type ReactElement } from "react";
+ import { ListItem } from "react-md";
+ import styles from "./styles.module.scss";
+
+ export default function Example(): ReactElement {
+ return (
+ {
+ // do something
+ }}
+- disableRipple
+- disableProgrammaticRipple
+- disableEnterClick
+- disableSpacebarClick
+- disablePressedFallback
+- enablePressedAndRipple
+- rippleTimeout={100}
+- rippleClassName={styles.ripple}
+- rippleClassNames={{ enter: "", exit: "" }}
+- rippleContainerClassName="example"
+ >
+ Hello, world!
+
+ );
+ }
+```
+
+Rename `component` to `as`:
+
+```diff
+ export default function Example(): ReactElement {
+ return (
+ <>
+-
++
+ Link 1
+
+-
++
+ Link 2
+
+ >
+```
+
+Remove `SimpleListItem`:
+
+```diff
+ import type { ReactElement } from "react";
+ import cn from "classnames";
+-import { FavoriteSVGIcon, List, SimpleListItem } from "react-md";
++import { FavoriteSVGIcon, List, ListItemChildren } from "react-md";
+
+ import people from "./people";
+
+@@ -12,33 +12,35 @@ export default function Demo(): ReactElement {
+
+
+ {people.slice(0, 10).map((name) => (
+-
+- {name}
+-
++ {name}
++
+ ))}
+
+
+ {people.slice(11, 20).map((name) => (
+-
+- {name}
+-
++
++ {name}
++
+ ))}
+
+
+- Primary Text}
+- secondaryText={
+- Secondary Text
+- }
+- leftAddon={}
+- rightAddon={}
+- rightAddonType="media"
+- >
+- Other children
+-
++
++ Primary Text}
++ secondaryText={
++ Secondary Text
++ }
++ leftAddon={}
++ rightAddon={}
++ rightAddonType="media"
++ >
++ Other children
++
++
+
+
+ );
+```
+
## Material Icons
## Media
diff --git a/packages/codemod/transforms/types.ts b/packages/codemod/transforms/types.ts
index 71b322af64..3359c81e7a 100644
--- a/packages/codemod/transforms/types.ts
+++ b/packages/codemod/transforms/types.ts
@@ -1,4 +1,6 @@
import {
+ type JSXEmptyExpression,
+ type JSXExpressionContainer,
type ArrayPattern,
type ArrowFunctionExpression,
type AssignmentPattern,
@@ -73,3 +75,8 @@ export type ComponentDefinition = (
| FunctionDeclaration
| FunctionExpression
) & { body: BlockStatement };
+
+export type NonEmptyJSXExpresson = Exclude<
+ JSXExpressionContainer["expression"],
+ JSXEmptyExpression
+>;
diff --git a/packages/codemod/transforms/utils/isJsxExpressionContainer.ts b/packages/codemod/transforms/utils/isJsxExpressionContainer.ts
index 14a2832a02..b87804b986 100644
--- a/packages/codemod/transforms/utils/isJsxExpressionContainer.ts
+++ b/packages/codemod/transforms/utils/isJsxExpressionContainer.ts
@@ -1,19 +1,11 @@
-import {
- type JSCodeshift,
- type JSXEmptyExpression,
- type JSXExpressionContainer,
-} from "jscodeshift";
-
-type DefinedExpression = Exclude<
- JSXExpressionContainer["expression"],
- JSXEmptyExpression
->;
+import { type JSCodeshift, type JSXExpressionContainer } from "jscodeshift";
+import { type NonEmptyJSXExpresson } from "../types";
export function isJsxExpressionContainer(
j: JSCodeshift,
value: unknown
): value is JSXExpressionContainer & {
- expression: DefinedExpression;
+ expression: NonEmptyJSXExpresson;
} {
return (
j.JSXExpressionContainer.check(value) &&
diff --git a/packages/codemod/transforms/utils/isPropConditionalExpression.ts b/packages/codemod/transforms/utils/isPropConditionalExpression.ts
index ad56570028..547183d0f0 100644
--- a/packages/codemod/transforms/utils/isPropConditionalExpression.ts
+++ b/packages/codemod/transforms/utils/isPropConditionalExpression.ts
@@ -1,18 +1,15 @@
import {
- type JSXText,
- type Identifier,
type JSXAttribute,
type JSXExpressionContainer,
- type Literal,
- type NumericLiteral,
- type StringLiteral,
+ type BooleanLiteral,
} from "jscodeshift";
+import { type NonEmptyJSXExpresson } from "../types";
export function isPropConditionalExpression(
attr: JSXAttribute
): attr is JSXAttribute & {
value: JSXExpressionContainer & {
- expression: Identifier | Literal | StringLiteral | NumericLiteral | JSXText;
+ expression: Exclude;
};
} {
return (
diff --git a/packages/codemod/transforms/v5-to-v6/README.md b/packages/codemod/transforms/v5-to-v6/README.md
index cac697cd99..3554a85d65 100644
--- a/packages/codemod/transforms/v5-to-v6/README.md
+++ b/packages/codemod/transforms/v5-to-v6/README.md
@@ -96,7 +96,7 @@ build times by switching to `@react-md/core/{{FILE}}`.
- [x] icon
- [ ] layout
- [x] link
-- [ ] list
+- [x] list
- [x] material-icons
- [ ] media
- [ ] menu
diff --git a/packages/codemod/transforms/v5-to-v6/list/__testfixtures__/DeprecatedListItemLinkProps.input.tsx b/packages/codemod/transforms/v5-to-v6/list/__testfixtures__/DeprecatedListItemLinkProps.input.tsx
new file mode 100644
index 0000000000..73faf890db
--- /dev/null
+++ b/packages/codemod/transforms/v5-to-v6/list/__testfixtures__/DeprecatedListItemLinkProps.input.tsx
@@ -0,0 +1,26 @@
+import { type ReactElement } from "react";
+import { ListItemLink } from "react-md";
+import styles from "./styles.module.scss";
+
+export default function Example(): ReactElement {
+ return (
+ {
+ // do something
+ }}
+ disableRipple
+ disableProgrammaticRipple
+ disableEnterClick
+ disableSpacebarClick
+ disablePressedFallback
+ enablePressedAndRipple
+ rippleTimeout={100}
+ rippleClassName={styles.ripple}
+ rippleClassNames={{ enter: "", exit: "" }}
+ rippleContainerClassName="example"
+ >
+ Hello, world!
+
+ );
+}
diff --git a/packages/codemod/transforms/v5-to-v6/list/__testfixtures__/DeprecatedListItemLinkProps.output.tsx b/packages/codemod/transforms/v5-to-v6/list/__testfixtures__/DeprecatedListItemLinkProps.output.tsx
new file mode 100644
index 0000000000..5d6dea0d82
--- /dev/null
+++ b/packages/codemod/transforms/v5-to-v6/list/__testfixtures__/DeprecatedListItemLinkProps.output.tsx
@@ -0,0 +1,14 @@
+import { type ReactElement } from "react";
+import { ListItemLink } from "react-md";
+import styles from "./styles.module.scss";
+
+export default function Example(): ReactElement {
+ return (
+ ( {
+ // do something
+ }}>Hello, world!
+ )
+ );
+}
diff --git a/packages/codemod/transforms/v5-to-v6/list/__testfixtures__/DeprecatedListItemProps.input.tsx b/packages/codemod/transforms/v5-to-v6/list/__testfixtures__/DeprecatedListItemProps.input.tsx
new file mode 100644
index 0000000000..299dd7375d
--- /dev/null
+++ b/packages/codemod/transforms/v5-to-v6/list/__testfixtures__/DeprecatedListItemProps.input.tsx
@@ -0,0 +1,25 @@
+import { type ReactElement } from "react";
+import { ListItem } from "react-md";
+import styles from "./styles.module.scss";
+
+export default function Example(): ReactElement {
+ return (
+ {
+ // do something
+ }}
+ disableRipple
+ disableProgrammaticRipple
+ disableEnterClick
+ disableSpacebarClick
+ disablePressedFallback
+ enablePressedAndRipple
+ rippleTimeout={100}
+ rippleClassName={styles.ripple}
+ rippleClassNames={{ enter: "", exit: "" }}
+ rippleContainerClassName="example"
+ >
+ Hello, world!
+
+ );
+}
diff --git a/packages/codemod/transforms/v5-to-v6/list/__testfixtures__/DeprecatedListItemProps.output.tsx b/packages/codemod/transforms/v5-to-v6/list/__testfixtures__/DeprecatedListItemProps.output.tsx
new file mode 100644
index 0000000000..8fed295a0e
--- /dev/null
+++ b/packages/codemod/transforms/v5-to-v6/list/__testfixtures__/DeprecatedListItemProps.output.tsx
@@ -0,0 +1,13 @@
+import { type ReactElement } from "react";
+import { ListItem } from "react-md";
+import styles from "./styles.module.scss";
+
+export default function Example(): ReactElement {
+ return (
+ ( {
+ // do something
+ }}>Hello, world!
+ )
+ );
+}
diff --git a/packages/codemod/transforms/v5-to-v6/list/__testfixtures__/ListItemLink.input.tsx b/packages/codemod/transforms/v5-to-v6/list/__testfixtures__/ListItemLink.input.tsx
new file mode 100644
index 0000000000..769eebff7a
--- /dev/null
+++ b/packages/codemod/transforms/v5-to-v6/list/__testfixtures__/ListItemLink.input.tsx
@@ -0,0 +1,17 @@
+import { type ReactElement } from "react";
+import { ListItemLink } from "react-md";
+
+import { CustomLink } from "./CustomLink";
+
+export default function Example(): ReactElement {
+ return (
+ <>
+
+ Link 1
+
+
+ Link 2
+
+ >
+ );
+}
diff --git a/packages/codemod/transforms/v5-to-v6/list/__testfixtures__/ListItemLink.output.tsx b/packages/codemod/transforms/v5-to-v6/list/__testfixtures__/ListItemLink.output.tsx
new file mode 100644
index 0000000000..fcc927f770
--- /dev/null
+++ b/packages/codemod/transforms/v5-to-v6/list/__testfixtures__/ListItemLink.output.tsx
@@ -0,0 +1,15 @@
+import { type ReactElement } from "react";
+import { ListItemLink } from "react-md";
+
+import { CustomLink } from "./CustomLink";
+
+export default function Example(): ReactElement {
+ return (<>
+
+ Link 1
+
+
+ Link 2
+
+ >);
+}
diff --git a/packages/codemod/transforms/v5-to-v6/list/__testfixtures__/ListItemTextChildrenProp.input.tsx b/packages/codemod/transforms/v5-to-v6/list/__testfixtures__/ListItemTextChildrenProp.input.tsx
new file mode 100644
index 0000000000..08d9349860
--- /dev/null
+++ b/packages/codemod/transforms/v5-to-v6/list/__testfixtures__/ListItemTextChildrenProp.input.tsx
@@ -0,0 +1,13 @@
+import { type ReactElement } from "react";
+import { ListItem } from "react-md";
+
+export default function Example(): ReactElement {
+ return (
+ <>
+ Hello, world!
+ Hello, world!
+ Hello, world!
+ Hello, world!
+ >
+ );
+}
diff --git a/packages/codemod/transforms/v5-to-v6/list/__testfixtures__/ListItemTextChildrenProp.output.tsx b/packages/codemod/transforms/v5-to-v6/list/__testfixtures__/ListItemTextChildrenProp.output.tsx
new file mode 100644
index 0000000000..4c2b829c5c
--- /dev/null
+++ b/packages/codemod/transforms/v5-to-v6/list/__testfixtures__/ListItemTextChildrenProp.output.tsx
@@ -0,0 +1,11 @@
+import { type ReactElement } from "react";
+import { ListItem } from "react-md";
+
+export default function Example(): ReactElement {
+ return (<>
+ Hello, world!
+ Hello, world!
+ Hello, world!
+ Hello, world!
+ >);
+}
diff --git a/packages/codemod/transforms/v5-to-v6/list/__testfixtures__/SimpleListItem.input.tsx b/packages/codemod/transforms/v5-to-v6/list/__testfixtures__/SimpleListItem.input.tsx
new file mode 100644
index 0000000000..3f62241e55
--- /dev/null
+++ b/packages/codemod/transforms/v5-to-v6/list/__testfixtures__/SimpleListItem.input.tsx
@@ -0,0 +1,45 @@
+import type { ReactElement } from "react";
+import cn from "classnames";
+import { FavoriteSVGIcon, List, SimpleListItem } from "react-md";
+
+import people from "./people";
+
+import Container from "./Container";
+import styles from "./NonInteractable.module.scss";
+
+export default function Demo(): ReactElement {
+ return (
+
+
+ {people.slice(0, 10).map((name) => (
+
+ {name}
+
+ ))}
+
+
+ {people.slice(11, 20).map((name) => (
+
+ {name}
+
+ ))}
+
+
+ Primary Text}
+ secondaryText={
+ Secondary Text
+ }
+ leftAddon={}
+ rightAddon={}
+ rightAddonType="media"
+ >
+ Other children
+
+
+
+ );
+}
diff --git a/packages/codemod/transforms/v5-to-v6/list/__testfixtures__/SimpleListItem.output.tsx b/packages/codemod/transforms/v5-to-v6/list/__testfixtures__/SimpleListItem.output.tsx
new file mode 100644
index 0000000000..34c93cb285
--- /dev/null
+++ b/packages/codemod/transforms/v5-to-v6/list/__testfixtures__/SimpleListItem.output.tsx
@@ -0,0 +1,40 @@
+import type { ReactElement } from "react";
+import cn from "classnames";
+import { FavoriteSVGIcon, List, ListItemChildren } from "react-md";
+
+import people from "./people";
+
+import Container from "./Container";
+import styles from "./NonInteractable.module.scss";
+
+export default function Demo(): ReactElement {
+ return (
+ (
+
+ {people.slice(0, 10).map((name) => (
+
+ {name}
+
+ ))}
+
+
+ {people.slice(11, 20).map((name) => (
+
+ {name}
+
+ ))}
+
+
+ Primary Text}
+ secondaryText={
+ Secondary Text
+ }
+ leftAddon={}
+ rightAddon={}
+ rightAddonType="media">Other children
+
+
+ )
+ );
+}
diff --git a/packages/codemod/transforms/v5-to-v6/list/__tests__/update-list-item-props.ts b/packages/codemod/transforms/v5-to-v6/list/__tests__/update-list-item-props.ts
new file mode 100644
index 0000000000..d9a87df83e
--- /dev/null
+++ b/packages/codemod/transforms/v5-to-v6/list/__tests__/update-list-item-props.ts
@@ -0,0 +1,13 @@
+import { defineTest } from "jscodeshift/src/testUtils";
+
+const test = (fixture: string): void => {
+ defineTest(__dirname, "update-list-item-props", null, fixture, {
+ parser: "tsx",
+ });
+};
+
+test("DeprecatedListItemProps");
+test("ListItemTextChildrenProp");
+test("ListItemLink");
+test("DeprecatedListItemLinkProps");
+test("SimpleListItem");
diff --git a/packages/codemod/transforms/v5-to-v6/list/update-list-item-props.ts b/packages/codemod/transforms/v5-to-v6/list/update-list-item-props.ts
new file mode 100644
index 0000000000..f00cb42375
--- /dev/null
+++ b/packages/codemod/transforms/v5-to-v6/list/update-list-item-props.ts
@@ -0,0 +1,256 @@
+import {
+ type API,
+ type Collection,
+ type FileInfo,
+ type JSCodeshift,
+ type JSXExpressionContainer,
+ type Options,
+} from "jscodeshift";
+import { type JSXAttributes } from "../../types";
+import { addImportSpecifiers } from "../../utils/addImportSpecifiers";
+import { createJsxElement } from "../../utils/createJsxElement";
+import { getPropName } from "../../utils/getPropName";
+import { isJsxExpressionContainer } from "../../utils/isJsxExpressionContainer";
+import { isPropBooleanExpression } from "../../utils/isPropBooleanExpression";
+import { isPropEnabled } from "../../utils/isPropEnabled";
+import { negateExpression } from "../../utils/negateExpression";
+import { removeProps } from "../../utils/removeProps";
+import { renameImportSpecifier } from "../../utils/renameImportSpecifier";
+import { traverseImportSpecifiers } from "../../utils/traverseImportSpecifiers";
+import { REMOVED_INTERACTION_PROPS } from "../interaction/constants";
+
+interface UpdateListItemPropsOptions {
+ j: JSCodeshift;
+ root: Collection;
+ name: string;
+ renames?: Record;
+}
+
+function updateListItemProps(options: UpdateListItemPropsOptions): void {
+ const { j, root, name, renames } = options;
+
+ traverseImportSpecifiers({
+ j,
+ root,
+ name,
+ }).forEach((name) => {
+ removeProps({
+ root,
+ props: REMOVED_INTERACTION_PROPS,
+ component: name,
+ });
+
+ root
+ .find(j.JSXOpeningElement, { name: { name } })
+ .forEach((jsxOpeningElement) => {
+ const props: JSXAttributes = [];
+ jsxOpeningElement.node.attributes?.forEach((attr) => {
+ if (!j.JSXAttribute.check(attr)) {
+ props.push(attr);
+ return;
+ }
+
+ const name = getPropName(attr);
+ switch (name) {
+ // convert:
+ // - `forceAddonWrap={false}` -> nothing
+ // - `forceAddonWrap`/`forceAddonWrap={true}` -> `leftAddonForceWrap rightAddonForceWrap`
+ // - `forceAddonWrap={flag}` -> `leftAddonForceWrap={flag} rightAddonForceWrap={flag}`
+ case "forceAddonWrap": {
+ if (
+ isPropBooleanExpression(attr) &&
+ !attr.value.expression.value
+ ) {
+ return;
+ }
+
+ let value: JSXExpressionContainer | null = null;
+ if (isJsxExpressionContainer(j, attr.value)) {
+ ({ value } = attr);
+ }
+
+ props.push(
+ j.jsxAttribute(j.jsxIdentifier("leftAddonForceWrap"), value),
+ j.jsxAttribute(j.jsxIdentifier("rightAddonForceWrap"), value)
+ );
+ break;
+ }
+
+ // convert:
+ // - `textChildren`/`textChildren={true}` -> nothing
+ // - `textChildren={false}` to `disableTextChildren`
+ // - `textChildren={someFlag}` to `disableTextChildren={!someFlag}`
+ case "textChildren": {
+ if (
+ !isJsxExpressionContainer(j, attr.value) ||
+ isPropEnabled(attr)
+ ) {
+ return;
+ }
+
+ let value: JSXExpressionContainer | null = null;
+ if (!isPropBooleanExpression(attr)) {
+ value = j.jsxExpressionContainer(
+ negateExpression({ j, expr: attr.value.expression })
+ );
+ }
+
+ props.push(
+ j.jsxAttribute(j.jsxIdentifier("disableTextChildren"), value)
+ );
+ break;
+ }
+
+ case "threeLines":
+ attr.name.name = "multiline";
+ props.push(attr);
+ break;
+
+ default: {
+ const rename = renames?.[name];
+ if (rename) {
+ attr.name.name = rename;
+ }
+
+ props.push(attr);
+ }
+ }
+ });
+
+ jsxOpeningElement.node.attributes = props;
+ });
+ });
+}
+
+const LIST_ITEM_CHILDREN_PROPS = new Set([
+ "textClassName",
+ "secondaryTextClassName",
+ "primaryText",
+ "secondaryText",
+ "leftAddon",
+ "leftAddonType",
+ "leftAddonPosition",
+ "rightAddon",
+ "rightAddonType",
+ "rightAddonPosition",
+
+ // these are deprecated, but will be handled by the `updateListItemProps`
+ "threeLines",
+ "textChildren",
+]);
+
+interface ConvertSimpleListItemToListItemChildrenOptions {
+ j: JSCodeshift;
+ root: Collection;
+ imports: Set;
+}
+
+function convertSimpleListItemToListItemChildren(
+ options: ConvertSimpleListItemToListItemChildrenOptions
+): void {
+ const { j, root, imports } = options;
+ traverseImportSpecifiers({
+ j,
+ root,
+ name: "SimpleListItem",
+ remove: true,
+ }).forEach((name) => {
+ imports.add("ListItemChildren");
+
+ root
+ .find(j.JSXElement, { openingElement: { name: { name } } })
+ .forEach((jsxElement) => {
+ const props: JSXAttributes = [];
+ const childrenProps: JSXAttributes = [];
+ jsxElement.node.openingElement.attributes?.forEach((attr) => {
+ if (j.JSXSpreadAttribute.check(attr)) {
+ props.push(attr);
+ return;
+ }
+
+ const name = getPropName(attr);
+ if (LIST_ITEM_CHILDREN_PROPS.has(name)) {
+ childrenProps.push(attr);
+ return;
+ }
+
+ switch (name) {
+ case "height":
+ case "clickable":
+ case "disabled":
+ case "disabledOpacity":
+ break;
+ default:
+ props.push(attr);
+ }
+ });
+
+ j(jsxElement).replaceWith(
+ createJsxElement({
+ j,
+ name: "li",
+ props,
+ children: [
+ createJsxElement({
+ j,
+ name: "ListItemChildren",
+ props: childrenProps,
+ children: jsxElement.node.children,
+ }),
+ ],
+ })
+ );
+ });
+ });
+
+ renameImportSpecifier({
+ j,
+ root,
+ from: "SimpleListItemProps",
+ to: "ListItemChildrenProps",
+ });
+}
+
+export default function transformer(
+ file: FileInfo,
+ api: API,
+ options: Options
+): string {
+ const j = api.jscodeshift;
+ const root = j(file.source);
+ const printOptions = options.printOptions;
+
+ const imports = new Set();
+ updateListItemProps({
+ j,
+ root,
+ name: "ListItem",
+ });
+ updateListItemProps({
+ j,
+ root,
+ name: "ListItemLink",
+ renames: {
+ component: "as",
+ },
+ });
+
+ convertSimpleListItemToListItemChildren({
+ j,
+ root,
+ imports,
+ });
+ updateListItemProps({
+ j,
+ root,
+ name: "ListItemChildren",
+ });
+
+ addImportSpecifiers({
+ j,
+ root,
+ imports,
+ });
+
+ return root.toSource(printOptions);
+}
diff --git a/packages/core/src/list/ListSubheader.tsx b/packages/core/src/list/ListSubheader.tsx
index 20958825ab..4d7aa0abac 100644
--- a/packages/core/src/list/ListSubheader.tsx
+++ b/packages/core/src/list/ListSubheader.tsx
@@ -30,6 +30,9 @@ export function listSubheader(
return cnb(styles({ inset }), className);
}
+/**
+ * @since 6.0.0 The `role` prop defaults to `"presentation"`
+ */
export interface ListSubheaderProps
extends HTMLAttributes,
ListSubheaderClassNameOptions {