Skip to content

Commit a419866

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

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,14 +3,13 @@ import { StyleSheet, View } from 'react-native';
33
import type {
44
ColorValue,
55
GestureResponderEvent,
6-
NativeSyntheticEvent,
76
StyleProp,
8-
TextLayoutEventData,
97
TextStyle,
108
ViewStyle,
119
} from 'react-native';
1210

1311
import { ListTokens } from './tokens';
12+
import { useMultilineDescription } from './useMultilineDescription';
1413
import { ListRowContext, getLeftStyles, getRightStyles } from './utils';
1514
import type { Style } from './utils';
1615
import { useInternalTheme } from '../../core/theming';
@@ -162,15 +161,11 @@ const ListItem = ({
162161
...rest
163162
}: Props) => {
164163
const theme = useInternalTheme(themeOverrides);
165-
const [isDescriptionMultiline, setIsDescriptionMultiline] =
166-
React.useState(false);
167-
168-
const onDescriptionTextLayout = (
169-
event: NativeSyntheticEvent<TextLayoutEventData>
170-
) => {
171-
const { nativeEvent } = event;
172-
setIsDescriptionMultiline(nativeEvent.lines.length >= 2);
173-
};
164+
const {
165+
isMultiline: isDescriptionMultiline,
166+
contentRef,
167+
descriptionProps,
168+
} = useMultilineDescription(Boolean(description));
174169

175170
const renderDescription = (
176171
descriptionColor: ColorValue,
@@ -191,7 +186,7 @@ const ListItem = ({
191186
numberOfLines={descriptionNumberOfLines}
192187
ellipsizeMode={descriptionEllipsizeMode}
193188
style={[{ color: descriptionColor }, descriptionStyle]}
194-
onTextLayout={onDescriptionTextLayout}
189+
{...descriptionProps}
195190
maxFontSizeMultiplier={descriptionMaxFontSizeMultiplier}
196191
>
197192
{description}
@@ -258,6 +253,7 @@ const ListItem = ({
258253
})
259254
: null}
260255
<View
256+
ref={contentRef}
261257
style={[styles.item, styles.content, contentStyle]}
262258
testID={`${testID}-content`}
263259
>
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)