Skip to content

Commit b965426

Browse files
committed
feat: detect three-line List rows on web by measuring the description
1 parent bd23951 commit b965426

4 files changed

Lines changed: 117 additions & 25 deletions

File tree

src/components/List/ListAccordion.tsx

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,16 @@ import { StyleSheet, View } from 'react-native';
33
import type {
44
ColorValue,
55
GestureResponderEvent,
6-
NativeSyntheticEvent,
76
PressableAndroidRippleConfig,
87
StyleProp,
9-
TextLayoutEventData,
108
TextStyle,
119
ViewProps,
1210
ViewStyle,
1311
} from 'react-native';
1412

1513
import { ListAccordionGroupContext } from './ListAccordionGroup';
1614
import { ListTokens } from './tokens';
15+
import { useMultilineDescription } from './useMultilineDescription';
1716
import type { ListChildProps, Style } from './utils';
1817
import { ListRowContext, getAccordionColors, getLeftStyles } from './utils';
1918
import { useLocale } from '../../core/locale';
@@ -202,15 +201,11 @@ const ListAccordion = ({
202201
const [expanded, setExpanded] = React.useState<boolean>(
203202
expandedProp || false
204203
);
205-
const [isDescriptionMultiline, setIsDescriptionMultiline] =
206-
React.useState(false);
207-
208-
const onDescriptionTextLayout = (
209-
event: NativeSyntheticEvent<TextLayoutEventData>
210-
) => {
211-
const { nativeEvent } = event;
212-
setIsDescriptionMultiline(nativeEvent.lines.length >= 2);
213-
};
204+
const {
205+
isMultiline: isDescriptionMultiline,
206+
contentRef,
207+
descriptionProps,
208+
} = useMultilineDescription(Boolean(description));
214209

215210
const handlePressAction = (e: GestureResponderEvent) => {
216211
onPress?.(e);
@@ -285,7 +280,10 @@ const ListAccordion = ({
285280
style: getLeftStyles(isDescriptionMultiline, description),
286281
})
287282
: null}
288-
<View style={[styles.contentItem, styles.content, contentStyle]}>
283+
<View
284+
ref={contentRef}
285+
style={[styles.contentItem, styles.content, contentStyle]}
286+
>
289287
<Text
290288
variant="bodyLarge"
291289
theme={theme}
@@ -313,7 +311,7 @@ const ListAccordion = ({
313311
},
314312
descriptionStyle,
315313
]}
316-
onTextLayout={onDescriptionTextLayout}
314+
{...descriptionProps}
317315
maxFontSizeMultiplier={descriptionMaxFontSizeMultiplier}
318316
>
319317
{description}

src/components/List/ListItem.tsx

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,14 @@ import { StyleSheet, View } from 'react-native';
33
import type {
44
ColorValue,
55
GestureResponderEvent,
6-
NativeSyntheticEvent,
76
StyleProp,
8-
TextLayoutEventData,
97
TextProps,
108
TextStyle,
119
ViewStyle,
1210
} from 'react-native';
1311

1412
import { ListTokens } from './tokens';
13+
import { useMultilineDescription } from './useMultilineDescription';
1514
import { ListRowContext, getLeftStyles, getRightStyles } from './utils';
1615
import type { Style } from './utils';
1716
import { useInternalTheme } from '../../core/theming';
@@ -167,15 +166,11 @@ const ListItem = ({
167166
...rest
168167
}: Props) => {
169168
const theme = useInternalTheme(themeOverrides);
170-
const [isDescriptionMultiline, setIsDescriptionMultiline] =
171-
React.useState(false);
172-
173-
const onDescriptionTextLayout = (
174-
event: NativeSyntheticEvent<TextLayoutEventData>
175-
) => {
176-
const { nativeEvent } = event;
177-
setIsDescriptionMultiline(nativeEvent.lines.length >= 2);
178-
};
169+
const {
170+
isMultiline: isDescriptionMultiline,
171+
contentRef,
172+
descriptionProps,
173+
} = useMultilineDescription(Boolean(description));
179174

180175
const renderDescription = (
181176
descriptionColor: ColorValue,
@@ -196,7 +191,7 @@ const ListItem = ({
196191
numberOfLines={descriptionNumberOfLines}
197192
ellipsizeMode={descriptionEllipsizeMode}
198193
style={[{ color: descriptionColor }, descriptionStyle]}
199-
onTextLayout={onDescriptionTextLayout}
194+
{...descriptionProps}
200195
maxFontSizeMultiplier={descriptionMaxFontSizeMultiplier}
201196
>
202197
{description}
@@ -263,6 +258,7 @@ const ListItem = ({
263258
})
264259
: null}
265260
<View
261+
ref={contentRef}
266262
style={[styles.item, styles.content, contentStyle]}
267263
testID={testID ? `${testID}-content` : undefined}
268264
>
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import * as React from 'react';
2+
import type {
3+
NativeSyntheticEvent,
4+
TextLayoutEventData,
5+
TextProps,
6+
View,
7+
} from 'react-native';
8+
9+
type MultilineDescription = {
10+
isMultiline: boolean;
11+
contentRef: React.RefObject<View | null>;
12+
descriptionProps: Pick<TextProps, 'onTextLayout'>;
13+
};
14+
15+
export const useMultilineDescription = (
16+
hasDescription: boolean
17+
): MultilineDescription => {
18+
const contentRef = React.useRef<View>(null);
19+
const [isMultiline, setIsMultiline] = React.useState(false);
20+
21+
const onTextLayout = React.useCallback(
22+
(event: NativeSyntheticEvent<TextLayoutEventData>) => {
23+
setIsMultiline(event.nativeEvent.lines.length >= 2);
24+
},
25+
[]
26+
);
27+
28+
return {
29+
isMultiline: hasDescription && isMultiline,
30+
contentRef,
31+
descriptionProps: { onTextLayout },
32+
};
33+
};
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import * as React from 'react';
2+
import type { TextProps, View } from 'react-native';
3+
4+
type MultilineDescription = {
5+
isMultiline: boolean;
6+
contentRef: React.RefObject<View | null>;
7+
descriptionProps: Pick<TextProps, 'onTextLayout'>;
8+
};
9+
10+
const LINE_TOLERANCE_PX = 1;
11+
12+
/**
13+
* Web has no `onTextLayout`, so the description is measured from the DOM.
14+
* `useLayoutEffect` runs before paint, so the row is laid out at its final
15+
* height in the frame it first appears. `ResizeObserver` re-measures on
16+
* reflow, for example when a web font swaps in or the column is resized.
17+
*/
18+
export const useMultilineDescription = (
19+
hasDescription: boolean
20+
): MultilineDescription => {
21+
const contentRef = React.useRef<View>(null);
22+
const [isMultiline, setIsMultiline] = React.useState(false);
23+
24+
React.useLayoutEffect(() => {
25+
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
26+
const content = contentRef.current as unknown as HTMLElement | null;
27+
28+
if (!hasDescription || !content) {
29+
setIsMultiline(false);
30+
return undefined;
31+
}
32+
33+
// The description is rendered after the title, as the last node of the column.
34+
const description = content.lastElementChild;
35+
36+
if (!description) {
37+
return undefined;
38+
}
39+
40+
const measure = () => {
41+
const { height } = description.getBoundingClientRect();
42+
43+
if (height === 0) {
44+
return;
45+
}
46+
47+
const computed = window.getComputedStyle(description);
48+
const lineHeight = Number.parseFloat(computed.lineHeight);
49+
const resolvedLineHeight = Number.isNaN(lineHeight)
50+
? Number.parseFloat(computed.fontSize) * 1.2 // `line-height: normal` fallback
51+
: lineHeight;
52+
53+
setIsMultiline(height > resolvedLineHeight + LINE_TOLERANCE_PX);
54+
};
55+
56+
measure();
57+
58+
const observer = new ResizeObserver(measure);
59+
observer.observe(description);
60+
61+
return () => observer.disconnect();
62+
}, [hasDescription]);
63+
64+
return { isMultiline, contentRef, descriptionProps: {} };
65+
};

0 commit comments

Comments
 (0)