Skip to content

Commit a3f05ed

Browse files
committed
feat: add primitives module collection subpkg
1 parent 99a46a6 commit a3f05ed

File tree

6 files changed

+994
-0
lines changed

6 files changed

+994
-0
lines changed

primitives/collection/package.json

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
{
2+
"name": "soybean-react-ui/collection",
3+
"version": "1.1.7",
4+
"license": "MIT",
5+
"homepage": "https://radix-ui.com/primitives",
6+
"repository": {
7+
"type": "git",
8+
"url": "git+https://github.com/radix-ui/primitives.git"
9+
},
10+
"bugs": {
11+
"url": "https://github.com/radix-ui/primitives/issues"
12+
},
13+
"publishConfig": {
14+
"main": "./dist/index.js",
15+
"module": "./dist/index.mjs",
16+
"types": "./dist/index.d.ts",
17+
"exports": {
18+
".": {
19+
"import": {
20+
"types": "./dist/index.d.mts",
21+
"default": "./dist/index.mjs"
22+
},
23+
"require": {
24+
"types": "./dist/index.d.ts",
25+
"default": "./dist/index.js"
26+
}
27+
}
28+
}
29+
},
30+
"sideEffects": false,
31+
"main": "./src/index.ts",
32+
"module": "./src/index.ts",
33+
"files": ["dist", "README.md"],
34+
"scripts": {
35+
"build": "radix-build",
36+
"clean": "rm -rf dist",
37+
"lint": "eslint --max-warnings 0 src",
38+
"typecheck": "tsc --noEmit"
39+
},
40+
"peerDependencies": {
41+
"@types/react": "*",
42+
"@types/react-dom": "*",
43+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
44+
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
45+
},
46+
"peerDependenciesMeta": {
47+
"@types/react": {
48+
"optional": true
49+
},
50+
"@types/react-dom": {
51+
"optional": true
52+
}
53+
},
54+
"dependencies": {
55+
"soybean-react-ui/compose-refs": "workspace:*",
56+
"soybean-react-ui/context": "workspace:*",
57+
"soybean-react-ui/primitive": "workspace:*",
58+
"soybean-react-ui/slot": "workspace:*"
59+
},
60+
"devDependencies": {
61+
"@types/react": "19.0.7",
62+
"@types/react-dom": "19.0.3",
63+
"eslint": "9.18.0",
64+
"react": "19.1.0",
65+
"react-dom": "19.1.0",
66+
"typescript": "5.7.3"
67+
},
68+
"source": "./src/index.ts"
69+
}
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import React from 'react';
2+
import { useComposedRefs } from 'soybean-react-ui/compose-refs';
3+
import { createContextScope } from 'soybean-react-ui/context';
4+
import { type Slot, createSlot } from 'soybean-react-ui/slot';
5+
6+
type SlotProps = React.ComponentPropsWithoutRef<typeof Slot>;
7+
type CollectionElement = HTMLElement;
8+
interface CollectionProps extends SlotProps {
9+
scope: any;
10+
}
11+
12+
// We have resorted to returning slots directly rather than exposing primitives that can then
13+
// be slotted like `<CollectionItem as={Slot}>…</CollectionItem>`.
14+
// This is because we encountered issues with generic types that cannot be statically analysed
15+
// due to creating them dynamically via createCollection.
16+
17+
function createCollection<ItemElement extends HTMLElement, ItemData = Record<string, unknown>>(name: string) {
18+
/* -----------------------------------------------------------------------------------------------
19+
* CollectionProvider
20+
* --------------------------------------------------------------------------------------------- */
21+
22+
const PROVIDER_NAME = `${name}CollectionProvider`;
23+
24+
const [createCollectionContext, createCollectionScope] = createContextScope(PROVIDER_NAME);
25+
26+
type ContextValue = {
27+
collectionRef: React.RefObject<CollectionElement | null>;
28+
itemMap: Map<React.RefObject<ItemElement | null>, { ref: React.RefObject<ItemElement | null> } & ItemData>;
29+
};
30+
31+
const [CollectionProviderImpl, useCollectionContext] = createCollectionContext<ContextValue>(PROVIDER_NAME, {
32+
collectionRef: { current: null },
33+
itemMap: new Map()
34+
});
35+
36+
const CollectionProvider: React.FC<{ children?: React.ReactNode; scope: any }> = props => {
37+
const { children, scope } = props;
38+
39+
const ref = React.useRef<CollectionElement>(null);
40+
41+
const itemMap = React.useRef<ContextValue['itemMap']>(new Map()).current;
42+
return (
43+
<CollectionProviderImpl
44+
collectionRef={ref}
45+
itemMap={itemMap}
46+
scope={scope}
47+
>
48+
{children}
49+
</CollectionProviderImpl>
50+
);
51+
};
52+
53+
CollectionProvider.displayName = PROVIDER_NAME;
54+
55+
/* -----------------------------------------------------------------------------------------------
56+
* CollectionSlot
57+
* --------------------------------------------------------------------------------------------- */
58+
59+
const COLLECTION_SLOT_NAME = `${name}CollectionSlot`;
60+
61+
const CollectionSlotImpl = createSlot(COLLECTION_SLOT_NAME);
62+
const CollectionSlot = React.forwardRef<CollectionElement, CollectionProps>((props, forwardedRef) => {
63+
const { children, scope } = props;
64+
const context = useCollectionContext(COLLECTION_SLOT_NAME, scope);
65+
const composedRefs = useComposedRefs(forwardedRef, context.collectionRef);
66+
return <CollectionSlotImpl ref={composedRefs}>{children}</CollectionSlotImpl>;
67+
});
68+
69+
CollectionSlot.displayName = COLLECTION_SLOT_NAME;
70+
71+
/* -----------------------------------------------------------------------------------------------
72+
* CollectionItem
73+
* --------------------------------------------------------------------------------------------- */
74+
75+
const ITEM_SLOT_NAME = `${name}CollectionItemSlot`;
76+
const ITEM_DATA_ATTR = 'data-soybean-collection-item';
77+
78+
type CollectionItemSlotProps = ItemData & {
79+
children: React.ReactNode;
80+
scope: any;
81+
};
82+
83+
const CollectionItemSlotImpl = createSlot(ITEM_SLOT_NAME);
84+
const CollectionItemSlot = React.forwardRef<ItemElement, CollectionItemSlotProps>((props, forwardedRef) => {
85+
const { children, scope, ...itemData } = props;
86+
const ref = React.useRef<ItemElement>(null);
87+
const composedRefs = useComposedRefs(forwardedRef, ref);
88+
const context = useCollectionContext(ITEM_SLOT_NAME, scope);
89+
90+
React.useEffect(() => {
91+
context.itemMap.set(ref, { ref, ...(itemData as unknown as ItemData) });
92+
return () => {
93+
context.itemMap.delete(ref);
94+
};
95+
});
96+
97+
return (
98+
<CollectionItemSlotImpl
99+
{...{ [ITEM_DATA_ATTR]: '' }}
100+
ref={composedRefs}
101+
>
102+
{children}
103+
</CollectionItemSlotImpl>
104+
);
105+
});
106+
107+
CollectionItemSlot.displayName = ITEM_SLOT_NAME;
108+
109+
/* -----------------------------------------------------------------------------------------------
110+
* useCollection
111+
* --------------------------------------------------------------------------------------------- */
112+
113+
function useCollection(scope: any) {
114+
const context = useCollectionContext(`${name}CollectionConsumer`, scope);
115+
116+
const getItems = React.useCallback(() => {
117+
const collectionNode = context.collectionRef.current;
118+
if (!collectionNode) return [];
119+
const orderedNodes = Array.from(collectionNode.querySelectorAll(`[${ITEM_DATA_ATTR}]`));
120+
const items = Array.from(context.itemMap.values());
121+
const orderedItems = items.sort(
122+
(a, b) => orderedNodes.indexOf(a.ref.current!) - orderedNodes.indexOf(b.ref.current!)
123+
);
124+
return orderedItems;
125+
}, [context.collectionRef, context.itemMap]);
126+
127+
return getItems;
128+
}
129+
130+
return [
131+
{ ItemSlot: CollectionItemSlot, Provider: CollectionProvider, Slot: CollectionSlot },
132+
useCollection,
133+
createCollectionScope
134+
] as const;
135+
}
136+
137+
export { createCollection };
138+
export type { CollectionProps };

0 commit comments

Comments
 (0)