@storybook/react-native-ui-lite 10.2.1 → 10.2.2-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/Tree.tsx CHANGED
@@ -9,8 +9,11 @@ import {
9
9
  Item,
10
10
  useExpanded,
11
11
  } from '@storybook/react-native-ui-common';
12
- import React, { useCallback, useMemo, useRef } from 'react';
13
- import { View } from 'react-native';
12
+ import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
13
+ import { View, ViewStyle } from 'react-native';
14
+ import { LegendList, LegendListRef, LegendListRenderItemProps } from '@legendapp/list';
15
+ import { useSafeAreaInsets } from 'react-native-safe-area-context';
16
+ import { useSelectedNode } from './SelectedNodeProvider';
14
17
  import type {
15
18
  ComponentEntry,
16
19
  GroupEntry,
@@ -18,7 +21,6 @@ import type {
18
21
  StoriesHash,
19
22
  StoryEntry,
20
23
  } from 'storybook/manager-api';
21
- import { useSelectedNode } from './SelectedNodeProvider';
22
24
  import { ComponentNode, GroupNode, StoryNode } from './TreeNode';
23
25
  import { CollapseAllIcon, CollapseIcon, ExpandAllIcon } from './icon/iconDataUris';
24
26
 
@@ -39,14 +41,15 @@ interface NodeProps {
39
41
  }
40
42
 
41
43
  const TextItem = styled.Text(({ theme }) => ({
44
+ fontSize: theme.typography.size.s2 + 1,
42
45
  color: theme.color.defaultText,
43
46
  }));
44
47
 
45
48
  export const Node = React.memo<NodeProps>(function Node({
46
49
  item,
47
50
  refId,
48
- isOrphan,
49
- isDisplayed,
51
+ isOrphan: _isOrphan,
52
+ isDisplayed: _isDisplayed,
50
53
  isSelected,
51
54
  isFullyExpanded,
52
55
  color: _2,
@@ -55,32 +58,16 @@ export const Node = React.memo<NodeProps>(function Node({
55
58
  setExpanded,
56
59
  onSelectStoryId,
57
60
  }) {
58
- const { setNodeRef } = useSelectedNode();
59
-
60
- const setRef = useCallback(
61
- (node: View | null) => {
62
- if (isSelected && node) {
63
- setNodeRef(node);
64
- }
65
- },
66
- [isSelected, setNodeRef]
67
- );
68
-
69
- if (!isDisplayed) {
70
- return null;
71
- }
72
-
73
61
  const id = createId(item.id, refId);
74
62
 
75
63
  if (item.type === 'story') {
76
64
  return (
77
65
  <LeafNodeStyleWrapper>
78
66
  <StoryNode
79
- ref={setRef}
80
67
  selected={isSelected}
81
68
  key={id}
82
69
  id={id}
83
- depth={isOrphan ? item.depth : item.depth - 1}
70
+ depth={item.depth}
84
71
  onPress={() => {
85
72
  onSelectStoryId(item.id);
86
73
  }}
@@ -96,6 +83,7 @@ export const Node = React.memo<NodeProps>(function Node({
96
83
  <RootNode key={id} id={id}>
97
84
  <CollapseButton
98
85
  data-action="collapse-root"
86
+ hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
99
87
  onPress={(event) => {
100
88
  event.preventDefault();
101
89
  setExpanded({ ids: [item.id], value: !isExpanded });
@@ -110,6 +98,7 @@ export const Node = React.memo<NodeProps>(function Node({
110
98
  aria-label={isFullyExpanded ? 'Expand' : 'Collapse'}
111
99
  data-action="expand-all"
112
100
  data-expanded={isFullyExpanded}
101
+ hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
113
102
  onPress={(event) => {
114
103
  event.preventDefault();
115
104
  setFullyExpanded();
@@ -130,7 +119,7 @@ export const Node = React.memo<NodeProps>(function Node({
130
119
  id={id}
131
120
  aria-controls={item.children && item.children[0]}
132
121
  aria-expanded={isExpanded}
133
- depth={isOrphan ? item.depth : item.depth - 1}
122
+ depth={item.depth}
134
123
  isComponent={item.type === 'component'}
135
124
  isExpandable={item.children && item.children.length > 0}
136
125
  isExpanded={isExpanded}
@@ -156,7 +145,7 @@ export const LeafNodeStyleWrapper = styled.View(({ theme }) => ({
156
145
  paddingRight: 20,
157
146
  color: theme.color.defaultText,
158
147
  backgroundColor: 'transparent',
159
- minHeight: 28,
148
+ minHeight: 34,
160
149
  borderRadius: 4,
161
150
  }));
162
151
 
@@ -167,7 +156,7 @@ export const RootNode = styled.View(() => ({
167
156
  justifyContent: 'space-between',
168
157
  marginTop: 16,
169
158
  marginBottom: 4,
170
- minHeight: 28,
159
+ minHeight: 34,
171
160
  }));
172
161
 
173
162
  export const RootNodeText = styled.Text(({ theme }) => ({
@@ -180,17 +169,46 @@ export const RootNodeText = styled.Text(({ theme }) => ({
180
169
  }));
181
170
 
182
171
  const CollapseButton = styled.TouchableOpacity(() => ({
172
+ flex: 1,
183
173
  display: 'flex',
184
174
  flexDirection: 'row',
185
- paddingVertical: 0,
186
175
  paddingHorizontal: 8,
176
+ paddingTop: 8,
177
+ paddingBottom: 7,
187
178
  borderRadius: 4,
188
179
  gap: 6,
189
180
  alignItems: 'center',
190
181
  cursor: 'pointer',
191
- height: 28,
182
+ minHeight: 34,
192
183
  }));
193
184
 
185
+ const flexStyle: ViewStyle = { flex: 1 };
186
+
187
+ // getEstimatedItemSize provides item size estimates for LegendList
188
+ // Root items have marginTop (16) + marginBottom (4) + minHeight (28) = 48px
189
+ // All other items have minHeight = 28px
190
+ const ITEM_HEIGHT = 34;
191
+ const ROOT_ITEM_HEIGHT = 54; // 34 + 16 (marginTop) + 4 (marginBottom)
192
+
193
+ const getEstimatedItemSize = (
194
+ item: {
195
+ itemId: string;
196
+ item: {
197
+ type: 'root' | 'component' | 'story' | 'docs';
198
+ id: string;
199
+ name: string;
200
+ children: string[];
201
+ parent: string | null;
202
+ depth: number;
203
+ };
204
+ isRoot: boolean;
205
+ isOrphan: boolean;
206
+ },
207
+ _index: number
208
+ ) => {
209
+ return item?.isRoot ? ROOT_ITEM_HEIGHT : ITEM_HEIGHT;
210
+ };
211
+
194
212
  export const Tree = React.memo<{
195
213
  isBrowsing: boolean;
196
214
  isMain: boolean;
@@ -201,8 +219,11 @@ export const Tree = React.memo<{
201
219
  selectedStoryId: string | null;
202
220
  onSelectStoryId: (storyId: string) => void;
203
221
  }>(function Tree({ isMain, refId, data, status, docsMode, selectedStoryId, onSelectStoryId }) {
204
- const containerRef = useRef<View>(null);
222
+ const { registerCallback } = useSelectedNode();
223
+ const [idToScrolllOnMount, setIdToScrolllOnMount] = useState<string | null>(null);
205
224
 
225
+ const insets = useSafeAreaInsets();
226
+ const listRef = useRef<LegendListRef | null>(null);
206
227
  // Find top-level nodes and group them so we can hoist any orphans and expand any roots.
207
228
  const [rootIds, orphanIds, initialExpanded] = useMemo(
208
229
  () =>
@@ -256,9 +277,8 @@ export const Tree = React.memo<{
256
277
  // Omit single-story components from the list of nodes.
257
278
  const collapsedItems = useMemo(
258
279
  () => Object.keys(data).filter((id) => !singleStoryComponentIds.includes(id)),
259
- // eslint-disable-next-line react-compiler/react-compiler
260
- // eslint-disable-next-line react-hooks/exhaustive-deps
261
- [singleStoryComponentIds]
280
+
281
+ [singleStoryComponentIds, data]
262
282
  );
263
283
 
264
284
  // Rewrite the dataset to place the child story in place of the component.
@@ -282,16 +302,7 @@ export const Tree = React.memo<{
282
302
  },
283
303
  { ...data }
284
304
  );
285
- // eslint-disable-next-line react-compiler/react-compiler
286
- // eslint-disable-next-line react-hooks/exhaustive-deps
287
- }, [data]);
288
-
289
- const ancestry = useMemo(() => {
290
- return collapsedItems.reduce(
291
- (acc, id) => Object.assign(acc, { [id]: getAncestorIds(collapsedData, id) }),
292
- {} as { [key: string]: string[] }
293
- );
294
- }, [collapsedItems, collapsedData]);
305
+ }, [data, singleStoryComponentIds]);
295
306
 
296
307
  // Track expanded nodes, keep it in sync with props and enable keyboard shortcuts.
297
308
  const [expanded, setExpanded] = useExpanded({
@@ -303,14 +314,74 @@ export const Tree = React.memo<{
303
314
  onSelectStoryId,
304
315
  });
305
316
 
306
- const treeItems = useMemo(() => {
307
- return collapsedItems.map((itemId) => {
317
+ // Optimized: Build a simple parent map instead of full ancestry for each item
318
+ // This is much faster than calling getAncestorIds for every item
319
+ const parentMap = useMemo(() => {
320
+ const map: Record<string, string | null> = {};
321
+ collapsedItems.forEach((id) => {
322
+ const item = collapsedData[id];
323
+ map[id] = ('parent' in item && item.parent) || null;
324
+ });
325
+ return map;
326
+ }, [collapsedItems, collapsedData]);
327
+
328
+ // Helper function to check if all ancestors are expanded (inline traversal)
329
+ const isItemVisible = useCallback(
330
+ (itemId: string) => {
308
331
  const item = collapsedData[itemId];
332
+ if (item.type === 'root') return true;
333
+ if (!('parent' in item) || !item.parent) return true;
334
+
335
+ // Traverse up the parent chain checking if each is expanded
336
+ let currentId: string | null = item.parent;
337
+ while (currentId) {
338
+ if (!expanded[currentId]) return false;
339
+ currentId = parentMap[currentId];
340
+ }
341
+ return true;
342
+ },
343
+ [collapsedData, expanded, parentMap]
344
+ );
345
+
346
+ // Convert to data array for LegendList, filtering only displayed items
347
+ const treeData = useMemo(() => {
348
+ return collapsedItems
349
+ .map((itemId) => {
350
+ const item = collapsedData[itemId];
351
+
352
+ // Use optimized visibility check
353
+ if (!isItemVisible(itemId)) {
354
+ return null;
355
+ }
356
+
357
+ if (item.type === 'root') {
358
+ const descendants = expandableDescendants[item.id];
359
+ const isFullyExpanded = descendants.every((d: string) => expanded[d]);
360
+ return {
361
+ itemId,
362
+ item,
363
+ isRoot: true,
364
+ isFullyExpanded,
365
+ descendants,
366
+ };
367
+ }
368
+
369
+ return {
370
+ itemId,
371
+ item,
372
+ isRoot: false,
373
+ isOrphan: orphanIds.some((oid) => itemId === oid || itemId.startsWith(`${oid}-`)),
374
+ };
375
+ })
376
+ .filter(Boolean);
377
+ }, [collapsedData, collapsedItems, expandableDescendants, expanded, isItemVisible, orphanIds]);
378
+
379
+ const renderItem = useCallback(
380
+ ({ item: treeItem }: LegendListRenderItemProps<(typeof treeData)[number]>) => {
381
+ const { itemId, item, isRoot } = treeItem;
309
382
  const id = createId(itemId, refId);
310
383
 
311
- if (item.type === 'root') {
312
- const descendants = expandableDescendants[item.id];
313
- const isFullyExpanded = descendants.every((d: string) => expanded[d]);
384
+ if (isRoot) {
314
385
  return (
315
386
  <Root
316
387
  key={id}
@@ -321,8 +392,8 @@ export const Tree = React.memo<{
321
392
  isSelected={selectedStoryId === itemId}
322
393
  isExpanded={!!expanded[itemId]}
323
394
  setExpanded={setExpanded}
324
- isFullyExpanded={isFullyExpanded}
325
- expandableDescendants={descendants}
395
+ isFullyExpanded={treeItem.isFullyExpanded}
396
+ expandableDescendants={treeItem.descendants}
326
397
  onSelectStoryId={onSelectStoryId}
327
398
  docsMode={false}
328
399
  color=""
@@ -331,8 +402,6 @@ export const Tree = React.memo<{
331
402
  );
332
403
  }
333
404
 
334
- const isDisplayed = !item.parent || ancestry[itemId].every((a: string) => expanded[a]);
335
-
336
405
  return (
337
406
  <Node
338
407
  key={id}
@@ -341,41 +410,84 @@ export const Tree = React.memo<{
341
410
  refId={refId}
342
411
  color={null}
343
412
  docsMode={docsMode}
344
- isOrphan={orphanIds.some((oid) => itemId === oid || itemId.startsWith(`${oid}-`))}
345
- isDisplayed={isDisplayed}
413
+ isOrphan={treeItem.isOrphan}
414
+ isDisplayed
346
415
  isSelected={selectedStoryId === itemId}
347
416
  isExpanded={!!expanded[itemId]}
348
417
  setExpanded={setExpanded}
349
418
  onSelectStoryId={onSelectStoryId}
350
419
  />
351
420
  );
421
+ },
422
+ [docsMode, expanded, onSelectStoryId, refId, selectedStoryId, setExpanded, status]
423
+ );
424
+
425
+ const keyExtractor = useCallback(
426
+ (item: any) => {
427
+ return createId(item.itemId, refId);
428
+ },
429
+ [refId]
430
+ );
431
+
432
+ const contentContainerStyle = useMemo(
433
+ () => ({
434
+ marginTop: isMain && orphanIds.length > 0 ? 20 : 0,
435
+ paddingBottom: insets.bottom + 20,
436
+ paddingLeft: 6,
437
+ }),
438
+ [isMain, orphanIds.length, insets.bottom]
439
+ );
440
+
441
+ // so we can call the scroll to function in the search component
442
+ useLayoutEffect(() => {
443
+ registerCallback(({ id: nextId, animated }) => {
444
+ const targetId = nextId ?? selectedStoryId;
445
+
446
+ const ancestorIds = getAncestorIds(collapsedData, targetId);
447
+
448
+ setExpanded({ ids: [...ancestorIds, targetId], value: true });
449
+
450
+ setIdToScrolllOnMount(targetId);
352
451
  });
353
- }, [
354
- ancestry,
355
- collapsedData,
356
- collapsedItems,
357
- docsMode,
358
- expandableDescendants,
359
- expanded,
360
- onSelectStoryId,
361
- orphanIds,
362
- refId,
363
- selectedStoryId,
364
- setExpanded,
365
- status,
366
- ]);
452
+ }, [collapsedData, registerCallback, selectedStoryId, setExpanded]);
453
+
454
+ // a workaround for the fact that we need to expand and scroll to an item that is not in the tree yet
455
+ useEffect(() => {
456
+ if (idToScrolllOnMount) {
457
+ const index = treeData.findIndex((item) => {
458
+ return item.itemId === idToScrolllOnMount;
459
+ });
460
+
461
+ if (index >= 0) {
462
+ listRef.current?.scrollToIndex({
463
+ index,
464
+ animated: false,
465
+ viewPosition: 0.5,
466
+ viewOffset: 100,
467
+ });
468
+
469
+ setIdToScrolllOnMount(null);
470
+ }
471
+ }
472
+ }, [idToScrolllOnMount, treeData]);
473
+
367
474
  return (
368
- <Container ref={containerRef} hasOrphans={isMain && orphanIds.length > 0}>
369
- {treeItems}
370
- </Container>
475
+ <View style={flexStyle}>
476
+ <LegendList
477
+ ref={listRef}
478
+ style={flexStyle}
479
+ data={treeData}
480
+ renderItem={renderItem}
481
+ keyExtractor={keyExtractor}
482
+ contentContainerStyle={contentContainerStyle}
483
+ getFixedItemSize={getEstimatedItemSize}
484
+ keyboardShouldPersistTaps="handled"
485
+ recycleItems
486
+ />
487
+ </View>
371
488
  );
372
489
  });
373
490
 
374
- const Container = styled.View<{ hasOrphans: boolean }>((props) => ({
375
- marginTop: props.hasOrphans ? 20 : 0,
376
- marginBottom: 20,
377
- }));
378
-
379
491
  const Root = React.memo<NodeProps & { expandableDescendants: string[] }>(function Root({
380
492
  setExpanded,
381
493
  isFullyExpanded,
package/src/TreeNode.tsx CHANGED
@@ -13,7 +13,7 @@ export interface NodeProps {
13
13
 
14
14
  const BranchNodeText = styled.Text<{ isSelected?: boolean }>(({ theme }) => ({
15
15
  textAlign: 'left',
16
- fontSize: theme.typography.size.s2,
16
+ fontSize: theme.typography.size.s2 + 1,
17
17
  flexShrink: 1,
18
18
  color: theme.color.defaultText,
19
19
  }));
@@ -30,16 +30,15 @@ const BranchNode = styled.TouchableOpacity<{
30
30
  cursor: 'pointer',
31
31
  display: 'flex',
32
32
  flexDirection: 'row',
33
- alignItems: 'flex-start',
34
- alignSelf: 'flex-start',
33
+ alignItems: 'center',
35
34
  paddingLeft: (isExpandable ? 8 : 22) + depth * 18,
36
35
 
37
36
  backgroundColor: 'transparent',
38
- minHeight: 28,
37
+ minHeight: 34,
39
38
  borderRadius: 4,
40
39
  gap: 6,
41
- paddingTop: 5,
42
- paddingBottom: 4,
40
+ paddingTop: 8,
41
+ paddingBottom: 7,
43
42
 
44
43
  // will this actually do anything?
45
44
  '&:hover, &:focus': {
@@ -50,27 +49,26 @@ const BranchNode = styled.TouchableOpacity<{
50
49
 
51
50
  const LeafNode = styled.TouchableOpacity<{ depth?: number; selected?: boolean }>(
52
51
  ({ depth = 0, selected, theme }) => ({
53
- alignSelf: 'flex-start',
54
52
  cursor: 'pointer',
55
53
  color: 'inherit',
56
54
  display: 'flex',
57
55
  gap: 6,
58
56
  flexDirection: 'row',
59
- alignItems: 'flex-start',
57
+ alignItems: 'center',
60
58
  paddingLeft: 22 + depth * 18,
61
- paddingTop: 5,
62
- paddingBottom: 4,
59
+ paddingTop: 8,
60
+ paddingBottom: 7,
63
61
  backgroundColor: selected ? theme.color.secondary : undefined,
64
62
  // not sure 👇
65
63
  width: '100%',
66
64
  borderRadius: 4,
67
65
  paddingRight: 20,
68
- minHeight: 28,
66
+ minHeight: 34,
69
67
  })
70
68
  );
71
69
 
72
70
  const LeafNodeText = styled.Text<{ depth?: number; selected?: boolean }>(({ theme, selected }) => ({
73
- fontSize: theme.typography.size.s2,
71
+ fontSize: theme.typography.size.s2 + 1,
74
72
  flexShrink: 1,
75
73
  fontWeight: selected ? 'bold' : 'normal',
76
74
  color: selected ? theme.color.lightest : theme.color.defaultText,
@@ -81,7 +79,6 @@ const Wrapper = styled.View({
81
79
  flexDirection: 'row',
82
80
  alignItems: 'center',
83
81
  gap: 6,
84
- marginTop: 2,
85
82
  });
86
83
 
87
84
  export const GroupNode: FC<
@@ -104,7 +101,7 @@ export const GroupNode: FC<
104
101
  {isExpandable && <CollapseIcon isExpanded={isExpanded} />}
105
102
  <GroupIcon width={14} height={14} color={color} />
106
103
  </Wrapper>
107
- <BranchNodeText>{children}</BranchNodeText>
104
+ <BranchNodeText numberOfLines={1}>{children}</BranchNodeText>
108
105
  </BranchNode>
109
106
  );
110
107
  });
@@ -124,7 +121,7 @@ export const ComponentNode: FC<ComponentProps<typeof BranchNode>> = React.memo(
124
121
  {isExpandable && <CollapseIcon isExpanded={isExpanded} />}
125
122
  <ComponentIcon width={12} height={12} color={color} />
126
123
  </Wrapper>
127
- <BranchNodeText>{children}</BranchNodeText>
124
+ <BranchNodeText numberOfLines={1}>{children}</BranchNodeText>
128
125
  </BranchNode>
129
126
  );
130
127
  }
@@ -146,7 +143,9 @@ export const StoryNode = React.memo(
146
143
  <Wrapper key={`story-${props.id}-${color}`}>
147
144
  <StoryIcon width={14} height={14} color={color} />
148
145
  </Wrapper>
149
- <LeafNodeText selected={props.selected}>{children}</LeafNodeText>
146
+ <LeafNodeText selected={props.selected} numberOfLines={1}>
147
+ {children}
148
+ </LeafNodeText>
150
149
  </LeafNode>
151
150
  );
152
151
  })