react-slot-utils 0.0.1 → 1.0.0
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/AGENTS.md +163 -0
- package/LICENSE +7 -0
- package/README.md +150 -6
- package/biome.json +37 -0
- package/package.json +46 -5
- package/src/index.ts +6 -1
- package/src/slot/index.tsx +76 -0
- package/src/slot/utils.ts +83 -0
- package/src/utils/classname.tsx +26 -0
- package/src/utils/common.ts +12 -0
- package/src/utils/prop.tsx +123 -0
- package/tsdown.config.ts +16 -0
package/AGENTS.md
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# AGENTS.md
|
|
2
|
+
|
|
3
|
+
Agent guide for `react-slot-utils`.
|
|
4
|
+
This is a TypeScript + React utility library for slot composition and prop merging.
|
|
5
|
+
|
|
6
|
+
## Instruction files (Cursor/Copilot)
|
|
7
|
+
|
|
8
|
+
Checked paths:
|
|
9
|
+
- `.cursorrules`: not found
|
|
10
|
+
- `.cursor/rules/`: not found
|
|
11
|
+
- `.github/copilot-instructions.md`: not found
|
|
12
|
+
|
|
13
|
+
If these files are added later, they override this guide.
|
|
14
|
+
|
|
15
|
+
## Project snapshot
|
|
16
|
+
|
|
17
|
+
- Runtime/package manager: Bun (`bun.lock` exists)
|
|
18
|
+
- Module mode: ESM (`"type": "module"`)
|
|
19
|
+
- Language: TypeScript + TSX
|
|
20
|
+
- Lint/format: Biome (`biome.json`)
|
|
21
|
+
- Build tool: tsdown (`tsdown.config.ts`)
|
|
22
|
+
- TS config: `tsconfig.json` (strict)
|
|
23
|
+
- Public entry: `src/index.ts`
|
|
24
|
+
- Output: `dist/`
|
|
25
|
+
|
|
26
|
+
## Setup and baseline commands
|
|
27
|
+
|
|
28
|
+
- Install deps: `bun install`
|
|
29
|
+
- Typecheck: `bun run typecheck`
|
|
30
|
+
- Lint/format check: `bun run check`
|
|
31
|
+
- Lint/format with autofix: `bun run check:write`
|
|
32
|
+
- Full build: `bun run build`
|
|
33
|
+
|
|
34
|
+
## Build/lint/test command reference
|
|
35
|
+
|
|
36
|
+
From `package.json` scripts:
|
|
37
|
+
- `bun run typecheck` -> `tsc --noEmit`
|
|
38
|
+
- `bun run check` -> `biome check`
|
|
39
|
+
- `bun run check:write` -> `biome check --write`
|
|
40
|
+
- `bun run build` -> `bun run typecheck && bun run check:write && tsdown`
|
|
41
|
+
- `bun run prepublishOnly` -> `bun run build`
|
|
42
|
+
|
|
43
|
+
Behavior notes:
|
|
44
|
+
- `bun run build` is mutating because it runs `check:write`.
|
|
45
|
+
- During iteration, use `bun run check` when you want non-mutating validation.
|
|
46
|
+
|
|
47
|
+
## Single-file and single-test commands
|
|
48
|
+
|
|
49
|
+
Current repo state:
|
|
50
|
+
- No test files found.
|
|
51
|
+
- No `test` script in `package.json`.
|
|
52
|
+
|
|
53
|
+
When tests are introduced with Bun:
|
|
54
|
+
- Run all tests: `bun test`
|
|
55
|
+
- Run one test file: `bun test ./path/to/file.test.ts`
|
|
56
|
+
- Run by path substring: `bun test utils`
|
|
57
|
+
- Run one case: use `test.only(...)` or `describe.only(...)`
|
|
58
|
+
|
|
59
|
+
Single-file commands available today:
|
|
60
|
+
- `biome check src/path/to/file.ts`
|
|
61
|
+
- `biome check --write src/path/to/file.ts`
|
|
62
|
+
- `biome lint --write src/path/to/file.ts`
|
|
63
|
+
- `biome format --write src/path/to/file.ts`
|
|
64
|
+
|
|
65
|
+
## Repository structure and exports
|
|
66
|
+
|
|
67
|
+
- Keep public API wired through `src/index.ts`.
|
|
68
|
+
- Prefer named exports.
|
|
69
|
+
- Current barrel exports:
|
|
70
|
+
- `./slot`
|
|
71
|
+
- `./slot/utils`
|
|
72
|
+
- `./utils/classname`
|
|
73
|
+
- `./utils/common`
|
|
74
|
+
- `./utils/prop`
|
|
75
|
+
|
|
76
|
+
## Formatting and imports
|
|
77
|
+
|
|
78
|
+
From `biome.json`:
|
|
79
|
+
- Formatter enabled
|
|
80
|
+
- Indent style: spaces (2-space indentation used in source)
|
|
81
|
+
- JS/TS quote style: single quotes
|
|
82
|
+
- JSX quote style: single quotes
|
|
83
|
+
- Linter enabled with recommended rules
|
|
84
|
+
- `correctness.noUnusedImports` is `error`
|
|
85
|
+
- Organize imports action is on
|
|
86
|
+
|
|
87
|
+
Agent rules:
|
|
88
|
+
- Remove unused imports immediately.
|
|
89
|
+
- Let Biome manage import organization/order.
|
|
90
|
+
- Do not hand-format against Biome output.
|
|
91
|
+
|
|
92
|
+
## TypeScript conventions
|
|
93
|
+
|
|
94
|
+
From `tsconfig.json`:
|
|
95
|
+
- `strict: true`
|
|
96
|
+
- `noUncheckedIndexedAccess: true`
|
|
97
|
+
- `moduleResolution: bundler`
|
|
98
|
+
- `verbatimModuleSyntax: true`
|
|
99
|
+
- `jsx: react-jsx`
|
|
100
|
+
|
|
101
|
+
Type safety rules:
|
|
102
|
+
- Use `import type` for type-only imports.
|
|
103
|
+
- Keep runtime imports separate when practical.
|
|
104
|
+
- Prefer `unknown` + type guards over broad casts.
|
|
105
|
+
- Avoid `as any`, `@ts-ignore`, and `@ts-expect-error`.
|
|
106
|
+
- Use explicit generics where helper typing is non-trivial.
|
|
107
|
+
|
|
108
|
+
## Naming conventions
|
|
109
|
+
|
|
110
|
+
Observed in `src/`:
|
|
111
|
+
- Components/interfaces: PascalCase (`Slot`, `Slottable`, `SlotProps`)
|
|
112
|
+
- Functions/utilities: camelCase (`flattenChildren`, `mergeProps`)
|
|
113
|
+
- Predicates/type guards: `is*` (`isRef`, `isStyleObject`, `isSlottable`)
|
|
114
|
+
- Higher-order helpers: `with*` (`withDefaultProps`, `withDisplayName`)
|
|
115
|
+
- Type aliases: descriptive PascalCase (`ClassNameDefaultize`)
|
|
116
|
+
|
|
117
|
+
## React and utility behavior
|
|
118
|
+
|
|
119
|
+
- Use function components; no class components are present.
|
|
120
|
+
- Handle `children` defensively:
|
|
121
|
+
- flatten fragments before slot resolution
|
|
122
|
+
- validate with `isValidElement`
|
|
123
|
+
- return `null` for unsupported structures
|
|
124
|
+
- Keep prop-merge semantics stable:
|
|
125
|
+
- compose handlers child-first, then slot handler unless default prevented
|
|
126
|
+
- shallow-merge `style`
|
|
127
|
+
- merge class names via `cn` (`classnames`)
|
|
128
|
+
|
|
129
|
+
## Error handling expectations
|
|
130
|
+
|
|
131
|
+
- Current source avoids explicit `try/catch` and `throw`.
|
|
132
|
+
- Prefer guard clauses and deterministic returns.
|
|
133
|
+
- Keep utilities side-effect-light (no noisy logging in shared helpers).
|
|
134
|
+
- Preserve existing null-safe behavior for invalid child shapes.
|
|
135
|
+
|
|
136
|
+
## Build configuration details
|
|
137
|
+
|
|
138
|
+
From `tsdown.config.ts`:
|
|
139
|
+
- `entry: 'src/index.ts'`
|
|
140
|
+
- `target: 'ES2024'`
|
|
141
|
+
- `format: 'esm'`
|
|
142
|
+
- `dts: true`
|
|
143
|
+
- `minify: true`
|
|
144
|
+
- Externalized deps: `react`, `classnames`
|
|
145
|
+
|
|
146
|
+
If API surface changes:
|
|
147
|
+
- Update exports in `src/index.ts`.
|
|
148
|
+
- Run `bun run build` to validate JS output and declarations.
|
|
149
|
+
|
|
150
|
+
## Agent completion checklist
|
|
151
|
+
|
|
152
|
+
Before handoff/PR:
|
|
153
|
+
1. Keep changes focused (no opportunistic refactors).
|
|
154
|
+
2. Run `bun run typecheck`.
|
|
155
|
+
3. Run `bun run check`.
|
|
156
|
+
4. Run `bun run build` for release-impacting changes.
|
|
157
|
+
5. Confirm no unused imports and no export-surface regressions.
|
|
158
|
+
|
|
159
|
+
## Test strategy note
|
|
160
|
+
|
|
161
|
+
- No test harness is currently configured in-repo.
|
|
162
|
+
- If adding tests, use Bun-native `*.test.ts` / `*.spec.ts`.
|
|
163
|
+
- Add a `test` script to `package.json` in the same PR when tests are introduced.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
Copyright 2026 Taeyeong Kim
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
4
|
+
|
|
5
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
6
|
+
|
|
7
|
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,15 +1,159 @@
|
|
|
1
1
|
# react-slot-utils
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Modern, next-gen React utilities for composing slots and props.
|
|
4
|
+
Built for headless and compound component patterns with predictable prop merging.
|
|
5
|
+
|
|
6
|
+
[](https://www.npmjs.com/package/react-slot-utils)
|
|
7
|
+
[](https://github.com/tyeongkim/react-slot-utils/blob/main/LICENSE)
|
|
8
|
+
[](https://bundlephobia.com/package/react-slot-utils)
|
|
9
|
+
|
|
10
|
+
## Motivation
|
|
11
|
+
|
|
12
|
+
React components often need to support custom rendering of their internal elements. While passing a `component` prop or using a render prop works, it often leads to complex prop drilling and breaks the natural JSX structure.
|
|
13
|
+
|
|
14
|
+
The slot pattern allows you to pass a child element that "merges" with the component's internal element. This provides a clean API for users to customize the underlying DOM element or component while preserving the behavior and styling provided by the parent. This library provides the foundational utilities to implement this pattern reliably.
|
|
15
|
+
|
|
16
|
+
## Features
|
|
17
|
+
|
|
18
|
+
- **Predictable Prop Merging**: Intelligently merges class names, styles, and event handlers.
|
|
19
|
+
- **Ref Composition**: Automatically composes multiple refs into a single callback ref.
|
|
20
|
+
- **Slottable Support**: Allows wrapping specific parts of children to be used as the slot target.
|
|
21
|
+
- **Higher-Order Components**: Utilities for setting default props and class names.
|
|
22
|
+
- **Type Safe**: Built with TypeScript for excellent developer experience.
|
|
23
|
+
|
|
24
|
+
## Install
|
|
4
25
|
|
|
5
26
|
```bash
|
|
6
|
-
bun
|
|
27
|
+
bun add react-slot-utils
|
|
28
|
+
# or
|
|
29
|
+
npm install react-slot-utils
|
|
7
30
|
```
|
|
8
31
|
|
|
9
|
-
|
|
32
|
+
## Quick Start
|
|
10
33
|
|
|
11
|
-
|
|
12
|
-
|
|
34
|
+
### Basic Slot Usage
|
|
35
|
+
|
|
36
|
+
```tsx
|
|
37
|
+
import { Slot } from 'react-slot-utils';
|
|
38
|
+
|
|
39
|
+
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
|
40
|
+
asChild?: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function Button({ asChild, ...props }: ButtonProps) {
|
|
44
|
+
const Component = asChild ? Slot : 'button';
|
|
45
|
+
return <Component {...props} className="btn-base" />;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Usage: renders as an <a> tag but with "btn-base" class and button behaviors
|
|
49
|
+
export const App = () => (
|
|
50
|
+
<Button asChild>
|
|
51
|
+
<a href="/home">Home</a>
|
|
52
|
+
</Button>
|
|
53
|
+
);
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### Using Slottable
|
|
57
|
+
|
|
58
|
+
```tsx
|
|
59
|
+
import { Slot, Slottable } from 'react-slot-utils';
|
|
60
|
+
|
|
61
|
+
function Card({ children, ...props }) {
|
|
62
|
+
return (
|
|
63
|
+
<Slot {...props}>
|
|
64
|
+
<div className="card-wrapper">
|
|
65
|
+
<Slottable>{children}</Slottable>
|
|
66
|
+
<span className="decoration">★</span>
|
|
67
|
+
</div>
|
|
68
|
+
</Slot>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## API Reference
|
|
74
|
+
|
|
75
|
+
### Components
|
|
76
|
+
|
|
77
|
+
#### `Slot`
|
|
78
|
+
A component that renders its child and merges its own props onto that child.
|
|
79
|
+
- If a `Slottable` child is found, it uses the `Slottable`'s children as the primary element.
|
|
80
|
+
- Merges `className` using `classnames`.
|
|
81
|
+
- Merges `style` objects (shallow merge).
|
|
82
|
+
- Composes event handlers (child handler runs first).
|
|
83
|
+
- Composes `ref`s.
|
|
84
|
+
|
|
85
|
+
```tsx
|
|
86
|
+
interface SlotProps extends HTMLAttributes<HTMLElement>, PropsWithChildren {
|
|
87
|
+
ref?: Ref<HTMLElement>;
|
|
88
|
+
}
|
|
13
89
|
```
|
|
14
90
|
|
|
15
|
-
|
|
91
|
+
#### `Slottable`
|
|
92
|
+
A utility component used inside `Slot` to mark which part of the children should be treated as the element to be cloned.
|
|
93
|
+
|
|
94
|
+
### Prop Utilities
|
|
95
|
+
|
|
96
|
+
#### `mergeProps(parentProps, childProps)`
|
|
97
|
+
Merges two sets of props with specific logic:
|
|
98
|
+
- **Event Handlers**: Both handlers are called. The child handler executes first. If the child handler calls `event.preventDefault()`, the slot handler is not called.
|
|
99
|
+
- **Styles**: Shallow merges style objects. Slot styles are spread first, child styles override.
|
|
100
|
+
- **ClassNames**: Merges strings using the `cn` utility.
|
|
101
|
+
- **Other Props**: Child props override slot props.
|
|
102
|
+
|
|
103
|
+
#### `withDefaultProps(Component, defaultProps)`
|
|
104
|
+
A HOC that returns a new component with the specified default props applied.
|
|
105
|
+
```tsx
|
|
106
|
+
function withDefaultProps<P extends object, K extends keyof P>(
|
|
107
|
+
Component: ComponentType<Pick<P, K> & Omit<P, K>>,
|
|
108
|
+
defaultProps: Pick<P, K>,
|
|
109
|
+
): FC<Omit<P, K>>
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
#### `withGenericDefaultProps(Component, defaultProps)`
|
|
113
|
+
A version of `withDefaultProps` for components with complex generic types.
|
|
114
|
+
|
|
115
|
+
#### `renderWithAdditionalProps(element, additionalProps)`
|
|
116
|
+
Renders a React element with additional props merged in.
|
|
117
|
+
|
|
118
|
+
### ClassName Utilities
|
|
119
|
+
|
|
120
|
+
#### `withDefaultClassNames(Component, defaultClassNames)`
|
|
121
|
+
A HOC that prepends default class names to the component's `className` prop.
|
|
122
|
+
```tsx
|
|
123
|
+
function withDefaultClassNames<P extends { className?: string }>(
|
|
124
|
+
Component: ComponentType<P>,
|
|
125
|
+
defaultClassNames: string,
|
|
126
|
+
): FC<ClassNameDefaultize<P>>
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
#### `cn(...inputs)`
|
|
130
|
+
A re-export of the `classnames` utility for conditional class merging.
|
|
131
|
+
|
|
132
|
+
### Common Utilities
|
|
133
|
+
|
|
134
|
+
#### `withDisplayName(Component, name)`
|
|
135
|
+
Sets the `displayName` of a component and returns it.
|
|
136
|
+
|
|
137
|
+
### Slot Utilities
|
|
138
|
+
|
|
139
|
+
#### `composeRefs(...refs)`
|
|
140
|
+
Composes multiple React refs (function or object) into a single callback ref.
|
|
141
|
+
|
|
142
|
+
#### `flattenChildren(children)`
|
|
143
|
+
Flattens React fragments and nested arrays into a flat array of `ReactNode`.
|
|
144
|
+
|
|
145
|
+
#### `isSlottable(child)`
|
|
146
|
+
Type guard to check if a child is a `Slottable` component.
|
|
147
|
+
|
|
148
|
+
#### `isRef(value)`
|
|
149
|
+
Type guard to check if a value is a React `Ref`.
|
|
150
|
+
|
|
151
|
+
#### `isStyleObject(value)`
|
|
152
|
+
Type guard to check if a value is a `CSSProperties` object.
|
|
153
|
+
|
|
154
|
+
#### `isEventHandler(propName)`
|
|
155
|
+
Checks if a prop name starts with `on` followed by an uppercase letter.
|
|
156
|
+
|
|
157
|
+
## License
|
|
158
|
+
|
|
159
|
+
MIT © [Taeyeong Kim](https://github.com/tyeongkim)
|
package/biome.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://biomejs.dev/schemas/2.3.14/schema.json",
|
|
3
|
+
"vcs": {
|
|
4
|
+
"enabled": true,
|
|
5
|
+
"clientKind": "git",
|
|
6
|
+
"useIgnoreFile": true
|
|
7
|
+
},
|
|
8
|
+
"files": {
|
|
9
|
+
"ignoreUnknown": true
|
|
10
|
+
},
|
|
11
|
+
"assist": {
|
|
12
|
+
"actions": {
|
|
13
|
+
"source": {
|
|
14
|
+
"organizeImports": "on"
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"formatter": {
|
|
19
|
+
"enabled": true,
|
|
20
|
+
"indentStyle": "space"
|
|
21
|
+
},
|
|
22
|
+
"linter": {
|
|
23
|
+
"enabled": true,
|
|
24
|
+
"rules": {
|
|
25
|
+
"recommended": true,
|
|
26
|
+
"correctness": {
|
|
27
|
+
"noUnusedImports": "error"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"javascript": {
|
|
32
|
+
"formatter": {
|
|
33
|
+
"quoteStyle": "single",
|
|
34
|
+
"jsxQuoteStyle": "single"
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
package/package.json
CHANGED
|
@@ -1,12 +1,53 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "react-slot-utils",
|
|
3
|
-
"version": "0.0
|
|
4
|
-
"
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Modern, next-gen React utilities for composing slots and props",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "https://github.com/tyeongkim/react-slot-utils.git"
|
|
8
|
+
},
|
|
9
|
+
"keywords": [
|
|
10
|
+
"react",
|
|
11
|
+
"slot",
|
|
12
|
+
"slots",
|
|
13
|
+
"props",
|
|
14
|
+
"prop-composition",
|
|
15
|
+
"headless",
|
|
16
|
+
"compound-components",
|
|
17
|
+
"hoc",
|
|
18
|
+
"ui-architecture",
|
|
19
|
+
"design-system",
|
|
20
|
+
"next-gen"
|
|
21
|
+
],
|
|
5
22
|
"type": "module",
|
|
6
|
-
"
|
|
7
|
-
"
|
|
23
|
+
"scripts": {
|
|
24
|
+
"build": "bun run typecheck && bun run check:write && tsdown",
|
|
25
|
+
"check": "biome check",
|
|
26
|
+
"check:write": "biome check --write",
|
|
27
|
+
"typecheck": "tsc --noEmit",
|
|
28
|
+
"prepublishOnly": "bun run build"
|
|
8
29
|
},
|
|
9
30
|
"peerDependencies": {
|
|
31
|
+
"react": "^19.2.4",
|
|
32
|
+
"react-dom": "^19.2.4",
|
|
10
33
|
"typescript": "^5"
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"classnames": "^2.5.1"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@biomejs/biome": "^2.3.14",
|
|
40
|
+
"@types/bun": "latest",
|
|
41
|
+
"@types/react": "^19.2.13",
|
|
42
|
+
"tsdown": "^0.20.3"
|
|
43
|
+
},
|
|
44
|
+
"author": {
|
|
45
|
+
"name": "Taeyeong Kim",
|
|
46
|
+
"email": "contact@tyeongk.im",
|
|
47
|
+
"url": "https://github.com/tyeongkim"
|
|
48
|
+
},
|
|
49
|
+
"exports": {
|
|
50
|
+
".": "./dist/index.js",
|
|
51
|
+
"./package.json": "./package.json"
|
|
11
52
|
}
|
|
12
|
-
}
|
|
53
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { HTMLAttributes, PropsWithChildren, ReactNode, Ref } from 'react';
|
|
2
|
+
import { Children, cloneElement, isValidElement } from 'react';
|
|
3
|
+
import { mergeProps } from '../utils/prop';
|
|
4
|
+
import { composeRefs, flattenChildren, isRef, isSlottable } from './utils';
|
|
5
|
+
|
|
6
|
+
export interface SlotProps
|
|
7
|
+
extends HTMLAttributes<HTMLElement>,
|
|
8
|
+
PropsWithChildren {
|
|
9
|
+
ref?: Ref<HTMLElement>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function Slottable(props: PropsWithChildren) {
|
|
13
|
+
const { children } = props;
|
|
14
|
+
|
|
15
|
+
return children ?? null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function Slot(props: SlotProps) {
|
|
19
|
+
const { children, ref: slotRef, ...slotProps } = props;
|
|
20
|
+
const childrenArray = flattenChildren(children);
|
|
21
|
+
const slottable = childrenArray.find(isSlottable);
|
|
22
|
+
|
|
23
|
+
if (slottable) {
|
|
24
|
+
const newElement = slottable.props.children;
|
|
25
|
+
const newChildren = childrenArray.map((child) => {
|
|
26
|
+
if (child !== slottable) {
|
|
27
|
+
return child;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (Children.count(newElement) > 1) {
|
|
31
|
+
return Children.only(null);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return isValidElement<PropsWithChildren>(newElement)
|
|
35
|
+
? newElement.props.children
|
|
36
|
+
: null;
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
if (isValidElement(newElement)) {
|
|
40
|
+
return (
|
|
41
|
+
<SlotClone ref={slotRef} {...slotProps}>
|
|
42
|
+
{cloneElement(newElement, undefined, newChildren)}
|
|
43
|
+
</SlotClone>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<SlotClone ref={slotRef} {...slotProps}>
|
|
52
|
+
{children}
|
|
53
|
+
</SlotClone>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface SlotCloneProps extends HTMLAttributes<HTMLElement> {
|
|
58
|
+
children?: ReactNode;
|
|
59
|
+
ref?: Ref<HTMLElement>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function SlotClone(props: SlotCloneProps) {
|
|
63
|
+
const { children, ref: slotRef, ...slotProps } = props;
|
|
64
|
+
|
|
65
|
+
if (isValidElement<Record<string, unknown>>(children)) {
|
|
66
|
+
const childRef = isRef<HTMLElement>(children.props.ref)
|
|
67
|
+
? children.props.ref
|
|
68
|
+
: undefined;
|
|
69
|
+
const mergedProps = mergeProps(slotProps, children.props);
|
|
70
|
+
const composedRef = slotRef ? composeRefs(slotRef, childRef) : childRef;
|
|
71
|
+
|
|
72
|
+
return cloneElement(children, { ...mergedProps, ref: composedRef });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return Children.count(children) > 1 ? Children.only(null) : null;
|
|
76
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Children,
|
|
3
|
+
type CSSProperties,
|
|
4
|
+
Fragment,
|
|
5
|
+
isValidElement,
|
|
6
|
+
type PropsWithChildren,
|
|
7
|
+
type ReactElement,
|
|
8
|
+
type ReactNode,
|
|
9
|
+
type Ref,
|
|
10
|
+
type RefObject,
|
|
11
|
+
} from 'react';
|
|
12
|
+
import { Slottable } from '.';
|
|
13
|
+
|
|
14
|
+
export function isSlottable(
|
|
15
|
+
child: ReactNode,
|
|
16
|
+
): child is ReactElement<PropsWithChildren> {
|
|
17
|
+
return isValidElement(child) && child.type === Slottable;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function flattenChildren(children: ReactNode): Array<ReactNode> {
|
|
21
|
+
const result: Array<ReactNode> = [];
|
|
22
|
+
|
|
23
|
+
for (const child of Children.toArray(children)) {
|
|
24
|
+
if (isFragment(child)) {
|
|
25
|
+
result.push(...flattenChildren(child.props.children));
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
result.push(child);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return result;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function composeRefs<T>(...refs: Array<Ref<T> | undefined>) {
|
|
35
|
+
return (node: T | null) => {
|
|
36
|
+
for (const ref of refs) {
|
|
37
|
+
if (typeof ref === 'function') {
|
|
38
|
+
ref(node);
|
|
39
|
+
} else if (isRefObject<T>(ref)) {
|
|
40
|
+
ref.current = node;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function isEventHandler(propName: string) {
|
|
47
|
+
return /^on[A-Z]/.test(propName);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function isEventHandlerValue(
|
|
51
|
+
value: unknown,
|
|
52
|
+
): value is (...args: Array<unknown>) => void {
|
|
53
|
+
return typeof value === 'function';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function isRef<T>(value: unknown): value is Ref<T> {
|
|
57
|
+
return (
|
|
58
|
+
typeof value === 'function' ||
|
|
59
|
+
(typeof value === 'object' && value !== null && 'current' in value)
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function isStyleObject(value: unknown): value is CSSProperties {
|
|
64
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function isDefaultPrevented(event: unknown) {
|
|
68
|
+
if (!event || typeof event !== 'object') {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return Reflect.get(event, 'defaultPrevented') === true;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function isFragment(
|
|
76
|
+
child: ReactNode,
|
|
77
|
+
): child is ReactElement<PropsWithChildren> {
|
|
78
|
+
return isValidElement(child) && child.type === Fragment;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function isRefObject<T>(ref: unknown): ref is RefObject<T | null> {
|
|
82
|
+
return typeof ref === 'object' && ref !== null && 'current' in ref;
|
|
83
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { ComponentType, FC } from 'react';
|
|
2
|
+
import { cn } from './common';
|
|
3
|
+
|
|
4
|
+
type ClassNameDefaultize<P extends { className?: string }> = Omit<
|
|
5
|
+
P,
|
|
6
|
+
'className'
|
|
7
|
+
> & { className?: string };
|
|
8
|
+
|
|
9
|
+
export function withDefaultClassNames<P extends { className?: string }>(
|
|
10
|
+
Component: ComponentType<P>,
|
|
11
|
+
defaultClassNames: string,
|
|
12
|
+
): FC<ClassNameDefaultize<P>> {
|
|
13
|
+
type Props = ClassNameDefaultize<P>;
|
|
14
|
+
|
|
15
|
+
const Wrapped: FC<Props> = ({ className, ...restProps }) => {
|
|
16
|
+
const mergedClassName = className
|
|
17
|
+
? cn(defaultClassNames, className)
|
|
18
|
+
: defaultClassNames;
|
|
19
|
+
|
|
20
|
+
return <Component {...(restProps as P)} className={mergedClassName} />;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
Wrapped.displayName = `withDefaultClassNames<${Component.displayName || 'Unknown'}>`;
|
|
24
|
+
|
|
25
|
+
return Wrapped;
|
|
26
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { ComponentType } from 'react';
|
|
2
|
+
|
|
3
|
+
export { default as cn } from 'classnames';
|
|
4
|
+
|
|
5
|
+
export function withDisplayName<C extends ComponentType<never>>(
|
|
6
|
+
Component: C,
|
|
7
|
+
name: string,
|
|
8
|
+
): C {
|
|
9
|
+
Object.assign(Component, { displayName: name });
|
|
10
|
+
|
|
11
|
+
return Component;
|
|
12
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ComponentType,
|
|
3
|
+
cloneElement,
|
|
4
|
+
type FC,
|
|
5
|
+
type HTMLAttributes,
|
|
6
|
+
type ReactElement,
|
|
7
|
+
type ReactNode,
|
|
8
|
+
} from 'react';
|
|
9
|
+
import {
|
|
10
|
+
isDefaultPrevented,
|
|
11
|
+
isEventHandler,
|
|
12
|
+
isEventHandlerValue,
|
|
13
|
+
isStyleObject,
|
|
14
|
+
} from '../slot/utils';
|
|
15
|
+
import { cn } from './common';
|
|
16
|
+
|
|
17
|
+
export function withDefaultProps<P extends object, K extends keyof P>(
|
|
18
|
+
Component: ComponentType<Pick<P, K> & Omit<P, K>>,
|
|
19
|
+
defaultProps: Pick<P, K>,
|
|
20
|
+
): FC<Omit<P, K>> {
|
|
21
|
+
type Props = Omit<P, K>;
|
|
22
|
+
|
|
23
|
+
const Wrapped: FC<Props> = (props) => {
|
|
24
|
+
const propsWithDefaults = { ...defaultProps, ...props };
|
|
25
|
+
return <Component {...propsWithDefaults} />;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
Wrapped.displayName = Component.displayName || 'Unknown';
|
|
29
|
+
|
|
30
|
+
return Wrapped;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function withGenericDefaultProps<R extends CallableFunction>(
|
|
34
|
+
Component: CallableFunction,
|
|
35
|
+
defaultProps: Record<string, unknown>,
|
|
36
|
+
): R;
|
|
37
|
+
export function withGenericDefaultProps(
|
|
38
|
+
Component: CallableFunction,
|
|
39
|
+
defaultProps: Record<string, unknown>,
|
|
40
|
+
) {
|
|
41
|
+
const Wrapped = (props: Record<string, unknown>) => {
|
|
42
|
+
const propsWithDefaults = { ...defaultProps, ...props };
|
|
43
|
+
return Reflect.apply(Component, undefined, [propsWithDefaults]);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
Reflect.set(
|
|
47
|
+
Wrapped,
|
|
48
|
+
'displayName',
|
|
49
|
+
Reflect.get(Component, 'displayName') ?? 'Unknown',
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
return Wrapped;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function renderWithAdditionalProps<P>(
|
|
56
|
+
element: ReactElement<P> | undefined,
|
|
57
|
+
additionalProps: Partial<P>,
|
|
58
|
+
): ReactNode {
|
|
59
|
+
if (!element) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return cloneElement(element, additionalProps);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function mergeProps(
|
|
67
|
+
parentProps: HTMLAttributes<HTMLElement>,
|
|
68
|
+
childProps: Record<string, unknown>,
|
|
69
|
+
) {
|
|
70
|
+
const overrideProps: Record<string, unknown> = { ...childProps };
|
|
71
|
+
|
|
72
|
+
for (const propName of Object.keys(childProps)) {
|
|
73
|
+
const slotValue = Reflect.get(parentProps, propName);
|
|
74
|
+
const childValue = Reflect.get(childProps, propName);
|
|
75
|
+
|
|
76
|
+
if (isEventHandler(propName)) {
|
|
77
|
+
const slotHandler = isEventHandlerValue(slotValue)
|
|
78
|
+
? slotValue
|
|
79
|
+
: undefined;
|
|
80
|
+
const childHandler = isEventHandlerValue(childValue)
|
|
81
|
+
? childValue
|
|
82
|
+
: undefined;
|
|
83
|
+
|
|
84
|
+
if (!slotHandler) {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (!childHandler) {
|
|
88
|
+
overrideProps[propName] = slotHandler;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
overrideProps[propName] = (...args: Array<unknown>) => {
|
|
93
|
+
childHandler(...args);
|
|
94
|
+
if (!isDefaultPrevented(args[0])) {
|
|
95
|
+
slotHandler(...args);
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (propName === 'style') {
|
|
102
|
+
const slotStyle = isStyleObject(slotValue) ? slotValue : undefined;
|
|
103
|
+
const childStyle = isStyleObject(childValue) ? childValue : undefined;
|
|
104
|
+
|
|
105
|
+
overrideProps[propName] = {
|
|
106
|
+
...slotStyle,
|
|
107
|
+
...childStyle,
|
|
108
|
+
};
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (propName === 'className') {
|
|
113
|
+
const slotClassName =
|
|
114
|
+
typeof slotValue === 'string' ? slotValue : undefined;
|
|
115
|
+
const childClassName =
|
|
116
|
+
typeof childValue === 'string' ? childValue : undefined;
|
|
117
|
+
|
|
118
|
+
overrideProps[propName] = cn(slotClassName, childClassName);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return { ...parentProps, ...overrideProps };
|
|
123
|
+
}
|
package/tsdown.config.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { esmExternalRequirePlugin } from 'rolldown/plugins';
|
|
2
|
+
import { defineConfig } from 'tsdown';
|
|
3
|
+
|
|
4
|
+
export default defineConfig({
|
|
5
|
+
entry: 'src/index.ts',
|
|
6
|
+
platform: 'neutral',
|
|
7
|
+
target: 'ES2024',
|
|
8
|
+
outDir: 'dist',
|
|
9
|
+
format: 'esm',
|
|
10
|
+
clean: true,
|
|
11
|
+
minify: true,
|
|
12
|
+
dts: true,
|
|
13
|
+
exports: true,
|
|
14
|
+
inlineOnly: false,
|
|
15
|
+
plugins: [esmExternalRequirePlugin({ external: ['react', 'classnames'] })],
|
|
16
|
+
});
|