Skip to content

Commit 394ba93

Browse files
TheSonOfThompCopilotstephl3
authored
feat(lib): Adds findChildren & restructure child query utils (#3156)
* mv existing utils * isChildWithProperty * Update findChild.tsx * findChildren * Fix isChildWithProperty tests * update findChild tests * Update findChildren.spec.tsx * mv componentQueries * update index files * mv child queries * Update index.ts * Apply suggestions from code review `allChildren.length === 1` Co-authored-by: Copilot <[email protected]> * Creates unwrapRootFragment * update findChild/children with unwrapRootFragment * Update findChildren.ts * Update findChildren.ts * Create lib-find-children.md * Update packages/lib/src/childQueries/findChild/findChild.tsx Co-authored-by: Stephen Lee <[email protected]> * Update packages/lib/src/childQueries/findChildren/findChildren.ts Co-authored-by: Stephen Lee <[email protected]> * adds 2 level fragment test * update index files * rm wizard * rm todo * Update isChildWithProperty.spec.tsx --------- Co-authored-by: Copilot <[email protected]> Co-authored-by: Stephen Lee <[email protected]>
1 parent c62b415 commit 394ba93

22 files changed

+615
-136
lines changed

.changeset/lib-find-children.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@leafygreen-ui/lib': minor
3+
---
4+
5+
Adds `findChildren` utility to `lib`. Also adds `unwrapRootFragment` and `isChildWithProperty` helpers
File renamed without changes.
File renamed without changes.
File renamed without changes.

packages/lib/src/findChild/findChild.spec.tsx renamed to packages/lib/src/childQueries/findChild/findChild.spec.tsx

Lines changed: 38 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
/**
2+
* Disabling `react/jsx-key` lets us pass `children` as an Iterable<ReactNode> directly to the test function
3+
* instead of needing to wrap everything in a Fragment,
4+
* which is not representative of real use-cases
5+
*/
6+
/* eslint-disable react/jsx-key */
17
import React from 'react';
28
import styled from '@emotion/styled';
39

@@ -24,28 +30,30 @@ Baz.displayName = 'Baz';
2430
(Bar as any).isBar = true;
2531
(Baz as any).isBaz = true;
2632

27-
describe('findChild', () => {
33+
describe('packages/lib/findChild', () => {
34+
test('should find a child component with matching static property', () => {
35+
// Create an iterable to test different iteration scenarios
36+
const children = [<Foo text="Foo" />, <Bar text="Bar" />];
37+
38+
const found = findChild(children, 'isFoo');
39+
expect(found).toBeDefined();
40+
expect((found as React.ReactElement).props.text).toBe('Foo');
41+
});
42+
2843
test('should find the first child component with matching static property', () => {
29-
const children = (
30-
<>
31-
<Foo text="first" />
32-
<Bar text="second" />
33-
<Foo text="third" />
34-
</>
35-
);
44+
const children = [
45+
<Foo text="first" />,
46+
<Bar text="second" />,
47+
<Foo text="third" />,
48+
];
3649

3750
const found = findChild(children, 'isFoo');
3851
expect(found).toBeDefined();
3952
expect((found as React.ReactElement).props.text).toBe('first');
4053
});
4154

4255
test('should return undefined if no child matches', () => {
43-
const children = (
44-
<>
45-
<Foo text="first" />
46-
<Bar text="second" />
47-
</>
48-
);
56+
const children = [<Foo text="first" />, <Bar text="second" />];
4957

5058
const found = findChild(children, 'isBaz');
5159
expect(found).toBeUndefined();
@@ -56,18 +64,7 @@ describe('findChild', () => {
5664
expect(found).toBeUndefined();
5765
});
5866

59-
test('should work with array children', () => {
60-
const children = [
61-
<Foo key="1" text="first" />,
62-
<Bar key="2" text="second" />,
63-
];
64-
65-
const found = findChild(children, 'isBar');
66-
expect(found).toBeDefined();
67-
expect((found as React.ReactElement).props.text).toBe('second');
68-
});
69-
70-
test('should handle fragment children', () => {
67+
test('should handle a single-level of fragment children', () => {
7168
const children = (
7269
<React.Fragment>
7370
<Foo text="in-fragment" />
@@ -80,62 +77,41 @@ describe('findChild', () => {
8077
expect((found as React.ReactElement).props.text).toBe('also-in-fragment');
8178
});
8279

83-
test('should find first match even with multiple matches', () => {
80+
test('should NOT find components in deeply nested fragments (search depth limitation)', () => {
8481
const children = (
85-
<>
86-
<Foo text="first-match" />
87-
<Foo text="second-match" />
88-
<Bar text="different" />
89-
</>
82+
<React.Fragment>
83+
<React.Fragment>
84+
<Foo text="deeply-nested" />
85+
<Bar text="also-in-fragment" />
86+
</React.Fragment>
87+
</React.Fragment>
9088
);
9189

90+
// Should NOT find the deeply nested Foo instances
9291
const found = findChild(children, 'isFoo');
93-
expect(found).toBeDefined();
94-
expect((found as React.ReactElement).props.text).toBe('first-match');
92+
expect(found).toBeUndefined();
9593
});
9694

97-
test('should NOT find deeply nested components (search depth limitation)', () => {
95+
test('should NOT find components wrapped in other elements', () => {
9896
const children = (
99-
<>
100-
{/* Nested fragment - should NOT find */}
101-
<React.Fragment>
102-
<React.Fragment>
103-
<Foo text="deeply-nested" />
104-
</React.Fragment>
105-
</React.Fragment>
106-
107-
{/* Component inside div - should NOT find */}
108-
<div>
109-
<Foo text="inside-div" />
110-
</div>
111-
112-
<Bar text="direct-child" />
113-
</>
97+
<div>
98+
<Foo text="inside-div" />
99+
</div>
114100
);
115101

116102
// Should NOT find the deeply nested Foo instances
117103
const found = findChild(children, 'isFoo');
118104
expect(found).toBeUndefined();
119-
120-
// Should find the direct Bar
121-
const found2 = findChild(children, 'isBar');
122-
expect(found2).toBeDefined();
123-
expect((found2 as React.ReactElement).props.text).toBe('direct-child');
124105
});
125106

126107
test('should work with styled components from @emotion/styled', () => {
127108
// Create a real styled component using the actual styled() function
128-
const RealStyledFoo = styled(Foo)`
109+
const StyledFoo = styled(Foo)`
129110
background-color: red;
130111
padding: 8px;
131112
`;
132113

133-
const children = (
134-
<>
135-
<RealStyledFoo text="real-styled" />
136-
<Bar text="regular" />
137-
</>
138-
);
114+
const children = [<StyledFoo text="real-styled" />, <Bar text="regular" />];
139115

140116
// The key test: findChild should find the styled component
141117
const found = findChild(children, 'isFoo');
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { Children, ReactElement, ReactNode } from 'react';
2+
3+
import { isChildWithProperty } from '../isChildWithProperty';
4+
import { unwrapRootFragment } from '../unwrapRootFragment';
5+
6+
/**
7+
* Find the first child component with a matching static property
8+
*
9+
* **Search Depth:** This function only searches 1 level deep:
10+
* - Direct children of the provided children
11+
* - Direct children inside React.Fragment components (1 level of fragment nesting)
12+
* - Does NOT recursively search nested fragments or deeply nested components
13+
*
14+
* **Styled Component Support:** Checks component.target and component.__emotion_base
15+
* for styled() wrapped components.
16+
*
17+
* @example
18+
* ```ts
19+
* // ✅ Will find: Direct child
20+
* findChild(<Foo />, 'isFoo') // <Foo />
21+
*
22+
* // ✅ Will find: Child inside a single fragment
23+
* findChild(<><Foo /></>, 'isFoo') // <Foo />
24+
*
25+
* // ❌ Will NOT find: Deeply nested fragments
26+
* findChild(<><><Foo /></></>, 'isFoo') // undefined
27+
*
28+
* // ❌ Will NOT find: Nested in other elements
29+
* findChild(<div><Foo /></div>, 'isFoo') // undefined
30+
* ```
31+
*
32+
* @param children Any React children
33+
* @param staticProperty The static property name to check for
34+
* @returns The first matching ReactElement or undefined if not found
35+
*/
36+
export const findChild = (
37+
children: ReactNode,
38+
staticProperty: string,
39+
): ReactElement | undefined => {
40+
if (!children || Children.count(children) === 0) {
41+
return undefined;
42+
}
43+
44+
const allChildren = unwrapRootFragment(children);
45+
46+
return allChildren?.find(child =>
47+
isChildWithProperty(child, staticProperty),
48+
) as ReactElement | undefined;
49+
};
File renamed without changes.
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
/**
2+
* Disabling `react/jsx-key` lets us pass `children` as an Iterable<ReactNode> directly to the test function
3+
* instead of needing to wrap everything in a Fragment,
4+
* which is not representative of real use-cases
5+
*/
6+
/* eslint-disable react/jsx-key */
7+
import React, { Fragment } from 'react';
8+
import styled from '@emotion/styled';
9+
10+
import { findChildren } from './findChildren';
11+
12+
// Test components
13+
const Foo = React.forwardRef<HTMLDivElement, { text: string }>(
14+
({ text }, ref) => <div ref={ref}>{text}</div>,
15+
);
16+
Foo.displayName = 'Foo';
17+
18+
const Bar = React.forwardRef<HTMLDivElement, { text: string }>(
19+
({ text }, ref) => <div ref={ref}>{text}</div>,
20+
);
21+
Bar.displayName = 'Bar';
22+
23+
const Baz = React.forwardRef<HTMLDivElement, { text: string }>(
24+
({ text }, ref) => <div ref={ref}>{text}</div>,
25+
);
26+
Baz.displayName = 'Baz';
27+
28+
// Add static properties to test components with type assertion
29+
(Foo as any).isFoo = true;
30+
(Bar as any).isBar = true;
31+
(Baz as any).isBaz = true;
32+
33+
describe('packages/lib/findChildren', () => {
34+
describe('basic functionality', () => {
35+
it('should find all children with matching static property', () => {
36+
const children = [
37+
<Foo text="first" />,
38+
<Bar text="second" />,
39+
<Foo text="third" />,
40+
<Baz text="fourth" />,
41+
<Foo text="fifth" />,
42+
];
43+
44+
const found = findChildren(children, 'isFoo');
45+
expect(found).toHaveLength(3);
46+
expect(found[0].props.text).toBe('first');
47+
expect(found[1].props.text).toBe('third');
48+
expect(found[2].props.text).toBe('fifth');
49+
});
50+
51+
it('should return empty array if no children match', () => {
52+
const children = [<Foo text="first" />, <Bar text="second" />];
53+
54+
const found = findChildren(children, 'isBaz');
55+
expect(found).toEqual([]);
56+
});
57+
58+
it('should find single matching child', () => {
59+
const children = [
60+
<Foo key="1" text="only-foo" />,
61+
<Bar key="2" text="second" />,
62+
];
63+
64+
const found = findChildren(children, 'isFoo');
65+
expect(found).toHaveLength(1);
66+
expect(found[0].props.text).toBe('only-foo');
67+
});
68+
});
69+
70+
describe('empty and null children handling', () => {
71+
it('should handle null children', () => {
72+
const found = findChildren(null, 'isFoo');
73+
expect(found).toEqual([]);
74+
});
75+
76+
it('should handle undefined children', () => {
77+
const found = findChildren(undefined, 'isFoo');
78+
expect(found).toEqual([]);
79+
});
80+
81+
it('should handle empty fragment', () => {
82+
const children = <></>;
83+
const found = findChildren(children, 'isFoo');
84+
expect(found).toEqual([]);
85+
});
86+
87+
it('should handle empty array children', () => {
88+
const children: Array<React.ReactElement> = [];
89+
const found = findChildren(children, 'isFoo');
90+
expect(found).toEqual([]);
91+
});
92+
});
93+
94+
describe('Fragment handling', () => {
95+
it('should handle single-level fragment children', () => {
96+
const children = (
97+
<React.Fragment>
98+
<Foo text="foo-in-fragment" />
99+
<Bar text="bar-in-fragment" />
100+
<Foo text="another-foo" />
101+
</React.Fragment>
102+
);
103+
104+
const found = findChildren(children, 'isFoo');
105+
expect(found).toHaveLength(2);
106+
expect(found[0].props.text).toBe('foo-in-fragment');
107+
expect(found[1].props.text).toBe('another-foo');
108+
});
109+
110+
it('should NOT find children in deeply nested Fragments', () => {
111+
const children = (
112+
<React.Fragment>
113+
<Foo text="direct-foo" />
114+
<React.Fragment>
115+
<React.Fragment>
116+
<Foo text="deeply-nested-foo" />
117+
</React.Fragment>
118+
</React.Fragment>
119+
<Bar text="direct-bar" />
120+
</React.Fragment>
121+
);
122+
123+
// Should only find direct children, not double-nested ones
124+
const found = findChildren(children, 'isFoo');
125+
expect(found).toHaveLength(1);
126+
expect(found[0].props.text).toBe('direct-foo');
127+
});
128+
});
129+
130+
describe('styled components', () => {
131+
it('should work with styled components from @emotion/styled', () => {
132+
const StyledFoo = styled(Foo)`
133+
background-color: red;
134+
padding: 8px;
135+
`;
136+
137+
const children = [
138+
<Foo text="regular-foo" />,
139+
<StyledFoo text="styled-foo" />,
140+
<StyledFoo text="styled-foo-two" />,
141+
<Bar text="regular-bar" />,
142+
<Foo text="another-foo" />,
143+
];
144+
145+
const found = findChildren(children, 'isFoo');
146+
expect(found).toHaveLength(4);
147+
expect(found.map(c => c.props.text)).toEqual([
148+
'regular-foo',
149+
'styled-foo',
150+
'styled-foo-two',
151+
'another-foo',
152+
]);
153+
154+
// Verify the styled component is actually styled
155+
const styledComponent = found[1];
156+
const styledType = styledComponent.type as any;
157+
const hasEmotionProps = !!(
158+
styledType.target || styledType.__emotion_base
159+
);
160+
expect(hasEmotionProps).toBe(true);
161+
});
162+
});
163+
164+
describe('search depth limitations', () => {
165+
it('should NOT find deeply nested components', () => {
166+
const children = [
167+
<Fragment>
168+
<Foo text="single-fragment" />
169+
</Fragment>,
170+
<Fragment>
171+
<Fragment>
172+
<Foo text="double-nested" />
173+
</Fragment>
174+
</Fragment>,
175+
<div>
176+
<Foo text="inside-div" />
177+
</div>,
178+
<Foo text="direct-child" />,
179+
];
180+
181+
const found = findChildren(children, 'isFoo');
182+
expect(found).toHaveLength(1);
183+
expect(found[0].props.text).toBe('direct-child');
184+
});
185+
});
186+
});

0 commit comments

Comments
 (0)