From dc2be44030b2ce31cb6396e01f1ec6d302be2c36 Mon Sep 17 00:00:00 2001 From: Adam Sajko Date: Fri, 7 Aug 2026 12:24:00 +0200 Subject: [PATCH 01/11] refactor: align List tokens, spacing and typography with MD3 --- src/components/List/ListAccordion.tsx | 59 ++-- src/components/List/ListItem.tsx | 43 +-- src/components/List/tokens.ts | 18 ++ src/components/List/utils.ts | 85 +----- .../__tests__/ListAccordion.test.tsx | 48 ++-- .../__snapshots__/ListAccordion.test.tsx.snap | 253 ++++++++--------- .../__snapshots__/ListItem.test.tsx.snap | 260 ++++++++++-------- .../__snapshots__/ListSection.test.tsx.snap | 144 +++++----- 8 files changed, 435 insertions(+), 475 deletions(-) create mode 100644 src/components/List/tokens.ts diff --git a/src/components/List/ListAccordion.tsx b/src/components/List/ListAccordion.tsx index 97c930c52e..a171582158 100644 --- a/src/components/List/ListAccordion.tsx +++ b/src/components/List/ListAccordion.tsx @@ -13,8 +13,9 @@ import type { } from 'react-native'; import { ListAccordionGroupContext } from './ListAccordionGroup'; +import { ListTokens } from './tokens'; import type { ListChildProps, Style } from './utils'; -import { getAccordionColors, getLeftStyles } from './utils'; +import { getLeftStyles } from './utils'; import { useLocale } from '../../core/locale'; import { useInternalTheme } from '../../core/theming'; import type { ThemeProp } from '../../theme/types'; @@ -232,10 +233,8 @@ const ListAccordion = ({ ? groupContext.expandedId === id : expandedInternal; - const { descriptionColor, titleTextColor } = getAccordionColors({ - theme, - isExpanded, - }); + const titleTextColor = theme.colors[ListTokens.headlineColor]; + const descriptionColor = theme.colors[ListTokens.supportingTextColor]; const handlePress = groupContext && id !== undefined @@ -243,9 +242,15 @@ const ListAccordion = ({ : handlePressAction; return ( - + {left ? left({ - color: isExpanded ? theme.colors?.primary : descriptionColor, + color: theme.colors[ListTokens.leadingIconColor], style: getLeftStyles(alignToTop, description), }) : null} {description ? ( ) : null} - + {right ? ( right({ isExpanded: isExpanded, @@ -314,7 +314,7 @@ const ListAccordion = ({ ) : ( @@ -349,29 +349,22 @@ ListAccordion.displayName = 'List.Accordion'; const styles = StyleSheet.create({ container: { - paddingVertical: 8, - paddingRight: 24, + paddingRight: ListTokens.trailingSpace, }, - row: { - flexDirection: 'row', - marginVertical: 6, - }, - multiline: { - height: 40, - alignItems: 'center', - justifyContent: 'center', + containerOneLine: { + paddingVertical: ListTokens.oneLineVerticalPadding, }, - title: { - fontSize: 16, + containerTwoLine: { + paddingVertical: ListTokens.twoLineVerticalPadding, }, - description: { - fontSize: 14, + row: { + flexDirection: 'row', }, contentItem: { - paddingLeft: 16, + paddingLeft: ListTokens.leadingSpace, }, trailingItem: { - marginVertical: 6, + alignSelf: 'center', paddingLeft: 8, }, child: { diff --git a/src/components/List/ListItem.tsx b/src/components/List/ListItem.tsx index 740b3df5d2..2ffa684a0c 100644 --- a/src/components/List/ListItem.tsx +++ b/src/components/List/ListItem.tsx @@ -11,6 +11,7 @@ import type { ViewStyle, } from 'react-native'; +import { ListTokens } from './tokens'; import { getLeftStyles, getRightStyles } from './utils'; import type { Style } from './utils'; import { useInternalTheme } from '../../core/theming'; @@ -184,18 +185,15 @@ const ListItem = ({ selectable: false, ellipsizeMode: descriptionEllipsizeMode, color: descriptionColor, - fontSize: styles.description.fontSize, + fontSize: theme.fonts.bodyMedium.fontSize, }) ) : ( @@ -205,21 +203,22 @@ const ListItem = ({ }; const renderTitle = () => { - const titleColor = theme.colors.onSurface; + const titleColor = theme.colors[ListTokens.headlineColor]; return typeof title === 'function' ? ( title({ selectable: false, ellipsizeMode: titleEllipsizeMode, color: titleColor, - fontSize: styles.title.fontSize, + fontSize: theme.fonts.bodyLarge.fontSize, }) ) : ( {title} @@ -227,13 +226,17 @@ const ListItem = ({ ); }; - const descriptionColor = theme.colors.onSurfaceVariant; + const descriptionColor = theme.colors[ListTokens.supportingTextColor]; return ( ; + +export const ListTokens = { ...sizes, ...colors }; diff --git a/src/components/List/utils.ts b/src/components/List/utils.ts index e01f0e55c6..296acc4e4e 100644 --- a/src/components/List/utils.ts +++ b/src/components/List/utils.ts @@ -1,7 +1,7 @@ -import { StyleSheet } from 'react-native'; import type { StyleProp, TextProps, ViewStyle } from 'react-native'; -import type { InternalTheme, ThemeProp } from '../../theme/types'; +import { ListTokens } from './tokens'; +import type { ThemeProp } from '../../theme/types'; type Description = | React.ReactNode @@ -26,82 +26,21 @@ export type Style = { alignSelf?: 'flex-start' | 'center'; }; -const stylesV3Left = { - marginRight: 0, - marginLeft: 16, -}; - -const stylesV3Right = { - marginLeft: 16, -}; - -export const getLeftStyles = ( - alignToTop: boolean, - description: Description -) => { - const stylesV3: Style = { - ...stylesV3Left, - alignSelf: alignToTop ? 'flex-start' : 'center', - }; - - if (!description) { - return { - ...styles.iconMarginLeft, - ...styles.marginVerticalNone, - ...stylesV3, - }; - } - - return { - ...styles.iconMarginLeft, - ...stylesV3, - }; -}; - -export const getRightStyles = ( +const getAccessoryStyles = ( alignToTop: boolean, description: Description -) => { - const stylesV3: Style = { - ...stylesV3Right, +): Style => { + const style: Style = { + marginLeft: ListTokens.leadingSpace, + marginRight: 0, alignSelf: alignToTop ? 'flex-start' : 'center', }; - if (!description) { - return { - ...styles.iconMarginRight, - ...styles.marginVerticalNone, - ...stylesV3, - }; - } - - return { - ...styles.iconMarginRight, - ...stylesV3, - }; + return description ? style : { ...style, marginVertical: 0 }; }; -const styles = StyleSheet.create({ - marginVerticalNone: { marginVertical: 0 }, - iconMarginLeft: { marginLeft: 0, marginRight: 16 }, - iconMarginRight: { marginRight: 0 }, -}); +export const getLeftStyles = (alignToTop: boolean, description: Description) => + getAccessoryStyles(alignToTop, description); -export const getAccordionColors = ({ - theme, - isExpanded, -}: { - theme: InternalTheme; - isExpanded?: boolean; -}) => { - const titleColor = theme.colors.onSurface; - - const descriptionColor = theme.colors.onSurfaceVariant; - - const titleTextColor = isExpanded ? theme.colors?.primary : titleColor; - - return { - descriptionColor, - titleTextColor, - }; -}; +export const getRightStyles = (alignToTop: boolean, description: Description) => + getAccessoryStyles(alignToTop, description); diff --git a/src/components/__tests__/ListAccordion.test.tsx b/src/components/__tests__/ListAccordion.test.tsx index ad1783ec52..f17f49345e 100644 --- a/src/components/__tests__/ListAccordion.test.tsx +++ b/src/components/__tests__/ListAccordion.test.tsx @@ -3,13 +3,12 @@ import { StyleSheet, View } from 'react-native'; import { describe, expect, it } from '@jest/globals'; import { getTheme } from '../../core/theming'; -import { render } from '../../test-utils'; +import { render, screen } from '../../test-utils'; import { red500 } from '../../theme/colors'; import ListAccordion from '../List/ListAccordion'; import ListAccordionGroup from '../List/ListAccordionGroup'; import ListIcon from '../List/ListIcon'; import ListItem from '../List/ListItem'; -import { getAccordionColors } from '../List/utils'; const styles = StyleSheet.create({ coloring: { @@ -120,39 +119,28 @@ describe('ListAccordion', () => { 'List.Accordion is used inside a List.AccordionGroup without specifying an id prop.' ); }); -}); -describe('getAccordionColors - description color', () => { - it('should return theme color, for theme version 3', () => { - expect( - getAccordionColors({ - theme: getTheme(), - }) - ).toMatchObject({ - descriptionColor: getTheme().colors.onSurfaceVariant, - }); - }); -}); + it('keeps the title on onSurface when collapsed', async () => { + await render( + + + + ); -describe('getAccordionColors - title text color', () => { - it('should return theme color, for theme version 3', () => { - expect( - getAccordionColors({ - theme: getTheme(), - }) - ).toMatchObject({ - titleTextColor: getTheme().colors.onSurface, + expect(screen.getByText('Accordion item 1')).toHaveStyle({ + color: getTheme().colors.onSurface, }); }); - it('should return primary color if it is expanded', () => { - expect( - getAccordionColors({ - theme: getTheme(), - isExpanded: true, - }) - ).toMatchObject({ - titleTextColor: getTheme().colors?.primary, + it('keeps the title on onSurface when expanded', async () => { + await render( + + + + ); + + expect(screen.getByText('Accordion item 1')).toHaveStyle({ + color: getTheme().colors.onSurface, }); }); }); diff --git a/src/components/__tests__/__snapshots__/ListAccordion.test.tsx.snap b/src/components/__tests__/__snapshots__/ListAccordion.test.tsx.snap index c01fcf856a..7aebd81411 100644 --- a/src/components/__tests__/__snapshots__/ListAccordion.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/ListAccordion.test.tsx.snap @@ -48,7 +48,9 @@ exports[`renders expanded accordion 1`] = ` [ { "paddingRight": 24, - "paddingVertical": 8, + }, + { + "paddingVertical": 16, }, undefined, ], @@ -61,7 +63,6 @@ exports[`renders expanded accordion 1`] = ` [ { "flexDirection": "row", - "marginVertical": 6, }, undefined, ] @@ -91,21 +92,22 @@ exports[`renders expanded accordion 1`] = ` }, { "color": "rgba(29, 27, 32, 1)", - "fontFamily": "System", - "fontWeight": "400", - "letterSpacing": 0, - }, - { "writingDirection": "ltr", }, [ { + "fontFamily": "System", "fontSize": 16, + "fontWeight": "400", + "letterSpacing": 0.5, + "lineHeight": 24, }, - { - "color": "rgba(103, 80, 164, 1)", - }, - undefined, + [ + { + "color": "rgba(29, 27, 32, 1)", + }, + undefined, + ], ], ] } @@ -115,13 +117,10 @@ exports[`renders expanded accordion 1`] = ` Date: Wed, 19 Aug 2026 16:19:26 +0200 Subject: [PATCH 02/11] fix: propagate theme override and add three-line padding to List --- src/components/List/ListAccordion.tsx | 28 +++++++---- src/components/List/ListItem.tsx | 34 +++++++++----- src/components/List/tokens.ts | 1 + .../__tests__/ListAccordion.test.tsx | 47 ++++++++++++++++++- src/components/__tests__/ListItem.test.tsx | 36 +++++++++++++- 5 files changed, 125 insertions(+), 21 deletions(-) diff --git a/src/components/List/ListAccordion.tsx b/src/components/List/ListAccordion.tsx index a171582158..dfacd258d1 100644 --- a/src/components/List/ListAccordion.tsx +++ b/src/components/List/ListAccordion.tsx @@ -202,13 +202,24 @@ const ListAccordion = ({ const [expanded, setExpanded] = React.useState( expandedProp || false ); - const [alignToTop, setAlignToTop] = React.useState(false); + const [isDescriptionMultiline, setIsDescriptionMultiline] = + React.useState(false); const onDescriptionTextLayout = ( event: NativeSyntheticEvent ) => { const { nativeEvent } = event; - setAlignToTop(nativeEvent.lines.length >= 2); + setIsDescriptionMultiline(nativeEvent.lines.length >= 2); + }; + + const getVerticalPaddingStyle = () => { + if (!description) { + return styles.containerOneLine; + } + + return isDescriptionMultiline + ? styles.containerThreeLine + : styles.containerTwoLine; }; const handlePressAction = (e: GestureResponderEvent) => { @@ -246,11 +257,7 @@ const ListAccordion = ({ style={{ backgroundColor: theme.colors[ListTokens.containerColor] }} > { const theme = useInternalTheme(themeOverrides); - const [alignToTop, setAlignToTop] = React.useState(false); + const [isDescriptionMultiline, setIsDescriptionMultiline] = + React.useState(false); const onDescriptionTextLayout = ( event: NativeSyntheticEvent ) => { const { nativeEvent } = event; - setAlignToTop(nativeEvent.lines.length >= 2); + setIsDescriptionMultiline(nativeEvent.lines.length >= 2); + }; + + const getVerticalPaddingStyle = () => { + if (!description) { + return styles.containerOneLine; + } + + return isDescriptionMultiline + ? styles.containerThreeLine + : styles.containerTwoLine; }; const renderDescription = ( @@ -190,6 +201,7 @@ const ListItem = ({ ) : ( {left ? left({ - color: descriptionColor, - style: getLeftStyles(alignToTop, description), + color: theme.colors[ListTokens.leadingIconColor], + style: getLeftStyles(isDescriptionMultiline, description), }) : null} {right ? right({ - color: descriptionColor, - style: getRightStyles(alignToTop, description), + color: theme.colors[ListTokens.trailingIconColor], + style: getRightStyles(isDescriptionMultiline, description), }) : null} @@ -281,6 +290,9 @@ const styles = StyleSheet.create({ containerTwoLine: { paddingVertical: ListTokens.twoLineVerticalPadding, }, + containerThreeLine: { + paddingVertical: ListTokens.threeLineVerticalPadding, + }, row: { width: '100%', flexDirection: 'row', diff --git a/src/components/List/tokens.ts b/src/components/List/tokens.ts index 329060c127..c753764bfa 100644 --- a/src/components/List/tokens.ts +++ b/src/components/List/tokens.ts @@ -3,6 +3,7 @@ import type { ColorRole } from '../../theme/types'; const sizes = { oneLineVerticalPadding: 16, twoLineVerticalPadding: 14, + threeLineVerticalPadding: 12, leadingSpace: 16, trailingSpace: 24, } as const; diff --git a/src/components/__tests__/ListAccordion.test.tsx b/src/components/__tests__/ListAccordion.test.tsx index f17f49345e..deb13e4e96 100644 --- a/src/components/__tests__/ListAccordion.test.tsx +++ b/src/components/__tests__/ListAccordion.test.tsx @@ -3,7 +3,7 @@ import { StyleSheet, View } from 'react-native'; import { describe, expect, it } from '@jest/globals'; import { getTheme } from '../../core/theming'; -import { render, screen } from '../../test-utils'; +import { fireEvent, render, screen } from '../../test-utils'; import { red500 } from '../../theme/colors'; import ListAccordion from '../List/ListAccordion'; import ListAccordionGroup from '../List/ListAccordionGroup'; @@ -143,4 +143,49 @@ describe('ListAccordion', () => { color: getTheme().colors.onSurface, }); }); + + it('drops to 12dp padding once the description wraps to a third line', async () => { + await render( + + + + ); + + expect(screen.getByTestId('list-accordion')).toHaveStyle({ + paddingVertical: 14, + }); + + await fireEvent( + screen.getByText('Describes the expandable list item'), + 'textLayout', + { nativeEvent: { lines: [{}, {}] } } + ); + + expect(screen.getByTestId('list-accordion')).toHaveStyle({ + paddingVertical: 12, + }); + }); + + it('applies the theme override to title and description typography', async () => { + await render( + + + + ); + + expect(screen.getByText('Accordion item 1')).toHaveStyle({ fontSize: 99 }); + expect(screen.getByText('Describes the expandable list item')).toHaveStyle({ + fontSize: 77, + }); + }); }); diff --git a/src/components/__tests__/ListItem.test.tsx b/src/components/__tests__/ListItem.test.tsx index e0e1cb5303..8126b18952 100644 --- a/src/components/__tests__/ListItem.test.tsx +++ b/src/components/__tests__/ListItem.test.tsx @@ -5,7 +5,7 @@ import { Text, View } from 'react-native'; import { expect, it, jest } from '@jest/globals'; import { userEvent } from '@testing-library/react-native'; -import { render, screen } from '../../test-utils'; +import { fireEvent, render, screen } from '../../test-utils'; import { red500 } from '../../theme/colors'; import Chip from '../Chip/Chip'; import IconButton from '../IconButton/IconButton'; @@ -177,3 +177,37 @@ it('renders list item with custom content style', async () => { expect(screen.getByTestId('list-item-content')).toHaveStyle(styles.content); }); + +it('drops to 12dp padding once the description wraps to a third line', async () => { + await render( + + ); + + expect(screen.getByTestId(testID)).toHaveStyle({ paddingVertical: 14 }); + + await fireEvent(screen.getByText('Item description'), 'textLayout', { + nativeEvent: { lines: [{}, {}] }, + }); + + expect(screen.getByTestId(testID)).toHaveStyle({ paddingVertical: 12 }); +}); + +it('applies the theme override to title and description typography', async () => { + await render( + + ); + + expect(screen.getByText('First Item')).toHaveStyle({ fontSize: 99 }); + expect(screen.getByText('Item description')).toHaveStyle({ fontSize: 77 }); +}); From f38d80d68baa3408bb1b3afe411c126288b3703e Mon Sep 17 00:00:00 2001 From: Adam Sajko Date: Wed, 19 Aug 2026 17:39:38 +0200 Subject: [PATCH 03/11] fix: use 16dp trailing inset to match MD3 --- src/components/List/tokens.ts | 2 +- .../__snapshots__/ListAccordion.test.tsx.snap | 12 ++++++------ .../__tests__/__snapshots__/ListItem.test.tsx.snap | 14 +++++++------- .../__snapshots__/ListSection.test.tsx.snap | 12 ++++++------ 4 files changed, 20 insertions(+), 20 deletions(-) diff --git a/src/components/List/tokens.ts b/src/components/List/tokens.ts index c753764bfa..4a9e7d7c14 100644 --- a/src/components/List/tokens.ts +++ b/src/components/List/tokens.ts @@ -5,7 +5,7 @@ const sizes = { twoLineVerticalPadding: 14, threeLineVerticalPadding: 12, leadingSpace: 16, - trailingSpace: 24, + trailingSpace: 16, } as const; const colors = { diff --git a/src/components/__tests__/__snapshots__/ListAccordion.test.tsx.snap b/src/components/__tests__/__snapshots__/ListAccordion.test.tsx.snap index 7aebd81411..1e1f5e740c 100644 --- a/src/components/__tests__/__snapshots__/ListAccordion.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/ListAccordion.test.tsx.snap @@ -47,7 +47,7 @@ exports[`renders expanded accordion 1`] = ` }, [ { - "paddingRight": 24, + "paddingRight": 16, }, { "paddingVertical": 16, @@ -191,7 +191,7 @@ exports[`renders expanded accordion 1`] = ` false, [ { - "paddingRight": 24, + "paddingRight": 16, }, { "paddingVertical": 16, @@ -312,7 +312,7 @@ exports[`renders list accordion with children 1`] = ` }, [ { - "paddingRight": 24, + "paddingRight": 16, }, { "paddingVertical": 16, @@ -518,7 +518,7 @@ exports[`renders list accordion with custom title and description styles 1`] = ` }, [ { - "paddingRight": 24, + "paddingRight": 16, }, { "paddingVertical": 14, @@ -714,7 +714,7 @@ exports[`renders list accordion with left items 1`] = ` }, [ { - "paddingRight": 24, + "paddingRight": 16, }, { "paddingVertical": 16, @@ -920,7 +920,7 @@ exports[`renders multiline list accordion 1`] = ` }, [ { - "paddingRight": 24, + "paddingRight": 16, }, { "paddingVertical": 14, diff --git a/src/components/__tests__/__snapshots__/ListItem.test.tsx.snap b/src/components/__tests__/__snapshots__/ListItem.test.tsx.snap index 583a559edb..68e85e678a 100644 --- a/src/components/__tests__/__snapshots__/ListItem.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/ListItem.test.tsx.snap @@ -36,7 +36,7 @@ exports[`renders list item with custom description 1`] = ` false, [ { - "paddingRight": 24, + "paddingRight": 16, }, { "paddingVertical": 14, @@ -419,7 +419,7 @@ exports[`renders list item with custom title and description styles 1`] = ` false, [ { - "paddingRight": 24, + "paddingRight": 16, }, { "paddingVertical": 14, @@ -567,7 +567,7 @@ exports[`renders list item with left and right items 1`] = ` false, [ { - "paddingRight": 24, + "paddingRight": 16, }, { "paddingVertical": 14, @@ -760,7 +760,7 @@ exports[`renders list item with left item 1`] = ` false, [ { - "paddingRight": 24, + "paddingRight": 16, }, { "paddingVertical": 16, @@ -918,7 +918,7 @@ exports[`renders list item with right item 1`] = ` false, [ { - "paddingRight": 24, + "paddingRight": 16, }, { "paddingVertical": 16, @@ -1032,7 +1032,7 @@ exports[`renders list item with title and description 1`] = ` false, [ { - "paddingRight": 24, + "paddingRight": 16, }, { "paddingVertical": 14, @@ -1176,7 +1176,7 @@ exports[`renders with a description with typeof number 1`] = ` false, [ { - "paddingRight": 24, + "paddingRight": 16, }, { "paddingVertical": 14, diff --git a/src/components/__tests__/__snapshots__/ListSection.test.tsx.snap b/src/components/__tests__/__snapshots__/ListSection.test.tsx.snap index 8bb42ff5d9..3f92bb4ee1 100644 --- a/src/components/__tests__/__snapshots__/ListSection.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/ListSection.test.tsx.snap @@ -488,7 +488,7 @@ exports[`renders list section with custom title style 1`] = ` false, [ { - "paddingRight": 24, + "paddingRight": 16, }, { "paddingVertical": 16, @@ -643,7 +643,7 @@ exports[`renders list section with custom title style 1`] = ` false, [ { - "paddingRight": 24, + "paddingRight": 16, }, { "paddingVertical": 16, @@ -1252,7 +1252,7 @@ exports[`renders list section with subheader 1`] = ` false, [ { - "paddingRight": 24, + "paddingRight": 16, }, { "paddingVertical": 16, @@ -1407,7 +1407,7 @@ exports[`renders list section with subheader 1`] = ` false, [ { - "paddingRight": 24, + "paddingRight": 16, }, { "paddingVertical": 16, @@ -1976,7 +1976,7 @@ exports[`renders list section without subheader 1`] = ` false, [ { - "paddingRight": 24, + "paddingRight": 16, }, { "paddingVertical": 16, @@ -2131,7 +2131,7 @@ exports[`renders list section without subheader 1`] = ` false, [ { - "paddingRight": 24, + "paddingRight": 16, }, { "paddingVertical": 16, From 923a1d9c63c56ebd8c76e1ff0045dee7618cec73 Mon Sep 17 00:00:00 2001 From: Adam Sajko Date: Fri, 28 Aug 2026 17:26:46 +0200 Subject: [PATCH 04/11] fix: use container heights in List and the expand token for the chevron --- src/components/List/ListAccordion.tsx | 27 +++++------- src/components/List/ListItem.tsx | 25 ++++------- src/components/List/tokens.ts | 7 +-- .../__tests__/ListAccordion.test.tsx | 33 +++++++++++++- src/components/__tests__/ListItem.test.tsx | 44 +++++++++++++++++-- .../__snapshots__/ListAccordion.test.tsx.snap | 34 +++++++++----- .../__snapshots__/ListItem.test.tsx.snap | 28 +++++++++--- .../__snapshots__/ListSection.test.tsx.snap | 24 +++++++--- 8 files changed, 157 insertions(+), 65 deletions(-) diff --git a/src/components/List/ListAccordion.tsx b/src/components/List/ListAccordion.tsx index dfacd258d1..c2bec3892c 100644 --- a/src/components/List/ListAccordion.tsx +++ b/src/components/List/ListAccordion.tsx @@ -212,16 +212,6 @@ const ListAccordion = ({ setIsDescriptionMultiline(nativeEvent.lines.length >= 2); }; - const getVerticalPaddingStyle = () => { - if (!description) { - return styles.containerOneLine; - } - - return isDescriptionMultiline - ? styles.containerThreeLine - : styles.containerTwoLine; - }; - const handlePressAction = (e: GestureResponderEvent) => { onPress?.(e); @@ -257,7 +247,11 @@ const ListAccordion = ({ style={{ backgroundColor: theme.colors[ListTokens.containerColor] }} > @@ -358,16 +352,15 @@ ListAccordion.displayName = 'List.Accordion'; const styles = StyleSheet.create({ container: { + paddingVertical: ListTokens.verticalPadding, paddingRight: ListTokens.trailingSpace, + justifyContent: 'center', }, containerOneLine: { - paddingVertical: ListTokens.oneLineVerticalPadding, + minHeight: ListTokens.oneLineContainerHeight, }, containerTwoLine: { - paddingVertical: ListTokens.twoLineVerticalPadding, - }, - containerThreeLine: { - paddingVertical: ListTokens.threeLineVerticalPadding, + minHeight: ListTokens.twoLineContainerHeight, }, row: { flexDirection: 'row', diff --git a/src/components/List/ListItem.tsx b/src/components/List/ListItem.tsx index 1be13576c8..6db14a4690 100644 --- a/src/components/List/ListItem.tsx +++ b/src/components/List/ListItem.tsx @@ -177,16 +177,6 @@ const ListItem = ({ setIsDescriptionMultiline(nativeEvent.lines.length >= 2); }; - const getVerticalPaddingStyle = () => { - if (!description) { - return styles.containerOneLine; - } - - return isDescriptionMultiline - ? styles.containerThreeLine - : styles.containerTwoLine; - }; - const renderDescription = ( descriptionColor: ColorValue, description?: Description | null @@ -245,7 +235,11 @@ const ListItem = ({ ; export const ListTokens = { ...sizes, ...colors }; diff --git a/src/components/__tests__/ListAccordion.test.tsx b/src/components/__tests__/ListAccordion.test.tsx index deb13e4e96..be2775c3ff 100644 --- a/src/components/__tests__/ListAccordion.test.tsx +++ b/src/components/__tests__/ListAccordion.test.tsx @@ -144,7 +144,20 @@ describe('ListAccordion', () => { }); }); - it('drops to 12dp padding once the description wraps to a third line', async () => { + it('hits the container heights without measuring the description', async () => { + await render( + + + + ); + + expect(screen.getByTestId('list-accordion')).toHaveStyle({ + minHeight: 56, + paddingVertical: 12, + }); + }); + + it('keeps the two line container height once a description is present', async () => { await render( { ); expect(screen.getByTestId('list-accordion')).toHaveStyle({ - paddingVertical: 14, + minHeight: 72, + paddingVertical: 12, }); await fireEvent( @@ -166,10 +180,25 @@ describe('ListAccordion', () => { ); expect(screen.getByTestId('list-accordion')).toHaveStyle({ + minHeight: 72, paddingVertical: 12, }); }); + it('uses the expand token for the chevron', async () => { + await render( + + + + ); + + expect( + screen.getByText('chevron-down', { includeHiddenElements: true }) + ).toHaveStyle({ + color: getTheme().colors.onSurface, + }); + }); + it('applies the theme override to title and description typography', async () => { await render( { expect(screen.getByTestId('list-item-content')).toHaveStyle(styles.content); }); -it('drops to 12dp padding once the description wraps to a third line', async () => { +it('hits the one line container height without measuring the description', async () => { + await render(); + + expect(screen.getByTestId(testID)).toHaveStyle({ + minHeight: 56, + paddingVertical: 12, + }); +}); + +it('hits the two and three line container heights without measuring', async () => { await render( ); - expect(screen.getByTestId(testID)).toHaveStyle({ paddingVertical: 14 }); + expect(screen.getByTestId(testID)).toHaveStyle({ + minHeight: 72, + paddingVertical: 12, + }); await fireEvent(screen.getByText('Item description'), 'textLayout', { nativeEvent: { lines: [{}, {}] }, }); - expect(screen.getByTestId(testID)).toHaveStyle({ paddingVertical: 12 }); + expect(screen.getByTestId(testID)).toHaveStyle({ + minHeight: 72, + paddingVertical: 12, + }); +}); + +it('top aligns the accessories once the description wraps', async () => { + await render( + } + testID={testID} + /> + ); + + expect(screen.getByTestId('left-accessory')).toHaveStyle({ + alignSelf: 'center', + }); + + await fireEvent(screen.getByText('Item description'), 'textLayout', { + nativeEvent: { lines: [{}, {}] }, + }); + + expect(screen.getByTestId('left-accessory')).toHaveStyle({ + alignSelf: 'flex-start', + }); }); it('applies the theme override to title and description typography', async () => { diff --git a/src/components/__tests__/__snapshots__/ListAccordion.test.tsx.snap b/src/components/__tests__/__snapshots__/ListAccordion.test.tsx.snap index 1e1f5e740c..493caab8f8 100644 --- a/src/components/__tests__/__snapshots__/ListAccordion.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/ListAccordion.test.tsx.snap @@ -47,10 +47,12 @@ exports[`renders expanded accordion 1`] = ` }, [ { + "justifyContent": "center", "paddingRight": 16, + "paddingVertical": 12, }, { - "paddingVertical": 16, + "minHeight": 56, }, undefined, ], @@ -131,7 +133,7 @@ exports[`renders expanded accordion 1`] = ` style={ [ { - "color": "rgba(73, 69, 79, 1)", + "color": "rgba(29, 27, 32, 1)", "fontSize": 24, }, [ @@ -191,10 +193,12 @@ exports[`renders expanded accordion 1`] = ` false, [ { + "justifyContent": "center", "paddingRight": 16, + "paddingVertical": 12, }, { - "paddingVertical": 16, + "minHeight": 56, }, undefined, ], @@ -312,10 +316,12 @@ exports[`renders list accordion with children 1`] = ` }, [ { + "justifyContent": "center", "paddingRight": 16, + "paddingVertical": 12, }, { - "paddingVertical": 16, + "minHeight": 56, }, undefined, ], @@ -443,7 +449,7 @@ exports[`renders list accordion with children 1`] = ` style={ [ { - "color": "rgba(73, 69, 79, 1)", + "color": "rgba(29, 27, 32, 1)", "fontSize": 24, }, [ @@ -518,10 +524,12 @@ exports[`renders list accordion with custom title and description styles 1`] = ` }, [ { + "justifyContent": "center", "paddingRight": 16, + "paddingVertical": 12, }, { - "paddingVertical": 14, + "minHeight": 72, }, undefined, ], @@ -639,7 +647,7 @@ exports[`renders list accordion with custom title and description styles 1`] = ` style={ [ { - "color": "rgba(73, 69, 79, 1)", + "color": "rgba(29, 27, 32, 1)", "fontSize": 24, }, [ @@ -714,10 +722,12 @@ exports[`renders list accordion with left items 1`] = ` }, [ { + "justifyContent": "center", "paddingRight": 16, + "paddingVertical": 12, }, { - "paddingVertical": 16, + "minHeight": 56, }, undefined, ], @@ -845,7 +855,7 @@ exports[`renders list accordion with left items 1`] = ` style={ [ { - "color": "rgba(73, 69, 79, 1)", + "color": "rgba(29, 27, 32, 1)", "fontSize": 24, }, [ @@ -920,10 +930,12 @@ exports[`renders multiline list accordion 1`] = ` }, [ { + "justifyContent": "center", "paddingRight": 16, + "paddingVertical": 12, }, { - "paddingVertical": 14, + "minHeight": 72, }, undefined, ], @@ -1037,7 +1049,7 @@ exports[`renders multiline list accordion 1`] = ` style={ [ { - "color": "rgba(73, 69, 79, 1)", + "color": "rgba(29, 27, 32, 1)", "fontSize": 24, }, [ diff --git a/src/components/__tests__/__snapshots__/ListItem.test.tsx.snap b/src/components/__tests__/__snapshots__/ListItem.test.tsx.snap index 68e85e678a..a95aa6271e 100644 --- a/src/components/__tests__/__snapshots__/ListItem.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/ListItem.test.tsx.snap @@ -36,10 +36,12 @@ exports[`renders list item with custom description 1`] = ` false, [ { + "justifyContent": "center", "paddingRight": 16, + "paddingVertical": 12, }, { - "paddingVertical": 14, + "minHeight": 72, }, undefined, ], @@ -419,10 +421,12 @@ exports[`renders list item with custom title and description styles 1`] = ` false, [ { + "justifyContent": "center", "paddingRight": 16, + "paddingVertical": 12, }, { - "paddingVertical": 14, + "minHeight": 72, }, undefined, ], @@ -567,10 +571,12 @@ exports[`renders list item with left and right items 1`] = ` false, [ { + "justifyContent": "center", "paddingRight": 16, + "paddingVertical": 12, }, { - "paddingVertical": 14, + "minHeight": 72, }, undefined, ], @@ -760,10 +766,12 @@ exports[`renders list item with left item 1`] = ` false, [ { + "justifyContent": "center", "paddingRight": 16, + "paddingVertical": 12, }, { - "paddingVertical": 16, + "minHeight": 56, }, undefined, ], @@ -918,10 +926,12 @@ exports[`renders list item with right item 1`] = ` false, [ { + "justifyContent": "center", "paddingRight": 16, + "paddingVertical": 12, }, { - "paddingVertical": 16, + "minHeight": 56, }, undefined, ], @@ -1032,10 +1042,12 @@ exports[`renders list item with title and description 1`] = ` false, [ { + "justifyContent": "center", "paddingRight": 16, + "paddingVertical": 12, }, { - "paddingVertical": 14, + "minHeight": 72, }, undefined, ], @@ -1176,10 +1188,12 @@ exports[`renders with a description with typeof number 1`] = ` false, [ { + "justifyContent": "center", "paddingRight": 16, + "paddingVertical": 12, }, { - "paddingVertical": 14, + "minHeight": 72, }, undefined, ], diff --git a/src/components/__tests__/__snapshots__/ListSection.test.tsx.snap b/src/components/__tests__/__snapshots__/ListSection.test.tsx.snap index 3f92bb4ee1..264e3e7f99 100644 --- a/src/components/__tests__/__snapshots__/ListSection.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/ListSection.test.tsx.snap @@ -488,10 +488,12 @@ exports[`renders list section with custom title style 1`] = ` false, [ { + "justifyContent": "center", "paddingRight": 16, + "paddingVertical": 12, }, { - "paddingVertical": 16, + "minHeight": 56, }, undefined, ], @@ -643,10 +645,12 @@ exports[`renders list section with custom title style 1`] = ` false, [ { + "justifyContent": "center", "paddingRight": 16, + "paddingVertical": 12, }, { - "paddingVertical": 16, + "minHeight": 56, }, undefined, ], @@ -1252,10 +1256,12 @@ exports[`renders list section with subheader 1`] = ` false, [ { + "justifyContent": "center", "paddingRight": 16, + "paddingVertical": 12, }, { - "paddingVertical": 16, + "minHeight": 56, }, undefined, ], @@ -1407,10 +1413,12 @@ exports[`renders list section with subheader 1`] = ` false, [ { + "justifyContent": "center", "paddingRight": 16, + "paddingVertical": 12, }, { - "paddingVertical": 16, + "minHeight": 56, }, undefined, ], @@ -1976,10 +1984,12 @@ exports[`renders list section without subheader 1`] = ` false, [ { + "justifyContent": "center", "paddingRight": 16, + "paddingVertical": 12, }, { - "paddingVertical": 16, + "minHeight": 56, }, undefined, ], @@ -2131,10 +2141,12 @@ exports[`renders list section without subheader 1`] = ` false, [ { + "justifyContent": "center", "paddingRight": 16, + "paddingVertical": 12, }, { - "paddingVertical": 16, + "minHeight": 56, }, undefined, ], From 256462716439ed23232d93e38656c0f343e31141 Mon Sep 17 00:00:00 2001 From: Adam Sajko Date: Tue, 25 Aug 2026 14:02:40 +0200 Subject: [PATCH 05/11] test: stabilise the Tooltip pressOut timing --- src/components/__tests__/Tooltip.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/__tests__/Tooltip.test.tsx b/src/components/__tests__/Tooltip.test.tsx index 75b4a18cf7..43904e5bd5 100644 --- a/src/components/__tests__/Tooltip.test.tsx +++ b/src/components/__tests__/Tooltip.test.tsx @@ -163,7 +163,7 @@ describe('Tooltip', () => { it('hides the tooltip when the user stop pressing the component', async () => { const { wrapper: { queryByText, getByText, findByText }, - } = await setup({ enterTouchDelay: 50, leaveTouchDelay: 0 }); + } = await setup({ enterTouchDelay: 50, leaveTouchDelay: 100 }); await userEvent.longPress(getTrigger(getByText)); From 3fc99ea8bc5244b7b65f4b990a05945f1c00b20d Mon Sep 17 00:00:00 2001 From: Adam Sajko Date: Fri, 28 Aug 2026 18:52:28 +0200 Subject: [PATCH 06/11] refactor: restore the List.Accordion colors helper --- src/components/List/ListAccordion.tsx | 5 ++--- src/components/List/utils.ts | 7 ++++++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/components/List/ListAccordion.tsx b/src/components/List/ListAccordion.tsx index c2bec3892c..ec294bfd2c 100644 --- a/src/components/List/ListAccordion.tsx +++ b/src/components/List/ListAccordion.tsx @@ -15,7 +15,7 @@ import type { import { ListAccordionGroupContext } from './ListAccordionGroup'; import { ListTokens } from './tokens'; import type { ListChildProps, Style } from './utils'; -import { getLeftStyles } from './utils'; +import { getAccordionColors, getLeftStyles } from './utils'; import { useLocale } from '../../core/locale'; import { useInternalTheme } from '../../core/theming'; import type { ThemeProp } from '../../theme/types'; @@ -234,8 +234,7 @@ const ListAccordion = ({ ? groupContext.expandedId === id : expandedInternal; - const titleTextColor = theme.colors[ListTokens.headlineColor]; - const descriptionColor = theme.colors[ListTokens.supportingTextColor]; + const { descriptionColor, titleTextColor } = getAccordionColors({ theme }); const handlePress = groupContext && id !== undefined diff --git a/src/components/List/utils.ts b/src/components/List/utils.ts index 296acc4e4e..d9fff8fef8 100644 --- a/src/components/List/utils.ts +++ b/src/components/List/utils.ts @@ -1,7 +1,7 @@ import type { StyleProp, TextProps, ViewStyle } from 'react-native'; import { ListTokens } from './tokens'; -import type { ThemeProp } from '../../theme/types'; +import type { InternalTheme, ThemeProp } from '../../theme/types'; type Description = | React.ReactNode @@ -44,3 +44,8 @@ export const getLeftStyles = (alignToTop: boolean, description: Description) => export const getRightStyles = (alignToTop: boolean, description: Description) => getAccessoryStyles(alignToTop, description); + +export const getAccordionColors = ({ theme }: { theme: InternalTheme }) => ({ + titleTextColor: theme.colors[ListTokens.headlineColor], + descriptionColor: theme.colors[ListTokens.supportingTextColor], +}); From d4135811f0ee5d9b3d93e92975d91a5be8d7eb8a Mon Sep 17 00:00:00 2001 From: Adam Sajko Date: Tue, 1 Sep 2026 15:40:30 +0200 Subject: [PATCH 07/11] fix: align List spacing and container heights with MD3 --- src/components/List/ListAccordion.tsx | 156 ++++++++++-------- src/components/List/ListImage.tsx | 13 +- src/components/List/ListItem.tsx | 85 ++++++---- src/components/List/tokens.ts | 3 +- src/components/List/utils.ts | 10 ++ .../__tests__/ListAccordion.test.tsx | 4 +- src/components/__tests__/ListImage.test.tsx | 1 + src/components/__tests__/ListItem.test.tsx | 102 +++++++++++- .../__snapshots__/ListAccordion.test.tsx.snap | 28 ++-- .../__snapshots__/ListItem.test.tsx.snap | 21 ++- .../__snapshots__/ListSection.test.tsx.snap | 18 +- 11 files changed, 306 insertions(+), 135 deletions(-) diff --git a/src/components/List/ListAccordion.tsx b/src/components/List/ListAccordion.tsx index ec294bfd2c..48bdcb3bb1 100644 --- a/src/components/List/ListAccordion.tsx +++ b/src/components/List/ListAccordion.tsx @@ -15,7 +15,7 @@ import type { import { ListAccordionGroupContext } from './ListAccordionGroup'; import { ListTokens } from './tokens'; import type { ListChildProps, Style } from './utils'; -import { getAccordionColors, getLeftStyles } from './utils'; +import { ListRowContext, getAccordionColors, getLeftStyles } from './utils'; import { useLocale } from '../../core/locale'; import { useInternalTheme } from '../../core/theming'; import type { ThemeProp } from '../../theme/types'; @@ -240,90 +240,103 @@ const ListAccordion = ({ groupContext && id !== undefined ? () => groupContext.onAccordionPress(id) : handlePressAction; + + const rowContext = React.useMemo( + () => ({ + verticalPadding: isDescriptionMultiline + ? ListTokens.threeLineVerticalPadding + : ListTokens.verticalPadding, + }), + [isDescriptionMultiline] + ); + return ( - - + - {left - ? left({ - color: theme.colors[ListTokens.leadingIconColor], - style: getLeftStyles(isDescriptionMultiline, description), - }) - : null} - - - {title} - - {description ? ( + + {left + ? left({ + color: theme.colors[ListTokens.leadingIconColor], + style: getLeftStyles(isDescriptionMultiline, description), + }) + : null} + - {description} + {title} - ) : null} + {description ? ( + + {description} + + ) : null} + + + {right ? ( + right({ + isExpanded: isExpanded, + }) + ) : ( + + )} + - - {right ? ( - right({ - isExpanded: isExpanded, - }) - ) : ( - - )} - - - + + {isExpanded @@ -361,6 +374,9 @@ const styles = StyleSheet.create({ containerTwoLine: { minHeight: ListTokens.twoLineContainerHeight, }, + containerThreeLine: { + paddingVertical: ListTokens.threeLineVerticalPadding, + }, row: { flexDirection: 'row', }, @@ -369,7 +385,7 @@ const styles = StyleSheet.create({ }, trailingItem: { alignSelf: 'center', - paddingLeft: 8, + paddingLeft: ListTokens.leadingSpace, }, child: { paddingLeft: 40, diff --git a/src/components/List/ListImage.tsx b/src/components/List/ListImage.tsx index 55e2a527db..4b9736b4da 100644 --- a/src/components/List/ListImage.tsx +++ b/src/components/List/ListImage.tsx @@ -1,6 +1,9 @@ +import * as React from 'react'; import { StyleSheet, Image } from 'react-native'; import type { StyleProp, ImageSourcePropType, ImageStyle } from 'react-native'; +import { ListTokens } from './tokens'; +import { ListRowContext } from './utils'; import type { ThemeProp } from '../../theme/types'; export type Props = { @@ -37,9 +40,17 @@ const ListImage = ({ variant = 'image', theme: _theme, }: Props) => { + const { verticalPadding } = React.useContext(ListRowContext); + const getStyles = () => { if (variant === 'video') { - return [style, styles.video]; + return [ + style, + styles.video, + { + marginVertical: ListTokens.threeLineVerticalPadding - verticalPadding, + }, + ]; } return [style, styles.image]; diff --git a/src/components/List/ListItem.tsx b/src/components/List/ListItem.tsx index 6db14a4690..a477177a25 100644 --- a/src/components/List/ListItem.tsx +++ b/src/components/List/ListItem.tsx @@ -12,7 +12,7 @@ import type { } from 'react-native'; import { ListTokens } from './tokens'; -import { getLeftStyles, getRightStyles } from './utils'; +import { ListRowContext, getLeftStyles, getRightStyles } from './utils'; import type { Style } from './utils'; import { useInternalTheme } from '../../core/theming'; import type { ThemeProp } from '../../theme/types'; @@ -231,44 +231,56 @@ const ListItem = ({ const descriptionColor = theme.colors[ListTokens.supportingTextColor]; + const rowContext = React.useMemo( + () => ({ + verticalPadding: isDescriptionMultiline + ? ListTokens.threeLineVerticalPadding + : ListTokens.verticalPadding, + }), + [isDescriptionMultiline] + ); + return ( - - - {left - ? left({ - color: theme.colors[ListTokens.leadingIconColor], - style: getLeftStyles(isDescriptionMultiline, description), - }) - : null} - - {renderTitle()} + + + + {left + ? left({ + color: theme.colors[ListTokens.leadingIconColor], + style: getLeftStyles(isDescriptionMultiline, description), + }) + : null} + + {renderTitle()} - {description - ? renderDescription(descriptionColor, description) + {description + ? renderDescription(descriptionColor, description) + : null} + + {right + ? right({ + color: theme.colors[ListTokens.trailingIconColor], + style: getRightStyles(isDescriptionMultiline, description), + }) : null} - {right - ? right({ - color: theme.colors[ListTokens.trailingIconColor], - style: getRightStyles(isDescriptionMultiline, description), - }) - : null} - - + + ); }; @@ -286,6 +298,9 @@ const styles = StyleSheet.create({ containerTwoLine: { minHeight: ListTokens.twoLineContainerHeight, }, + containerThreeLine: { + paddingVertical: ListTokens.threeLineVerticalPadding, + }, row: { width: '100%', flexDirection: 'row', diff --git a/src/components/List/tokens.ts b/src/components/List/tokens.ts index e67a604812..5df93882ed 100644 --- a/src/components/List/tokens.ts +++ b/src/components/List/tokens.ts @@ -1,7 +1,8 @@ import type { ColorRole } from '../../theme/types'; const sizes = { - verticalPadding: 12, + verticalPadding: 8, + threeLineVerticalPadding: 12, oneLineContainerHeight: 56, twoLineContainerHeight: 72, leadingSpace: 16, diff --git a/src/components/List/utils.ts b/src/components/List/utils.ts index d9fff8fef8..a0c5f1ef00 100644 --- a/src/components/List/utils.ts +++ b/src/components/List/utils.ts @@ -1,3 +1,4 @@ +import * as React from 'react'; import type { StyleProp, TextProps, ViewStyle } from 'react-native'; import { ListTokens } from './tokens'; @@ -45,6 +46,15 @@ export const getLeftStyles = (alignToTop: boolean, description: Description) => export const getRightStyles = (alignToTop: boolean, description: Description) => getAccessoryStyles(alignToTop, description); +/** + * Rows change their vertical padding with the number of lines, so a leading + * element taller than the default slot needs to know how much of the MD3 + * height the row already covers. + */ +export const ListRowContext = React.createContext<{ verticalPadding: number }>({ + verticalPadding: ListTokens.verticalPadding, +}); + export const getAccordionColors = ({ theme }: { theme: InternalTheme }) => ({ titleTextColor: theme.colors[ListTokens.headlineColor], descriptionColor: theme.colors[ListTokens.supportingTextColor], diff --git a/src/components/__tests__/ListAccordion.test.tsx b/src/components/__tests__/ListAccordion.test.tsx index be2775c3ff..db0cdd74f2 100644 --- a/src/components/__tests__/ListAccordion.test.tsx +++ b/src/components/__tests__/ListAccordion.test.tsx @@ -153,7 +153,7 @@ describe('ListAccordion', () => { expect(screen.getByTestId('list-accordion')).toHaveStyle({ minHeight: 56, - paddingVertical: 12, + paddingVertical: 8, }); }); @@ -170,7 +170,7 @@ describe('ListAccordion', () => { expect(screen.getByTestId('list-accordion')).toHaveStyle({ minHeight: 72, - paddingVertical: 12, + paddingVertical: 8, }); await fireEvent( diff --git a/src/components/__tests__/ListImage.test.tsx b/src/components/__tests__/ListImage.test.tsx index 8aa7e9b54d..3001324a99 100644 --- a/src/components/__tests__/ListImage.test.tsx +++ b/src/components/__tests__/ListImage.test.tsx @@ -14,6 +14,7 @@ const styles = StyleSheet.create({ width: 114, height: 64, marginLeft: 0, + marginVertical: 4, }, container: { width: 30, diff --git a/src/components/__tests__/ListItem.test.tsx b/src/components/__tests__/ListItem.test.tsx index 3bcbba13c8..985976f03a 100644 --- a/src/components/__tests__/ListItem.test.tsx +++ b/src/components/__tests__/ListItem.test.tsx @@ -10,6 +10,7 @@ import { red500 } from '../../theme/colors'; import Chip from '../Chip/Chip'; import IconButton from '../IconButton/IconButton'; import ListIcon from '../List/ListIcon'; +import ListImage from '../List/ListImage'; import ListItem from '../List/ListItem'; const styles = StyleSheet.create({ @@ -22,6 +23,14 @@ const styles = StyleSheet.create({ content: { paddingLeft: 0, }, + avatar: { + width: 40, + height: 40, + }, + image: { + width: 56, + height: 56, + }, }); const testID = 'list-item'; @@ -183,7 +192,7 @@ it('hits the one line container height without measuring the description', async expect(screen.getByTestId(testID)).toHaveStyle({ minHeight: 56, - paddingVertical: 12, + paddingVertical: 8, }); }); @@ -198,7 +207,7 @@ it('hits the two and three line container heights without measuring', async () = expect(screen.getByTestId(testID)).toHaveStyle({ minHeight: 72, - paddingVertical: 12, + paddingVertical: 8, }); await fireEvent(screen.getByText('Item description'), 'textLayout', { @@ -211,6 +220,95 @@ it('hits the two and three line container heights without measuring', async () = }); }); +it('leaves a 40dp leading element on the one line container height', async () => { + await render( + ( + + )} + testID={testID} + /> + ); + + expect(screen.getByTestId(testID)).toHaveStyle({ + minHeight: 56, + paddingVertical: 8, + }); + expect(screen.getByTestId('left-accessory')).toHaveStyle({ height: 40 }); +}); + +it('leaves a 56dp leading image on the two line container height', async () => { + await render( + ( + + )} + testID={testID} + /> + ); + + expect(screen.getByTestId(testID)).toHaveStyle({ + minHeight: 72, + paddingVertical: 8, + }); + expect(screen.getByTestId('left-accessory')).toHaveStyle({ height: 56 }); +}); + +it('pads a 64dp leading video to the three line container height', async () => { + await render( + ( + + )} + testID={testID} + /> + ); + + expect(screen.getByTestId(testID)).toHaveStyle({ + minHeight: 56, + paddingVertical: 8, + }); + expect(screen.getByTestId('list-image')).toHaveStyle({ + height: 64, + marginVertical: 4, + }); +}); + +it('keeps a 64dp leading video on the same height once the description wraps', async () => { + await render( + ( + + )} + testID={testID} + /> + ); + + await fireEvent(screen.getByText('Item description'), 'textLayout', { + nativeEvent: { lines: [{}, {}] }, + }); + + expect(screen.getByTestId(testID)).toHaveStyle({ paddingVertical: 12 }); + expect(screen.getByTestId('list-image')).toHaveStyle({ + height: 64, + marginVertical: 0, + }); +}); + it('top aligns the accessories once the description wraps', async () => { await render( @@ -195,11 +196,12 @@ exports[`renders expanded accordion 1`] = ` { "justifyContent": "center", "paddingRight": 16, - "paddingVertical": 12, + "paddingVertical": 8, }, { "minHeight": 56, }, + false, undefined, ], ] @@ -318,11 +320,12 @@ exports[`renders list accordion with children 1`] = ` { "justifyContent": "center", "paddingRight": 16, - "paddingVertical": 12, + "paddingVertical": 8, }, { "minHeight": 56, }, + false, undefined, ], ] @@ -437,7 +440,7 @@ exports[`renders list accordion with children 1`] = ` style={ { "alignSelf": "center", - "paddingLeft": 8, + "paddingLeft": 16, } } > @@ -526,11 +529,12 @@ exports[`renders list accordion with custom title and description styles 1`] = ` { "justifyContent": "center", "paddingRight": 16, - "paddingVertical": 12, + "paddingVertical": 8, }, { "minHeight": 72, }, + false, undefined, ], ] @@ -635,7 +639,7 @@ exports[`renders list accordion with custom title and description styles 1`] = ` style={ { "alignSelf": "center", - "paddingLeft": 8, + "paddingLeft": 16, } } > @@ -724,11 +728,12 @@ exports[`renders list accordion with left items 1`] = ` { "justifyContent": "center", "paddingRight": 16, - "paddingVertical": 12, + "paddingVertical": 8, }, { "minHeight": 56, }, + false, undefined, ], ] @@ -843,7 +848,7 @@ exports[`renders list accordion with left items 1`] = ` style={ { "alignSelf": "center", - "paddingLeft": 8, + "paddingLeft": 16, } } > @@ -932,11 +937,12 @@ exports[`renders multiline list accordion 1`] = ` { "justifyContent": "center", "paddingRight": 16, - "paddingVertical": 12, + "paddingVertical": 8, }, { "minHeight": 72, }, + false, undefined, ], ] @@ -1037,7 +1043,7 @@ exports[`renders multiline list accordion 1`] = ` style={ { "alignSelf": "center", - "paddingLeft": 8, + "paddingLeft": 16, } } > diff --git a/src/components/__tests__/__snapshots__/ListItem.test.tsx.snap b/src/components/__tests__/__snapshots__/ListItem.test.tsx.snap index a95aa6271e..4aa0355fcd 100644 --- a/src/components/__tests__/__snapshots__/ListItem.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/ListItem.test.tsx.snap @@ -38,11 +38,12 @@ exports[`renders list item with custom description 1`] = ` { "justifyContent": "center", "paddingRight": 16, - "paddingVertical": 12, + "paddingVertical": 8, }, { "minHeight": 72, }, + false, undefined, ], ] @@ -423,11 +424,12 @@ exports[`renders list item with custom title and description styles 1`] = ` { "justifyContent": "center", "paddingRight": 16, - "paddingVertical": 12, + "paddingVertical": 8, }, { "minHeight": 72, }, + false, undefined, ], ] @@ -573,11 +575,12 @@ exports[`renders list item with left and right items 1`] = ` { "justifyContent": "center", "paddingRight": 16, - "paddingVertical": 12, + "paddingVertical": 8, }, { "minHeight": 72, }, + false, undefined, ], ] @@ -768,11 +771,12 @@ exports[`renders list item with left item 1`] = ` { "justifyContent": "center", "paddingRight": 16, - "paddingVertical": 12, + "paddingVertical": 8, }, { "minHeight": 56, }, + false, undefined, ], ] @@ -928,11 +932,12 @@ exports[`renders list item with right item 1`] = ` { "justifyContent": "center", "paddingRight": 16, - "paddingVertical": 12, + "paddingVertical": 8, }, { "minHeight": 56, }, + false, undefined, ], ] @@ -1044,11 +1049,12 @@ exports[`renders list item with title and description 1`] = ` { "justifyContent": "center", "paddingRight": 16, - "paddingVertical": 12, + "paddingVertical": 8, }, { "minHeight": 72, }, + false, undefined, ], ] @@ -1190,11 +1196,12 @@ exports[`renders with a description with typeof number 1`] = ` { "justifyContent": "center", "paddingRight": 16, - "paddingVertical": 12, + "paddingVertical": 8, }, { "minHeight": 72, }, + false, undefined, ], ] diff --git a/src/components/__tests__/__snapshots__/ListSection.test.tsx.snap b/src/components/__tests__/__snapshots__/ListSection.test.tsx.snap index 264e3e7f99..406c975348 100644 --- a/src/components/__tests__/__snapshots__/ListSection.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/ListSection.test.tsx.snap @@ -490,11 +490,12 @@ exports[`renders list section with custom title style 1`] = ` { "justifyContent": "center", "paddingRight": 16, - "paddingVertical": 12, + "paddingVertical": 8, }, { "minHeight": 56, }, + false, undefined, ], ] @@ -647,11 +648,12 @@ exports[`renders list section with custom title style 1`] = ` { "justifyContent": "center", "paddingRight": 16, - "paddingVertical": 12, + "paddingVertical": 8, }, { "minHeight": 56, }, + false, undefined, ], ] @@ -1258,11 +1260,12 @@ exports[`renders list section with subheader 1`] = ` { "justifyContent": "center", "paddingRight": 16, - "paddingVertical": 12, + "paddingVertical": 8, }, { "minHeight": 56, }, + false, undefined, ], ] @@ -1415,11 +1418,12 @@ exports[`renders list section with subheader 1`] = ` { "justifyContent": "center", "paddingRight": 16, - "paddingVertical": 12, + "paddingVertical": 8, }, { "minHeight": 56, }, + false, undefined, ], ] @@ -1986,11 +1990,12 @@ exports[`renders list section without subheader 1`] = ` { "justifyContent": "center", "paddingRight": 16, - "paddingVertical": 12, + "paddingVertical": 8, }, { "minHeight": 56, }, + false, undefined, ], ] @@ -2143,11 +2148,12 @@ exports[`renders list section without subheader 1`] = ` { "justifyContent": "center", "paddingRight": 16, - "paddingVertical": 12, + "paddingVertical": 8, }, { "minHeight": 56, }, + false, undefined, ], ] From bd239515e252d955563608f2c4416afa346295b6 Mon Sep 17 00:00:00 2001 From: Adam Sajko Date: Tue, 1 Sep 2026 15:40:43 +0200 Subject: [PATCH 08/11] chore: forward accessory styles in the List.Item example --- example/src/Examples/ListItemExample.tsx | 59 ++++++++++-------------- 1 file changed, 24 insertions(+), 35 deletions(-) diff --git a/example/src/Examples/ListItemExample.tsx b/example/src/Examples/ListItemExample.tsx index 4d5a69b400..1cf1252071 100644 --- a/example/src/Examples/ListItemExample.tsx +++ b/example/src/Examples/ListItemExample.tsx @@ -1,15 +1,7 @@ -import { View, StyleSheet } from 'react-native'; - import { List, Divider, Checkbox, Avatar, Switch } from 'react-native-paper'; import ScreenWrapper from '../ScreenWrapper'; -const CenteredCheckbox = () => ( - - - -); - const ListItemExample = () => { return ( @@ -21,16 +13,19 @@ const ListItemExample = () => { description="Supporting text that is long enough to fill up multiple lines in the item" /> - } /> + } + /> } + right={(props) => } /> } + right={(props) => } /> @@ -54,19 +49,19 @@ const ListItemExample = () => { } - right={() => } + right={(props) => } /> } - right={() => } + right={(props) => } /> } - right={() => } + right={(props) => } /> @@ -98,7 +93,7 @@ const ListItemExample = () => { left={(props) => ( )} - right={() => } + right={(props) => } /> { left={(props) => ( )} - right={() => } + right={(props) => } /> { left={(props) => ( )} - right={() => } + right={(props) => } /> @@ -158,7 +153,7 @@ const ListItemExample = () => { source={require('../../../example/assets/images/strawberries.jpg')} /> )} - right={() => } + right={(props) => } /> { source={require('../../../example/assets/images/strawberries.jpg')} /> )} - right={() => } + right={(props) => } /> { source={require('../../../example/assets/images/strawberries.jpg')} /> )} - right={() => } + right={(props) => } /> @@ -228,7 +223,7 @@ const ListItemExample = () => { source={require('../../../example/assets/images/strawberries.jpg')} /> )} - right={() => } + right={(props) => } /> { source={require('../../../example/assets/images/strawberries.jpg')} /> )} - right={() => } + right={(props) => } /> { source={require('../../../example/assets/images/strawberries.jpg')} /> )} - right={() => } + right={(props) => } /> @@ -260,35 +255,35 @@ const ListItemExample = () => { } + right={(props) => } /> } + right={(props) => } /> } + right={(props) => } /> } - right={() => } + right={(props) => } /> } - right={() => } + right={(props) => } /> } - right={() => } + right={(props) => } /> @@ -296,12 +291,6 @@ const ListItemExample = () => { ); }; -const styles = StyleSheet.create({ - centered: { - alignSelf: 'center', - }, -}); - ListItemExample.title = 'List.Item'; export default ListItemExample; From 85e2a1e551e57f5248b19958d5658a383c78b97a Mon Sep 17 00:00:00 2001 From: Adam Sajko Date: Fri, 7 Aug 2026 12:29:52 +0200 Subject: [PATCH 09/11] feat: add selected state to List.Item and List.Accordion --- example/src/Examples/ListItemExample.tsx | 24 ++++++++++++ src/components/List/ListAccordion.tsx | 23 +++++++++-- src/components/List/ListItem.tsx | 28 ++++++++++--- src/components/List/tokens.ts | 2 + src/components/List/utils.ts | 28 +++++++++++-- .../__tests__/ListAccordion.test.tsx | 15 +++++++ src/components/__tests__/ListItem.test.tsx | 39 +++++++++++++++++++ .../__snapshots__/ListAccordion.test.tsx.snap | 3 ++ .../__snapshots__/ListItem.test.tsx.snap | 21 ++++++++++ .../__snapshots__/ListSection.test.tsx.snap | 18 +++++++++ 10 files changed, 188 insertions(+), 13 deletions(-) diff --git a/example/src/Examples/ListItemExample.tsx b/example/src/Examples/ListItemExample.tsx index 1cf1252071..923c3f7c53 100644 --- a/example/src/Examples/ListItemExample.tsx +++ b/example/src/Examples/ListItemExample.tsx @@ -1,10 +1,34 @@ +import { useState } from 'react'; + import { List, Divider, Checkbox, Avatar, Switch } from 'react-native-paper'; import ScreenWrapper from '../ScreenWrapper'; +const SelectableSection = () => { + const [selected, setSelected] = useState(0); + + return ( + + {[0, 1, 2].map((index) => ( + setSelected(index)} + left={(props) => } + /> + ))} + + + ); +}; + const ListItemExample = () => { return ( + + diff --git a/src/components/List/ListAccordion.tsx b/src/components/List/ListAccordion.tsx index 48bdcb3bb1..d8e39e2eda 100644 --- a/src/components/List/ListAccordion.tsx +++ b/src/components/List/ListAccordion.tsx @@ -47,6 +47,10 @@ export type Props = { * You'll need to update this prop when you want to toggle the component or on `onPress`. */ expanded?: boolean; + /** + * Whether to highlight the accordion as selected. + */ + selected?: boolean; /** * Function to execute on press. */ @@ -191,6 +195,7 @@ const ListAccordion = ({ onLongPress, delayLongPress, expanded: expandedProp, + selected, 'aria-label': ariaLabel, pointerEvents = 'none', titleMaxFontSizeMultiplier, @@ -234,7 +239,12 @@ const ListAccordion = ({ ? groupContext.expandedId === id : expandedInternal; - const { descriptionColor, titleTextColor } = getAccordionColors({ theme }); + const { + descriptionColor, + titleTextColor, + leadingIconColor, + trailingIconColor, + } = getAccordionColors({ theme, selected }); const handlePress = groupContext && id !== undefined @@ -253,7 +263,11 @@ const ListAccordion = ({ return ( {left ? left({ - color: theme.colors[ListTokens.leadingIconColor], + color: leadingIconColor, style: getLeftStyles(isDescriptionMultiline, description), }) : null} @@ -328,7 +343,7 @@ const ListAccordion = ({ ) : ( diff --git a/src/components/List/ListItem.tsx b/src/components/List/ListItem.tsx index a477177a25..52b7ae3924 100644 --- a/src/components/List/ListItem.tsx +++ b/src/components/List/ListItem.tsx @@ -58,6 +58,10 @@ export type Props = Omit< * Callback which returns a React element to display on the right side. */ right?: (props: { color: ColorValue; style?: Style }) => React.ReactNode; + /** + * Whether to highlight the list item as selected. + */ + selected?: boolean; /** * Function to execute on press. */ @@ -149,6 +153,7 @@ const ListItem = ({ right, title, description, + selected, onPress, theme: themeOverrides, style, @@ -177,6 +182,13 @@ const ListItem = ({ setIsDescriptionMultiline(nativeEvent.lines.length >= 2); }; + const backgroundColor = selected + ? theme.colors[ListTokens.selectedContainerColor] + : undefined; + const titleColor = selected + ? theme.colors[ListTokens.selectedContentColor] + : theme.colors[ListTokens.headlineColor]; + const renderDescription = ( descriptionColor: ColorValue, description?: Description | null @@ -205,8 +217,6 @@ const ListItem = ({ }; const renderTitle = () => { - const titleColor = theme.colors[ListTokens.headlineColor]; - return typeof title === 'function' ? ( title({ selectable: false, @@ -229,7 +239,9 @@ const ListItem = ({ ); }; - const descriptionColor = theme.colors[ListTokens.supportingTextColor]; + const descriptionColor = selected + ? theme.colors[ListTokens.selectedContentColor] + : theme.colors[ListTokens.supportingTextColor]; const rowContext = React.useMemo( () => ({ @@ -249,16 +261,20 @@ const ListItem = ({ styles.container, description ? styles.containerTwoLine : styles.containerOneLine, isDescriptionMultiline && styles.containerThreeLine, + { backgroundColor }, style, ]} onPress={onPress} + aria-selected={selected} theme={theme} testID={testID} > {left ? left({ - color: theme.colors[ListTokens.leadingIconColor], + color: selected + ? theme.colors[ListTokens.selectedContentColor] + : theme.colors[ListTokens.leadingIconColor], style: getLeftStyles(isDescriptionMultiline, description), }) : null} @@ -274,7 +290,9 @@ const ListItem = ({ {right ? right({ - color: theme.colors[ListTokens.trailingIconColor], + color: selected + ? theme.colors[ListTokens.selectedContentColor] + : theme.colors[ListTokens.trailingIconColor], style: getRightStyles(isDescriptionMultiline, description), }) : null} diff --git a/src/components/List/tokens.ts b/src/components/List/tokens.ts index 5df93882ed..f1fd872458 100644 --- a/src/components/List/tokens.ts +++ b/src/components/List/tokens.ts @@ -16,6 +16,8 @@ const colors = { leadingIconColor: 'onSurfaceVariant', trailingIconColor: 'onSurfaceVariant', expandTrailingIconColor: 'onSurface', + selectedContainerColor: 'primaryContainer', + selectedContentColor: 'onPrimaryContainer', } as const satisfies Record; export const ListTokens = { ...sizes, ...colors }; diff --git a/src/components/List/utils.ts b/src/components/List/utils.ts index a0c5f1ef00..1d9a9cc1ff 100644 --- a/src/components/List/utils.ts +++ b/src/components/List/utils.ts @@ -55,7 +55,27 @@ export const ListRowContext = React.createContext<{ verticalPadding: number }>({ verticalPadding: ListTokens.verticalPadding, }); -export const getAccordionColors = ({ theme }: { theme: InternalTheme }) => ({ - titleTextColor: theme.colors[ListTokens.headlineColor], - descriptionColor: theme.colors[ListTokens.supportingTextColor], -}); +export const getAccordionColors = ({ + theme, + selected, +}: { + theme: InternalTheme; + selected?: boolean; +}) => { + const selectedContentColor = theme.colors[ListTokens.selectedContentColor]; + + return { + titleTextColor: selected + ? selectedContentColor + : theme.colors[ListTokens.headlineColor], + descriptionColor: selected + ? selectedContentColor + : theme.colors[ListTokens.supportingTextColor], + leadingIconColor: selected + ? selectedContentColor + : theme.colors[ListTokens.leadingIconColor], + trailingIconColor: selected + ? selectedContentColor + : theme.colors[ListTokens.expandTrailingIconColor], + }; +}; diff --git a/src/components/__tests__/ListAccordion.test.tsx b/src/components/__tests__/ListAccordion.test.tsx index db0cdd74f2..e7fa7318de 100644 --- a/src/components/__tests__/ListAccordion.test.tsx +++ b/src/components/__tests__/ListAccordion.test.tsx @@ -217,4 +217,19 @@ describe('ListAccordion', () => { fontSize: 77, }); }); + + it('renders a selected accordion on the primary container', async () => { + await render( + + + + ); + + expect(screen.getByText('Accordion item 1')).toHaveStyle({ + color: getTheme().colors.onPrimaryContainer, + }); + expect(screen.getByText('Supporting')).toHaveStyle({ + color: getTheme().colors.onPrimaryContainer, + }); + }); }); diff --git a/src/components/__tests__/ListItem.test.tsx b/src/components/__tests__/ListItem.test.tsx index 985976f03a..65c6b00ac1 100644 --- a/src/components/__tests__/ListItem.test.tsx +++ b/src/components/__tests__/ListItem.test.tsx @@ -5,6 +5,7 @@ import { Text, View } from 'react-native'; import { expect, it, jest } from '@jest/globals'; import { userEvent } from '@testing-library/react-native'; +import { getTheme } from '../../core/theming'; import { fireEvent, render, screen } from '../../test-utils'; import { red500 } from '../../theme/colors'; import Chip from '../Chip/Chip'; @@ -347,3 +348,41 @@ it('applies the theme override to title and description typography', async () => expect(screen.getByText('First Item')).toHaveStyle({ fontSize: 99 }); expect(screen.getByText('Item description')).toHaveStyle({ fontSize: 77 }); }); + +it('renders an unselected list item on surface colors', async () => { + await render( + + ); + + expect(screen.getByText('First Item')).toHaveStyle({ + color: getTheme().colors.onSurface, + }); + expect(screen.getByText('Item description')).toHaveStyle({ + color: getTheme().colors.onSurfaceVariant, + }); +}); + +it('renders a selected list item on the primary container', async () => { + await render( + + ); + + expect(screen.getByTestId(testID)).toHaveStyle({ + backgroundColor: getTheme().colors.primaryContainer, + }); + expect(screen.getByText('First Item')).toHaveStyle({ + color: getTheme().colors.onPrimaryContainer, + }); + expect(screen.getByText('Item description')).toHaveStyle({ + color: getTheme().colors.onPrimaryContainer, + }); +}); diff --git a/src/components/__tests__/__snapshots__/ListAccordion.test.tsx.snap b/src/components/__tests__/__snapshots__/ListAccordion.test.tsx.snap index 69661b0af7..a77dc4f5f2 100644 --- a/src/components/__tests__/__snapshots__/ListAccordion.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/ListAccordion.test.tsx.snap @@ -202,6 +202,9 @@ exports[`renders expanded accordion 1`] = ` "minHeight": 56, }, false, + { + "backgroundColor": undefined, + }, undefined, ], ] diff --git a/src/components/__tests__/__snapshots__/ListItem.test.tsx.snap b/src/components/__tests__/__snapshots__/ListItem.test.tsx.snap index 4aa0355fcd..74facd848f 100644 --- a/src/components/__tests__/__snapshots__/ListItem.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/ListItem.test.tsx.snap @@ -44,6 +44,9 @@ exports[`renders list item with custom description 1`] = ` "minHeight": 72, }, false, + { + "backgroundColor": undefined, + }, undefined, ], ] @@ -430,6 +433,9 @@ exports[`renders list item with custom title and description styles 1`] = ` "minHeight": 72, }, false, + { + "backgroundColor": undefined, + }, undefined, ], ] @@ -581,6 +587,9 @@ exports[`renders list item with left and right items 1`] = ` "minHeight": 72, }, false, + { + "backgroundColor": undefined, + }, undefined, ], ] @@ -777,6 +786,9 @@ exports[`renders list item with left item 1`] = ` "minHeight": 56, }, false, + { + "backgroundColor": undefined, + }, undefined, ], ] @@ -938,6 +950,9 @@ exports[`renders list item with right item 1`] = ` "minHeight": 56, }, false, + { + "backgroundColor": undefined, + }, undefined, ], ] @@ -1055,6 +1070,9 @@ exports[`renders list item with title and description 1`] = ` "minHeight": 72, }, false, + { + "backgroundColor": undefined, + }, undefined, ], ] @@ -1202,6 +1220,9 @@ exports[`renders with a description with typeof number 1`] = ` "minHeight": 72, }, false, + { + "backgroundColor": undefined, + }, undefined, ], ] diff --git a/src/components/__tests__/__snapshots__/ListSection.test.tsx.snap b/src/components/__tests__/__snapshots__/ListSection.test.tsx.snap index 406c975348..bc5c04f24d 100644 --- a/src/components/__tests__/__snapshots__/ListSection.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/ListSection.test.tsx.snap @@ -496,6 +496,9 @@ exports[`renders list section with custom title style 1`] = ` "minHeight": 56, }, false, + { + "backgroundColor": undefined, + }, undefined, ], ] @@ -654,6 +657,9 @@ exports[`renders list section with custom title style 1`] = ` "minHeight": 56, }, false, + { + "backgroundColor": undefined, + }, undefined, ], ] @@ -1266,6 +1272,9 @@ exports[`renders list section with subheader 1`] = ` "minHeight": 56, }, false, + { + "backgroundColor": undefined, + }, undefined, ], ] @@ -1424,6 +1433,9 @@ exports[`renders list section with subheader 1`] = ` "minHeight": 56, }, false, + { + "backgroundColor": undefined, + }, undefined, ], ] @@ -1996,6 +2008,9 @@ exports[`renders list section without subheader 1`] = ` "minHeight": 56, }, false, + { + "backgroundColor": undefined, + }, undefined, ], ] @@ -2154,6 +2169,9 @@ exports[`renders list section without subheader 1`] = ` "minHeight": 56, }, false, + { + "backgroundColor": undefined, + }, undefined, ], ] From 7737875d2f36d0a5015dffdd5fd91a87c97df226 Mon Sep 17 00:00:00 2001 From: Adam Sajko Date: Fri, 7 Aug 2026 12:33:36 +0200 Subject: [PATCH 10/11] feat: animate List.Accordion expand and collapse --- src/components/List/ListAccordion.tsx | 100 +++- .../__snapshots__/ListAccordion.test.tsx.snap | 456 +++++++++++------- 2 files changed, 362 insertions(+), 194 deletions(-) diff --git a/src/components/List/ListAccordion.tsx b/src/components/List/ListAccordion.tsx index d8e39e2eda..2dbc727db7 100644 --- a/src/components/List/ListAccordion.tsx +++ b/src/components/List/ListAccordion.tsx @@ -12,12 +12,25 @@ import type { ViewStyle, } from 'react-native'; +import Animated, { + Easing, + ReduceMotion, + useAnimatedStyle, + useSharedValue, + withTiming, +} from 'react-native-reanimated'; +import type { + EntryAnimationsValues, + ExitAnimationsValues, +} from 'react-native-reanimated'; + import { ListAccordionGroupContext } from './ListAccordionGroup'; import { ListTokens } from './tokens'; import type { ListChildProps, Style } from './utils'; import { ListRowContext, getAccordionColors, getLeftStyles } from './utils'; import { useLocale } from '../../core/locale'; import { useInternalTheme } from '../../core/theming'; +import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext'; import type { ThemeProp } from '../../theme/types'; import MaterialCommunityIcon from '../MaterialCommunityIcon'; import TouchableRipple from '../TouchableRipple/TouchableRipple'; @@ -239,6 +252,62 @@ const ListAccordion = ({ ? groupContext.expandedId === id : expandedInternal; + const reduceMotion = useReduceMotion(); + const reanimatedReduceMotion = reduceMotion + ? ReduceMotion.Always + : ReduceMotion.Never; + + const timingConfig = React.useMemo( + () => ({ + duration: theme.motion.duration.medium2, + easing: Easing.bezier(...theme.motion.easing.emphasized), + reduceMotion: reanimatedReduceMotion, + }), + [ + theme.motion.duration.medium2, + theme.motion.easing.emphasized, + reanimatedReduceMotion, + ] + ); + + const chevronProgress = useSharedValue(isExpanded ? 1 : 0); + + React.useEffect(() => { + chevronProgress.value = withTiming(isExpanded ? 1 : 0, timingConfig); + }, [isExpanded, chevronProgress, timingConfig]); + + const chevronStyle = useAnimatedStyle(() => ({ + transform: [{ rotate: `${chevronProgress.value * 180}deg` }], + })); + + const expandAnimation = React.useCallback( + (values: EntryAnimationsValues) => { + 'worklet'; + return { + initialValues: { height: 0, opacity: 0 }, + animations: { + height: withTiming(values.targetHeight, timingConfig), + opacity: withTiming(1, timingConfig), + }, + }; + }, + [timingConfig] + ); + + const collapseAnimation = React.useCallback( + (values: ExitAnimationsValues) => { + 'worklet'; + return { + initialValues: { height: values.currentHeight, opacity: 1 }, + animations: { + height: withTiming(0, timingConfig), + opacity: withTiming(0, timingConfig), + }, + }; + }, + [timingConfig] + ); + const { descriptionColor, titleTextColor, @@ -341,12 +410,14 @@ const ListAccordion = ({ isExpanded: isExpanded, }) ) : ( - + + + )} @@ -354,8 +425,13 @@ const ListAccordion = ({ - {isExpanded - ? React.Children.map(children, (child) => { + {isExpanded ? ( + + {React.Children.map(children, (child) => { if ( left && React.isValidElement(child) && @@ -369,8 +445,9 @@ const ListAccordion = ({ } return child; - }) - : null} + })} + + ) : null} ); }; @@ -405,6 +482,9 @@ const styles = StyleSheet.create({ child: { paddingLeft: 40, }, + expandedContent: { + overflow: 'hidden', + }, content: { flex: 1, justifyContent: 'center', diff --git a/src/components/__tests__/__snapshots__/ListAccordion.test.tsx.snap b/src/components/__tests__/__snapshots__/ListAccordion.test.tsx.snap index a77dc4f5f2..93e6f6398c 100644 --- a/src/components/__tests__/__snapshots__/ListAccordion.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/ListAccordion.test.tsx.snap @@ -126,98 +126,114 @@ exports[`renders expanded accordion 1`] = ` } } > - - chevron-up - + + chevron-down + + @@ -225,49 +241,61 @@ exports[`renders expanded accordion 1`] = ` style={ [ { - "paddingLeft": 16, - }, - { - "flexGrow": 1, - "flexShrink": 1, - "justifyContent": "center", + "flexDirection": "row", + "width": "100%", }, undefined, ] } > - + - List item 1 - + ] + } + > + List item 1 + + @@ -447,35 +475,50 @@ exports[`renders list accordion with children 1`] = ` } } > - - chevron-down - + + chevron-down + + @@ -646,35 +689,50 @@ exports[`renders list accordion with custom title and description styles 1`] = ` } } > - - chevron-down - + + chevron-down + + @@ -855,35 +913,50 @@ exports[`renders list accordion with left items 1`] = ` } } > - - chevron-down - + + chevron-down + + @@ -1050,35 +1123,50 @@ exports[`renders multiline list accordion 1`] = ` } } > - - chevron-down - + + chevron-down + + From ec5685e86feed2126b9c009d5a4997f1f7f85acb Mon Sep 17 00:00:00 2001 From: Adam Sajko Date: Fri, 7 Aug 2026 13:23:41 +0200 Subject: [PATCH 11/11] feat: add typed leading and trailing slots to List.Item --- example/src/Examples/ListItemExample.tsx | 2 +- src/components/List/ListIcon.tsx | 6 +- src/components/List/ListItem.tsx | 90 +++++++++++++++------- src/components/List/ListItemContext.tsx | 10 +++ src/components/__tests__/ListItem.test.tsx | 44 +++++++++++ 5 files changed, 124 insertions(+), 28 deletions(-) create mode 100644 src/components/List/ListItemContext.tsx diff --git a/example/src/Examples/ListItemExample.tsx b/example/src/Examples/ListItemExample.tsx index 923c3f7c53..030d21cef8 100644 --- a/example/src/Examples/ListItemExample.tsx +++ b/example/src/Examples/ListItemExample.tsx @@ -16,7 +16,7 @@ const SelectableSection = () => { description="Supporting text" selected={index === selected} onPress={() => setSelected(index)} - left={(props) => } + leading={} /> ))} diff --git a/src/components/List/ListIcon.tsx b/src/components/List/ListIcon.tsx index ff02dea7f6..fb06e6463e 100644 --- a/src/components/List/ListIcon.tsx +++ b/src/components/List/ListIcon.tsx @@ -1,6 +1,8 @@ +import * as React from 'react'; import { StyleSheet, View } from 'react-native'; import type { ColorValue, StyleProp, ViewStyle } from 'react-native'; +import { ListItemContext } from './ListItemContext'; import { useInternalTheme } from '../../core/theming'; import type { ThemeProp } from '../../theme/types'; import Icon from '../Icon'; @@ -50,10 +52,12 @@ const ListIcon = ({ theme: themeOverrides, }: Props) => { const theme = useInternalTheme(themeOverrides); + const listItem = React.useContext(ListItemContext); + const color = iconColor ?? listItem?.color; return ( - + ); }; diff --git a/src/components/List/ListItem.tsx b/src/components/List/ListItem.tsx index 52b7ae3924..68879db917 100644 --- a/src/components/List/ListItem.tsx +++ b/src/components/List/ListItem.tsx @@ -11,6 +11,7 @@ import type { ViewStyle, } from 'react-native'; +import { ListItemContext } from './ListItemContext'; import { ListTokens } from './tokens'; import { ListRowContext, getLeftStyles, getRightStyles } from './utils'; import type { Style } from './utils'; @@ -50,6 +51,14 @@ export type Props = Omit< * Description text for the list item or callback which returns a React element to display the description. */ description?: Description; + /** + * Element to display in the leading slot. Takes precedence over `left`. + */ + leading?: React.ReactNode; + /** + * Element to display in the trailing slot. Takes precedence over `right`. + */ + trailing?: React.ReactNode; /** * Callback which returns a React element to display on the left side. */ @@ -151,6 +160,8 @@ export type Props = Omit< const ListItem = ({ left, right, + leading, + trailing, title, description, selected, @@ -252,6 +263,45 @@ const ListItem = ({ [isDescriptionMultiline] ); + const accessoryContext = React.useMemo( + () => ({ color: descriptionColor }), + [descriptionColor] + ); + + const renderLeading = () => { + const accessoryStyle = getLeftStyles(isDescriptionMultiline, description); + + if (leading) { + return {leading}; + } + + return left + ? left({ + color: selected + ? theme.colors[ListTokens.selectedContentColor] + : theme.colors[ListTokens.leadingIconColor], + style: accessoryStyle, + }) + : null; + }; + + const renderTrailing = () => { + const accessoryStyle = getRightStyles(isDescriptionMultiline, description); + + if (trailing) { + return {trailing}; + } + + return right + ? right({ + color: selected + ? theme.colors[ListTokens.selectedContentColor] + : theme.colors[ListTokens.trailingIconColor], + style: accessoryStyle, + }) + : null; + }; + return ( - - {left - ? left({ - color: selected - ? theme.colors[ListTokens.selectedContentColor] - : theme.colors[ListTokens.leadingIconColor], - style: getLeftStyles(isDescriptionMultiline, description), - }) - : null} - - {renderTitle()} + + + {renderLeading()} + + {renderTitle()} - {description - ? renderDescription(descriptionColor, description) - : null} + {description + ? renderDescription(descriptionColor, description) + : null} + + {renderTrailing()} - {right - ? right({ - color: selected - ? theme.colors[ListTokens.selectedContentColor] - : theme.colors[ListTokens.trailingIconColor], - style: getRightStyles(isDescriptionMultiline, description), - }) - : null} - + ); diff --git a/src/components/List/ListItemContext.tsx b/src/components/List/ListItemContext.tsx new file mode 100644 index 0000000000..f372559a93 --- /dev/null +++ b/src/components/List/ListItemContext.tsx @@ -0,0 +1,10 @@ +import * as React from 'react'; +import type { ColorValue } from 'react-native'; + +export type ListItemContextType = { + color: ColorValue; +}; + +export const ListItemContext = React.createContext( + null +); diff --git a/src/components/__tests__/ListItem.test.tsx b/src/components/__tests__/ListItem.test.tsx index 65c6b00ac1..6dc2662020 100644 --- a/src/components/__tests__/ListItem.test.tsx +++ b/src/components/__tests__/ListItem.test.tsx @@ -310,6 +310,50 @@ it('keeps a 64dp leading video on the same height once the description wraps', a }); }); +it('colors a leading List.Icon from the list item context', async () => { + await render( + } + testID={testID} + /> + ); + + expect( + screen.getByText('folder', { includeHiddenElements: true }) + ).toHaveStyle({ color: getTheme().colors.onSurfaceVariant }); +}); + +it('renders the trailing slot', async () => { + await render( + Trailing} + testID={testID} + /> + ); + + expect(screen.getByText('Trailing')).toBeOnTheScreen(); +}); + +it('prefers the typed slots over the left and right render props', async () => { + await render( + Leading slot} + trailing={Trailing slot} + left={() => Left render prop} + right={() => Right render prop} + testID={testID} + /> + ); + + expect(screen.getByText('Leading slot')).toBeOnTheScreen(); + expect(screen.getByText('Trailing slot')).toBeOnTheScreen(); + expect(screen.queryByText('Left render prop')).not.toBeOnTheScreen(); + expect(screen.queryByText('Right render prop')).not.toBeOnTheScreen(); +}); + it('top aligns the accessories once the description wraps', async () => { await render(